Add Proxy API types with CEL validation (Step 1)
Full ProxySpec/ProxyStatus/Proxy types per the plan: PlacementSpec,
CloudInitSpec, EndpointSpec, HealthCheckSpec, SecretKeySelector, all
defaults, and 7 CEL XValidation rules enforcing mode/provider
immutability, provider/endpoint required-iff-Managed/External, and
cloud-init exactly-one-of inline/secretRef.
Applies the four corrections identified during planning that would
otherwise be silent bugs: MaxLeases as *int32 (so an explicit 0 survives
Go round-trips instead of re-defaulting to 5), HealthCheck's
default={} marker (so nested defaults apply even when the field is
omitted entirely), MinLength=1 on Provider/CloudInit.Inline (so the CEL
has() checks stay simple), and listType=map on Conditions.
Adds pure helpers (EffectivePort, EffectiveHost, HealthCheckOrDefault,
MaxLeasesOrDefault) with table-driven tests, for use by the health
engine, discovery API, and spec-hash computation in later steps.
Patches the scaffolded placeholder controller test's resource literal to
a schema-valid spec so it survives the new CRD validation — the test
itself is rewritten wholesale in Step 4 alongside the real reconciler.
Regenerated deepcopy and the CRD; make test green (envtest confirmed all
7 CEL rules enforced by a real apiserver).
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
87
api/v1alpha1/helpers.go
Normal file
87
api/v1alpha1/helpers.go
Normal file
@@ -0,0 +1,87 @@
|
||||
/*
|
||||
Copyright 2026.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
package v1alpha1
|
||||
|
||||
// EffectivePort returns the port a client should use to reach the proxy:
|
||||
// spec.endpoint.port for External proxies, spec.port for Managed proxies.
|
||||
// Falls back to DefaultPort if the relevant field is unset, so callers that
|
||||
// bypass CRD structural defaulting (unit tests, fake clients) still get a
|
||||
// sane value.
|
||||
func (p *Proxy) EffectivePort() int32 {
|
||||
if p.Spec.Mode == ModeExternal && p.Spec.Endpoint != nil {
|
||||
if p.Spec.Endpoint.Port != 0 {
|
||||
return p.Spec.Endpoint.Port
|
||||
}
|
||||
return DefaultPort
|
||||
}
|
||||
if p.Spec.Port != 0 {
|
||||
return p.Spec.Port
|
||||
}
|
||||
return DefaultPort
|
||||
}
|
||||
|
||||
// EffectiveHost returns the host a client should use to reach the proxy:
|
||||
// spec.endpoint.host for External proxies, status.ip for Managed proxies
|
||||
// (populated once the VM is running).
|
||||
func (p *Proxy) EffectiveHost() string {
|
||||
if p.Spec.Mode == ModeExternal && p.Spec.Endpoint != nil {
|
||||
return p.Spec.Endpoint.Host
|
||||
}
|
||||
return p.Status.IP
|
||||
}
|
||||
|
||||
// HealthCheckOrDefault returns spec.healthCheck with every unset field
|
||||
// filled from its default. CRD structural defaulting (the default={} marker
|
||||
// on ProxySpec.HealthCheck) already does this for objects that went through
|
||||
// the API server; this is for callers that didn't (unit tests, fake
|
||||
// clients, or a Proxy constructed directly in Go).
|
||||
func (p *Proxy) HealthCheckOrDefault() HealthCheckSpec {
|
||||
var hc HealthCheckSpec
|
||||
if p.Spec.HealthCheck != nil {
|
||||
hc = *p.Spec.HealthCheck
|
||||
}
|
||||
if hc.ProbeURL == "" {
|
||||
hc.ProbeURL = DefaultProbeURL
|
||||
}
|
||||
if hc.IntervalSeconds == 0 {
|
||||
hc.IntervalSeconds = DefaultHealthCheckIntervalSeconds
|
||||
}
|
||||
if hc.TimeoutSeconds == 0 {
|
||||
hc.TimeoutSeconds = DefaultHealthCheckTimeoutSeconds
|
||||
}
|
||||
if hc.FailureThreshold == 0 {
|
||||
hc.FailureThreshold = DefaultFailureThreshold
|
||||
}
|
||||
if hc.SuccessThreshold == 0 {
|
||||
hc.SuccessThreshold = DefaultSuccessThreshold
|
||||
}
|
||||
if len(hc.ExpectedStatusCodes) == 0 {
|
||||
hc.ExpectedStatusCodes = append([]int32(nil), DefaultExpectedStatusCodes...)
|
||||
}
|
||||
return hc
|
||||
}
|
||||
|
||||
// MaxLeasesOrDefault returns spec.maxLeases, or DefaultMaxLeases if unset.
|
||||
// spec.maxLeases is a pointer specifically so an explicit 0 (unleasable) is
|
||||
// distinguishable from "unset" and survives Go round-trips; this helper
|
||||
// preserves that distinction.
|
||||
func (p *Proxy) MaxLeasesOrDefault() int32 {
|
||||
if p.Spec.MaxLeases != nil {
|
||||
return *p.Spec.MaxLeases
|
||||
}
|
||||
return DefaultMaxLeases
|
||||
}
|
||||
190
api/v1alpha1/helpers_test.go
Normal file
190
api/v1alpha1/helpers_test.go
Normal file
@@ -0,0 +1,190 @@
|
||||
/*
|
||||
Copyright 2026.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
package v1alpha1
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestEffectivePort(t *testing.T) {
|
||||
t.Parallel()
|
||||
tests := []struct {
|
||||
name string
|
||||
spec ProxySpec
|
||||
want int32
|
||||
}{
|
||||
{
|
||||
name: "managed with explicit port",
|
||||
spec: ProxySpec{Mode: ModeManaged, Port: 8080},
|
||||
want: 8080,
|
||||
},
|
||||
{
|
||||
name: "managed with unset port falls back to default",
|
||||
spec: ProxySpec{Mode: ModeManaged},
|
||||
want: DefaultPort,
|
||||
},
|
||||
{
|
||||
name: "external with explicit endpoint port",
|
||||
spec: ProxySpec{Mode: ModeExternal, Endpoint: &EndpointSpec{Host: "1.2.3.4", Port: 9999}},
|
||||
want: 9999,
|
||||
},
|
||||
{
|
||||
name: "external with unset endpoint port falls back to default",
|
||||
spec: ProxySpec{Mode: ModeExternal, Endpoint: &EndpointSpec{Host: "1.2.3.4"}},
|
||||
want: DefaultPort,
|
||||
},
|
||||
{
|
||||
name: "external with nil endpoint falls back to spec.port",
|
||||
spec: ProxySpec{Mode: ModeExternal, Port: 3000},
|
||||
want: 3000,
|
||||
},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
p := &Proxy{Spec: tc.spec}
|
||||
if got := p.EffectivePort(); got != tc.want {
|
||||
t.Errorf("EffectivePort() = %d, want %d", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestEffectiveHost(t *testing.T) {
|
||||
t.Parallel()
|
||||
tests := []struct {
|
||||
name string
|
||||
spec ProxySpec
|
||||
status ProxyStatus
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "managed uses status.ip",
|
||||
spec: ProxySpec{Mode: ModeManaged},
|
||||
status: ProxyStatus{IP: "10.0.0.5"},
|
||||
want: "10.0.0.5",
|
||||
},
|
||||
{
|
||||
name: "external uses endpoint.host",
|
||||
spec: ProxySpec{Mode: ModeExternal, Endpoint: &EndpointSpec{Host: "proxy.example.com"}},
|
||||
want: "proxy.example.com",
|
||||
},
|
||||
{
|
||||
name: "external with nil endpoint falls back to status.ip",
|
||||
spec: ProxySpec{Mode: ModeExternal},
|
||||
status: ProxyStatus{IP: "10.0.0.6"},
|
||||
want: "10.0.0.6",
|
||||
},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
p := &Proxy{Spec: tc.spec, Status: tc.status}
|
||||
if got := p.EffectiveHost(); got != tc.want {
|
||||
t.Errorf("EffectiveHost() = %q, want %q", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestHealthCheckOrDefault(t *testing.T) {
|
||||
t.Parallel()
|
||||
fullDefault := HealthCheckSpec{
|
||||
ProbeURL: DefaultProbeURL,
|
||||
IntervalSeconds: DefaultHealthCheckIntervalSeconds,
|
||||
TimeoutSeconds: DefaultHealthCheckTimeoutSeconds,
|
||||
FailureThreshold: DefaultFailureThreshold,
|
||||
SuccessThreshold: DefaultSuccessThreshold,
|
||||
ExpectedStatusCodes: []int32{200, 204},
|
||||
}
|
||||
|
||||
t.Run("nil healthCheck returns full default", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
p := &Proxy{Spec: ProxySpec{}}
|
||||
got := p.HealthCheckOrDefault()
|
||||
if !reflect.DeepEqual(got, fullDefault) {
|
||||
t.Errorf("HealthCheckOrDefault() = %+v, want %+v", got, fullDefault)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("partial healthCheck fills only unset fields", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
p := &Proxy{Spec: ProxySpec{HealthCheck: &HealthCheckSpec{
|
||||
ProbeURL: "http://internal/probe",
|
||||
FailureThreshold: 7,
|
||||
}}}
|
||||
got := p.HealthCheckOrDefault()
|
||||
want := fullDefault
|
||||
want.ProbeURL = "http://internal/probe"
|
||||
want.FailureThreshold = 7
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Errorf("HealthCheckOrDefault() = %+v, want %+v", got, want)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("fully set healthCheck passes through unchanged", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
custom := HealthCheckSpec{
|
||||
ProbeURL: "http://internal/probe",
|
||||
IntervalSeconds: 10,
|
||||
TimeoutSeconds: 2,
|
||||
FailureThreshold: 5,
|
||||
SuccessThreshold: 2,
|
||||
ExpectedStatusCodes: []int32{200},
|
||||
}
|
||||
p := &Proxy{Spec: ProxySpec{HealthCheck: &custom}}
|
||||
got := p.HealthCheckOrDefault()
|
||||
if !reflect.DeepEqual(got, custom) {
|
||||
t.Errorf("HealthCheckOrDefault() = %+v, want %+v", got, custom)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("does not mutate the original spec", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
hc := &HealthCheckSpec{ProbeURL: "http://internal/probe"}
|
||||
p := &Proxy{Spec: ProxySpec{HealthCheck: hc}}
|
||||
_ = p.HealthCheckOrDefault()
|
||||
if hc.IntervalSeconds != 0 {
|
||||
t.Errorf("original HealthCheckSpec was mutated: IntervalSeconds = %d, want 0", hc.IntervalSeconds)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestMaxLeasesOrDefault(t *testing.T) {
|
||||
t.Parallel()
|
||||
zero := int32(0)
|
||||
seven := int32(7)
|
||||
tests := []struct {
|
||||
name string
|
||||
spec ProxySpec
|
||||
want int32
|
||||
}{
|
||||
{name: "unset falls back to default", spec: ProxySpec{}, want: DefaultMaxLeases},
|
||||
{name: "explicit zero is preserved (unleasable)", spec: ProxySpec{MaxLeases: &zero}, want: 0},
|
||||
{name: "explicit non-zero is preserved", spec: ProxySpec{MaxLeases: &seven}, want: 7},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
p := &Proxy{Spec: tc.spec}
|
||||
if got := p.MaxLeasesOrDefault(); got != tc.want {
|
||||
t.Errorf("MaxLeasesOrDefault() = %d, want %d", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -21,46 +21,302 @@ import (
|
||||
"k8s.io/apimachinery/pkg/runtime"
|
||||
)
|
||||
|
||||
// EDIT THIS FILE! THIS IS SCAFFOLDING FOR YOU TO OWN!
|
||||
// NOTE: json tags are required. Any new fields you add must have json tags for the fields to be serialized.
|
||||
// ProvisioningMode selects who owns the lifecycle of the proxy VM.
|
||||
type ProvisioningMode string
|
||||
|
||||
// ProxySpec defines the desired state of Proxy
|
||||
type ProxySpec struct {
|
||||
// INSERT ADDITIONAL SPEC FIELDS - desired state of cluster
|
||||
// Important: Run "make" to regenerate code after modifying this file
|
||||
// The following markers will use OpenAPI v3 schema to validate the value
|
||||
// More info: https://book.kubebuilder.io/reference/markers/crd-validation.html
|
||||
const (
|
||||
// ModeManaged means the operator creates, monitors, and deletes the VM.
|
||||
ModeManaged ProvisioningMode = "Managed"
|
||||
// ModeExternal means the VM exists outside the operator's control; the
|
||||
// operator only tracks and healthchecks it.
|
||||
ModeExternal ProvisioningMode = "External"
|
||||
)
|
||||
|
||||
// foo is an example field of Proxy. Edit proxy_types.go to remove/update
|
||||
// ProxyPhase is a high-level, human-readable summary of status.conditions.
|
||||
type ProxyPhase string
|
||||
|
||||
const (
|
||||
PhasePending ProxyPhase = "Pending"
|
||||
PhaseProvisioning ProxyPhase = "Provisioning"
|
||||
PhaseReady ProxyPhase = "Ready"
|
||||
PhaseUnhealthy ProxyPhase = "Unhealthy"
|
||||
PhaseDeleting ProxyPhase = "Deleting"
|
||||
PhaseFailed ProxyPhase = "Failed"
|
||||
)
|
||||
|
||||
const (
|
||||
// ConditionProvisioned reflects the state of the underlying VM (Managed)
|
||||
// or endpoint (External).
|
||||
ConditionProvisioned = "Provisioned"
|
||||
// ConditionHealthy reflects the result of the through-the-proxy healthcheck.
|
||||
ConditionHealthy = "Healthy"
|
||||
|
||||
// FinalizerName is set on Managed proxies so deletion can clean up the
|
||||
// provider-side VM before the CR is removed. External proxies never get
|
||||
// this finalizer.
|
||||
FinalizerName = "crawl.example.com/proxy-cleanup"
|
||||
|
||||
// AnnotationSpecHash stores the hash of the replacement-triggering spec
|
||||
// fields (placement, resolved cloud-init, port) as of the last successful
|
||||
// provision. A mismatch against the freshly computed hash means the VM
|
||||
// must be replaced.
|
||||
AnnotationSpecHash = "crawl.example.com/spec-hash"
|
||||
)
|
||||
|
||||
// Defaults, applied both by CRD structural defaulting (kubebuilder:default
|
||||
// markers below) and by the OrDefault helpers for callers that bypass the
|
||||
// API server (unit tests, fake clients).
|
||||
const (
|
||||
DefaultPort int32 = 3128
|
||||
DefaultMaxLeases int32 = 5
|
||||
DefaultProbeURL string = "https://www.gstatic.com/generate_204"
|
||||
DefaultHealthCheckIntervalSeconds int32 = 30
|
||||
DefaultHealthCheckTimeoutSeconds int32 = 5
|
||||
DefaultFailureThreshold int32 = 3
|
||||
DefaultSuccessThreshold int32 = 1
|
||||
DefaultCloudInitSecretKey string = "user-data"
|
||||
)
|
||||
|
||||
// DefaultExpectedStatusCodes is the default set of HTTP status codes a
|
||||
// healthcheck probe treats as success.
|
||||
var DefaultExpectedStatusCodes = []int32{200, 204}
|
||||
|
||||
// PlacementSpec is provider-opaque placement/size configuration. It is kept
|
||||
// as a small typed struct rather than map[string]string; providers ignore
|
||||
// fields that don't apply to them.
|
||||
type PlacementSpec struct {
|
||||
// region is the provider's region identifier (e.g. "europe-west1").
|
||||
// +optional
|
||||
Foo *string `json:"foo,omitempty"`
|
||||
Region string `json:"region,omitempty"`
|
||||
|
||||
// zone is the provider's zone identifier (e.g. "europe-west1-b").
|
||||
// +optional
|
||||
Zone string `json:"zone,omitempty"`
|
||||
|
||||
// machineType is the provider's machine/instance type (e.g. "e2-micro").
|
||||
// +optional
|
||||
MachineType string `json:"machineType,omitempty"`
|
||||
|
||||
// image is the boot image reference.
|
||||
// +optional
|
||||
Image string `json:"image,omitempty"`
|
||||
}
|
||||
|
||||
// SecretKeySelector references a key within a Secret in the same namespace.
|
||||
type SecretKeySelector struct {
|
||||
// name is the Secret's name.
|
||||
// +kubebuilder:validation:MinLength=1
|
||||
Name string `json:"name"`
|
||||
|
||||
// key is the data key holding the cloud-init user-data.
|
||||
// +kubebuilder:default=user-data
|
||||
// +optional
|
||||
Key string `json:"key,omitempty"`
|
||||
}
|
||||
|
||||
// CloudInitSpec supplies cloud-init user-data either inline or from a
|
||||
// Secret. Exactly one of Inline or SecretRef must be set. Changing the
|
||||
// resolved content on a Managed proxy triggers replacement.
|
||||
//
|
||||
// +kubebuilder:validation:XValidation:rule="has(self.inline) != has(self.secretRef)",message="exactly one of inline or secretRef must be set"
|
||||
type CloudInitSpec struct {
|
||||
// inline is the literal cloud-init user-data.
|
||||
// +kubebuilder:validation:MinLength=1
|
||||
// +kubebuilder:validation:MaxLength=262144
|
||||
// +optional
|
||||
Inline string `json:"inline,omitempty"`
|
||||
|
||||
// secretRef points at a Secret key holding the cloud-init user-data.
|
||||
// +optional
|
||||
SecretRef *SecretKeySelector `json:"secretRef,omitempty"`
|
||||
}
|
||||
|
||||
// EndpointSpec identifies an External proxy's network location.
|
||||
type EndpointSpec struct {
|
||||
// host is the proxy's hostname or IP address.
|
||||
// +kubebuilder:validation:MinLength=1
|
||||
Host string `json:"host"`
|
||||
|
||||
// port is the port the proxy listens on.
|
||||
// +kubebuilder:default=3128
|
||||
// +kubebuilder:validation:Minimum=1
|
||||
// +kubebuilder:validation:Maximum=65535
|
||||
// +optional
|
||||
Port int32 `json:"port,omitempty"`
|
||||
}
|
||||
|
||||
// HealthCheckSpec configures the through-the-proxy healthcheck. All fields
|
||||
// are defaulted, both by the CRD (kubebuilder:default markers, activated by
|
||||
// the default={} marker on ProxySpec.HealthCheck) and by
|
||||
// Proxy.HealthCheckOrDefault for callers that bypass the API server.
|
||||
type HealthCheckSpec struct {
|
||||
// probeURL is fetched through the proxy on every probe.
|
||||
// +kubebuilder:default="https://www.gstatic.com/generate_204"
|
||||
// +kubebuilder:validation:MinLength=1
|
||||
// +optional
|
||||
ProbeURL string `json:"probeURL,omitempty"`
|
||||
|
||||
// intervalSeconds is the time between probes for a given proxy.
|
||||
// +kubebuilder:default=30
|
||||
// +kubebuilder:validation:Minimum=5
|
||||
// +optional
|
||||
IntervalSeconds int32 `json:"intervalSeconds,omitempty"`
|
||||
|
||||
// timeoutSeconds bounds a single probe, including the CONNECT tunnel
|
||||
// setup and the TLS handshake through it.
|
||||
// +kubebuilder:default=5
|
||||
// +kubebuilder:validation:Minimum=1
|
||||
// +optional
|
||||
TimeoutSeconds int32 `json:"timeoutSeconds,omitempty"`
|
||||
|
||||
// failureThreshold is the number of consecutive failed probes required
|
||||
// to transition Healthy -> False.
|
||||
// +kubebuilder:default=3
|
||||
// +kubebuilder:validation:Minimum=1
|
||||
// +optional
|
||||
FailureThreshold int32 `json:"failureThreshold,omitempty"`
|
||||
|
||||
// successThreshold is the number of consecutive successful probes
|
||||
// required to transition Healthy -> True.
|
||||
// +kubebuilder:default=1
|
||||
// +kubebuilder:validation:Minimum=1
|
||||
// +optional
|
||||
SuccessThreshold int32 `json:"successThreshold,omitempty"`
|
||||
|
||||
// expectedStatusCodes are the HTTP status codes a probe response must
|
||||
// match to count as successful.
|
||||
// +kubebuilder:default={200,204}
|
||||
// +kubebuilder:validation:MaxItems=8
|
||||
// +optional
|
||||
ExpectedStatusCodes []int32 `json:"expectedStatusCodes,omitempty"`
|
||||
}
|
||||
|
||||
// ProxySpec defines the desired state of Proxy.
|
||||
//
|
||||
// Cross-field rules are deliberately split into one XValidation marker per
|
||||
// concern: a rule referencing oldSelf is skipped on CREATE, so an
|
||||
// immutability rule and a required-iff rule must never be combined into one
|
||||
// `&&`-ed expression, or the required-iff half would silently stop applying
|
||||
// on CREATE. The provider-immutability rule uses has(self.x)==has(oldSelf.x)
|
||||
// rather than a field-level self==oldSelf rule because Provider is optional:
|
||||
// a field-level rule does not fire when the field is absent on either side,
|
||||
// which would silently allow adding or removing it after creation.
|
||||
//
|
||||
// +kubebuilder:validation:XValidation:rule="self.mode == oldSelf.mode",message="mode is immutable"
|
||||
// +kubebuilder:validation:XValidation:rule="has(self.provider) == has(oldSelf.provider) && (!has(self.provider) || self.provider == oldSelf.provider)",message="provider is immutable"
|
||||
// +kubebuilder:validation:XValidation:rule="self.mode != 'Managed' || has(self.provider)",message="provider is required when mode is Managed"
|
||||
// +kubebuilder:validation:XValidation:rule="self.mode != 'External' || !has(self.provider)",message="provider must not be set when mode is External"
|
||||
// +kubebuilder:validation:XValidation:rule="self.mode != 'External' || has(self.endpoint)",message="endpoint is required when mode is External"
|
||||
// +kubebuilder:validation:XValidation:rule="self.mode != 'Managed' || !has(self.endpoint)",message="endpoint must not be set when mode is Managed"
|
||||
type ProxySpec struct {
|
||||
// mode selects who owns the VM lifecycle: Managed (operator creates it)
|
||||
// or External (operator only tracks and healthchecks it). Immutable.
|
||||
// +kubebuilder:validation:Enum=Managed;External
|
||||
Mode ProvisioningMode `json:"mode"`
|
||||
|
||||
// provider is the name of a configured provider ("mock", "gcp-eu", ...).
|
||||
// Required iff mode is Managed; must be unset iff mode is External.
|
||||
// Immutable.
|
||||
// +kubebuilder:validation:MinLength=1
|
||||
// +kubebuilder:validation:MaxLength=63
|
||||
// +optional
|
||||
Provider string `json:"provider,omitempty"`
|
||||
|
||||
// placement is provider-opaque placement/size configuration. Only
|
||||
// meaningful for Managed proxies.
|
||||
// +optional
|
||||
Placement *PlacementSpec `json:"placement,omitempty"`
|
||||
|
||||
// cloudInit supplies the VM's cloud-init user-data. Only meaningful for
|
||||
// Managed proxies. Changing the resolved content triggers replacement.
|
||||
// +optional
|
||||
CloudInit *CloudInitSpec `json:"cloudInit,omitempty"`
|
||||
|
||||
// endpoint identifies an External proxy's network location. Required
|
||||
// iff mode is External; must be unset iff mode is Managed.
|
||||
// +optional
|
||||
Endpoint *EndpointSpec `json:"endpoint,omitempty"`
|
||||
|
||||
// port is the port the proxy listens on once the VM is up. Only
|
||||
// meaningful for Managed proxies; External proxies use endpoint.port.
|
||||
// +kubebuilder:default=3128
|
||||
// +kubebuilder:validation:Minimum=1
|
||||
// +kubebuilder:validation:Maximum=65535
|
||||
// +optional
|
||||
Port int32 `json:"port,omitempty"`
|
||||
|
||||
// attributes are selection attributes exposed to the discovery API
|
||||
// (geo, asn, purpose, ...). Deliberately separate from Kubernetes object
|
||||
// labels, which stay an operator implementation concern.
|
||||
// +kubebuilder:validation:MaxProperties=32
|
||||
// +optional
|
||||
Attributes map[string]string `json:"attributes,omitempty"`
|
||||
|
||||
// healthCheck configures the through-the-proxy healthcheck. All nested
|
||||
// fields are defaulted; the default={} marker ensures a proxy that omits
|
||||
// healthCheck entirely still gets every nested default.
|
||||
// +kubebuilder:default={}
|
||||
// +optional
|
||||
HealthCheck *HealthCheckSpec `json:"healthCheck,omitempty"`
|
||||
|
||||
// maxLeases is the maximum number of concurrent leases handed out for
|
||||
// this proxy. 0 means unleasable (list-only visibility). A pointer so an
|
||||
// explicit 0 survives Go round-trips instead of being re-defaulted to 5.
|
||||
// +kubebuilder:default=5
|
||||
// +kubebuilder:validation:Minimum=0
|
||||
// +kubebuilder:validation:Maximum=1000
|
||||
// +optional
|
||||
MaxLeases *int32 `json:"maxLeases,omitempty"`
|
||||
}
|
||||
|
||||
// ProxyStatus defines the observed state of Proxy.
|
||||
type ProxyStatus struct {
|
||||
// INSERT ADDITIONAL STATUS FIELD - define observed state of cluster
|
||||
// Important: Run "make" to regenerate code after modifying this file
|
||||
// phase is a high-level, human-readable summary derived from conditions.
|
||||
// One of Pending, Provisioning, Ready, Unhealthy, Deleting, Failed.
|
||||
// +optional
|
||||
Phase ProxyPhase `json:"phase,omitempty"`
|
||||
|
||||
// For Kubernetes API conventions, see:
|
||||
// https://github.com/kubernetes/community/blob/master/contributors/devel/sig-architecture/api-conventions.md#typical-status-properties
|
||||
// providerID is the opaque cloud resource ID returned by the provider.
|
||||
// Empty for External proxies.
|
||||
// +optional
|
||||
ProviderID string `json:"providerID,omitempty"`
|
||||
|
||||
// ip is the proxy's current IP address: the VM's address for Managed
|
||||
// proxies, or spec.endpoint.host for External proxies.
|
||||
// +optional
|
||||
IP string `json:"ip,omitempty"`
|
||||
|
||||
// conditions represent the current state of the Proxy resource.
|
||||
// Each condition has a unique type and reflects the status of a specific aspect of the resource.
|
||||
//
|
||||
// Standard condition types include:
|
||||
// - "Available": the resource is fully functional
|
||||
// - "Progressing": the resource is being created or updated
|
||||
// - "Degraded": the resource failed to reach or maintain its desired state
|
||||
//
|
||||
// The status of each condition is one of True, False, or Unknown.
|
||||
// Standard types are Provisioned and Healthy.
|
||||
// +listType=map
|
||||
// +listMapKey=type
|
||||
// +optional
|
||||
Conditions []metav1.Condition `json:"conditions,omitempty"`
|
||||
|
||||
// lastHealthCheckTime is the time of the last status-affecting probe,
|
||||
// not the time of the most recent probe — probes that don't change the
|
||||
// Healthy condition or move latency materially don't write status.
|
||||
// +optional
|
||||
LastHealthCheckTime *metav1.Time `json:"lastHealthCheckTime,omitempty"`
|
||||
|
||||
// latencyMillis is the latency of the last status-affecting probe.
|
||||
// +optional
|
||||
LatencyMillis int64 `json:"latencyMillis,omitempty"`
|
||||
|
||||
// observedGeneration is the .metadata.generation last reconciled.
|
||||
// +optional
|
||||
ObservedGeneration int64 `json:"observedGeneration,omitempty"`
|
||||
}
|
||||
|
||||
// +kubebuilder:object:root=true
|
||||
// +kubebuilder:subresource:status
|
||||
// +kubebuilder:resource:shortName=px
|
||||
// +kubebuilder:printcolumn:name="Mode",type=string,JSONPath=`.spec.mode`
|
||||
// +kubebuilder:printcolumn:name="Provider",type=string,JSONPath=`.spec.provider`
|
||||
// +kubebuilder:printcolumn:name="Phase",type=string,JSONPath=`.status.phase`
|
||||
// +kubebuilder:printcolumn:name="IP",type=string,JSONPath=`.status.ip`
|
||||
// +kubebuilder:printcolumn:name="Healthy",type=string,JSONPath=`.status.conditions[?(@.type=="Healthy")].status`
|
||||
// +kubebuilder:printcolumn:name="Age",type=date,JSONPath=`.metadata.creationTimestamp`
|
||||
|
||||
// Proxy is the Schema for the proxies API
|
||||
type Proxy struct {
|
||||
|
||||
@@ -25,6 +25,76 @@ import (
|
||||
"k8s.io/apimachinery/pkg/runtime"
|
||||
)
|
||||
|
||||
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
|
||||
func (in *CloudInitSpec) DeepCopyInto(out *CloudInitSpec) {
|
||||
*out = *in
|
||||
if in.SecretRef != nil {
|
||||
in, out := &in.SecretRef, &out.SecretRef
|
||||
*out = new(SecretKeySelector)
|
||||
**out = **in
|
||||
}
|
||||
}
|
||||
|
||||
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new CloudInitSpec.
|
||||
func (in *CloudInitSpec) DeepCopy() *CloudInitSpec {
|
||||
if in == nil {
|
||||
return nil
|
||||
}
|
||||
out := new(CloudInitSpec)
|
||||
in.DeepCopyInto(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
|
||||
func (in *EndpointSpec) DeepCopyInto(out *EndpointSpec) {
|
||||
*out = *in
|
||||
}
|
||||
|
||||
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new EndpointSpec.
|
||||
func (in *EndpointSpec) DeepCopy() *EndpointSpec {
|
||||
if in == nil {
|
||||
return nil
|
||||
}
|
||||
out := new(EndpointSpec)
|
||||
in.DeepCopyInto(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
|
||||
func (in *HealthCheckSpec) DeepCopyInto(out *HealthCheckSpec) {
|
||||
*out = *in
|
||||
if in.ExpectedStatusCodes != nil {
|
||||
in, out := &in.ExpectedStatusCodes, &out.ExpectedStatusCodes
|
||||
*out = make([]int32, len(*in))
|
||||
copy(*out, *in)
|
||||
}
|
||||
}
|
||||
|
||||
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new HealthCheckSpec.
|
||||
func (in *HealthCheckSpec) DeepCopy() *HealthCheckSpec {
|
||||
if in == nil {
|
||||
return nil
|
||||
}
|
||||
out := new(HealthCheckSpec)
|
||||
in.DeepCopyInto(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
|
||||
func (in *PlacementSpec) DeepCopyInto(out *PlacementSpec) {
|
||||
*out = *in
|
||||
}
|
||||
|
||||
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PlacementSpec.
|
||||
func (in *PlacementSpec) DeepCopy() *PlacementSpec {
|
||||
if in == nil {
|
||||
return nil
|
||||
}
|
||||
out := new(PlacementSpec)
|
||||
in.DeepCopyInto(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
|
||||
func (in *Proxy) DeepCopyInto(out *Proxy) {
|
||||
*out = *in
|
||||
@@ -87,9 +157,36 @@ func (in *ProxyList) DeepCopyObject() runtime.Object {
|
||||
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
|
||||
func (in *ProxySpec) DeepCopyInto(out *ProxySpec) {
|
||||
*out = *in
|
||||
if in.Foo != nil {
|
||||
in, out := &in.Foo, &out.Foo
|
||||
*out = new(string)
|
||||
if in.Placement != nil {
|
||||
in, out := &in.Placement, &out.Placement
|
||||
*out = new(PlacementSpec)
|
||||
**out = **in
|
||||
}
|
||||
if in.CloudInit != nil {
|
||||
in, out := &in.CloudInit, &out.CloudInit
|
||||
*out = new(CloudInitSpec)
|
||||
(*in).DeepCopyInto(*out)
|
||||
}
|
||||
if in.Endpoint != nil {
|
||||
in, out := &in.Endpoint, &out.Endpoint
|
||||
*out = new(EndpointSpec)
|
||||
**out = **in
|
||||
}
|
||||
if in.Attributes != nil {
|
||||
in, out := &in.Attributes, &out.Attributes
|
||||
*out = make(map[string]string, len(*in))
|
||||
for key, val := range *in {
|
||||
(*out)[key] = val
|
||||
}
|
||||
}
|
||||
if in.HealthCheck != nil {
|
||||
in, out := &in.HealthCheck, &out.HealthCheck
|
||||
*out = new(HealthCheckSpec)
|
||||
(*in).DeepCopyInto(*out)
|
||||
}
|
||||
if in.MaxLeases != nil {
|
||||
in, out := &in.MaxLeases, &out.MaxLeases
|
||||
*out = new(int32)
|
||||
**out = **in
|
||||
}
|
||||
}
|
||||
@@ -114,6 +211,10 @@ func (in *ProxyStatus) DeepCopyInto(out *ProxyStatus) {
|
||||
(*in)[i].DeepCopyInto(&(*out)[i])
|
||||
}
|
||||
}
|
||||
if in.LastHealthCheckTime != nil {
|
||||
in, out := &in.LastHealthCheckTime, &out.LastHealthCheckTime
|
||||
*out = (*in).DeepCopy()
|
||||
}
|
||||
}
|
||||
|
||||
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ProxyStatus.
|
||||
@@ -125,3 +226,18 @@ func (in *ProxyStatus) DeepCopy() *ProxyStatus {
|
||||
in.DeepCopyInto(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
|
||||
func (in *SecretKeySelector) DeepCopyInto(out *SecretKeySelector) {
|
||||
*out = *in
|
||||
}
|
||||
|
||||
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new SecretKeySelector.
|
||||
func (in *SecretKeySelector) DeepCopy() *SecretKeySelector {
|
||||
if in == nil {
|
||||
return nil
|
||||
}
|
||||
out := new(SecretKeySelector)
|
||||
in.DeepCopyInto(out)
|
||||
return out
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user