diff --git a/.claude/settings.json b/.claude/settings.json index cdc16e7..b91d2fb 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -40,12 +40,17 @@ "Bash(go install *)", "Bash(go env *)", "Bash(git rm *)", - "Bash(mkdir -p /Users/jan.novak/srv/go/egress-proxies-operator/docs/plans-executions)" + "Bash(mkdir -p /Users/jan.novak/srv/go/egress-proxies-operator/docs/plans-executions)", + "Bash(cd /Users/jan.novak/srv/go/egress-proxies-operator *)", + "Bash(echo \"build: $?\")", + "Bash(echo \"vet: $?\")", + "Bash(perl -i -pe 's{^\\\\t\\\\t\\\\t\\\\t\\\\t// TODO\\\\\\(user\\\\\\): Specify other spec details if needed\\\\.\\\\n}{\\\\t\\\\t\\\\t\\\\t\\\\t// A minimal, schema-valid spec so this placeholder test survives the\\\\n\\\\t\\\\t\\\\t\\\\t\\\\t// CEL/CRD validation added in Step 1. Rewritten wholesale in Step 4\\\\n\\\\t\\\\t\\\\t\\\\t\\\\t// alongside the real reconciler and envtest suite.\\\\n\\\\t\\\\t\\\\t\\\\t\\\\tSpec: crawlv1alpha1.ProxySpec{\\\\n\\\\t\\\\t\\\\t\\\\t\\\\t\\\\tMode: crawlv1alpha1.ModeExternal,\\\\n\\\\t\\\\t\\\\t\\\\t\\\\t\\\\tEndpoint: &crawlv1alpha1.EndpointSpec{Host: \"10.0.0.1\"},\\\\n\\\\t\\\\t\\\\t\\\\t\\\\t},\\\\n}' internal/controller/proxy_controller_test.go)" ], "additionalDirectories": [ "/Users/jan.novak/srv/go/egress-proxies-operator/.claude", "/Users/jan.novak/srv/go/egress-proxies-operator/docs/plans", - "/Users/jan.novak/srv/go/egress-proxies-operator/docs" + "/Users/jan.novak/srv/go/egress-proxies-operator/docs", + "/Users/jan.novak/srv/go/egress-proxies-operator/docs/prompts" ] } } diff --git a/api/v1alpha1/helpers.go b/api/v1alpha1/helpers.go new file mode 100644 index 0000000..cd3232a --- /dev/null +++ b/api/v1alpha1/helpers.go @@ -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 +} diff --git a/api/v1alpha1/helpers_test.go b/api/v1alpha1/helpers_test.go new file mode 100644 index 0000000..79182aa --- /dev/null +++ b/api/v1alpha1/helpers_test.go @@ -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) + } + }) + } +} diff --git a/api/v1alpha1/proxy_types.go b/api/v1alpha1/proxy_types.go index ff6016f..701c510 100644 --- a/api/v1alpha1/proxy_types.go +++ b/api/v1alpha1/proxy_types.go @@ -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 { diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go index ed0873f..da378cb 100644 --- a/api/v1alpha1/zz_generated.deepcopy.go +++ b/api/v1alpha1/zz_generated.deepcopy.go @@ -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 +} diff --git a/config/crd/bases/crawl.example.com_proxies.yaml b/config/crd/bases/crawl.example.com_proxies.yaml index 7de65f1..42063e0 100644 --- a/config/crd/bases/crawl.example.com_proxies.yaml +++ b/config/crd/bases/crawl.example.com_proxies.yaml @@ -11,10 +11,31 @@ spec: kind: Proxy listKind: ProxyList plural: proxies + shortNames: + - px singular: proxy scope: Namespaced versions: - - name: v1alpha1 + - additionalPrinterColumns: + - jsonPath: .spec.mode + name: Mode + type: string + - jsonPath: .spec.provider + name: Provider + type: string + - jsonPath: .status.phase + name: Phase + type: string + - jsonPath: .status.ip + name: IP + type: string + - jsonPath: .status.conditions[?(@.type=="Healthy")].status + name: Healthy + type: string + - jsonPath: .metadata.creationTimestamp + name: Age + type: date + name: v1alpha1 schema: openAPIV3Schema: description: Proxy is the Schema for the proxies API @@ -39,25 +60,198 @@ spec: spec: description: spec defines the desired state of Proxy properties: - foo: - description: foo is an example field of Proxy. Edit proxy_types.go - to remove/update + attributes: + additionalProperties: + type: string + description: |- + attributes are selection attributes exposed to the discovery API + (geo, asn, purpose, ...). Deliberately separate from Kubernetes object + labels, which stay an operator implementation concern. + maxProperties: 32 + type: object + cloudInit: + description: |- + cloudInit supplies the VM's cloud-init user-data. Only meaningful for + Managed proxies. Changing the resolved content triggers replacement. + properties: + inline: + description: inline is the literal cloud-init user-data. + maxLength: 262144 + minLength: 1 + type: string + secretRef: + description: secretRef points at a Secret key holding the cloud-init + user-data. + properties: + key: + default: user-data + description: key is the data key holding the cloud-init user-data. + type: string + name: + description: name is the Secret's name. + minLength: 1 + type: string + required: + - name + type: object + type: object + x-kubernetes-validations: + - message: exactly one of inline or secretRef must be set + rule: has(self.inline) != has(self.secretRef) + endpoint: + description: |- + endpoint identifies an External proxy's network location. Required + iff mode is External; must be unset iff mode is Managed. + properties: + host: + description: host is the proxy's hostname or IP address. + minLength: 1 + type: string + port: + default: 3128 + description: port is the port the proxy listens on. + format: int32 + maximum: 65535 + minimum: 1 + type: integer + required: + - host + type: object + healthCheck: + default: {} + description: |- + 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. + properties: + expectedStatusCodes: + default: + - 200 + - 204 + description: |- + expectedStatusCodes are the HTTP status codes a probe response must + match to count as successful. + items: + format: int32 + type: integer + maxItems: 8 + type: array + failureThreshold: + default: 3 + description: |- + failureThreshold is the number of consecutive failed probes required + to transition Healthy -> False. + format: int32 + minimum: 1 + type: integer + intervalSeconds: + default: 30 + description: intervalSeconds is the time between probes for a + given proxy. + format: int32 + minimum: 5 + type: integer + probeURL: + default: https://www.gstatic.com/generate_204 + description: probeURL is fetched through the proxy on every probe. + minLength: 1 + type: string + successThreshold: + default: 1 + description: |- + successThreshold is the number of consecutive successful probes + required to transition Healthy -> True. + format: int32 + minimum: 1 + type: integer + timeoutSeconds: + default: 5 + description: |- + timeoutSeconds bounds a single probe, including the CONNECT tunnel + setup and the TLS handshake through it. + format: int32 + minimum: 1 + type: integer + type: object + maxLeases: + default: 5 + description: |- + 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. + format: int32 + maximum: 1000 + minimum: 0 + type: integer + mode: + description: |- + mode selects who owns the VM lifecycle: Managed (operator creates it) + or External (operator only tracks and healthchecks it). Immutable. + enum: + - Managed + - External type: string + placement: + description: |- + placement is provider-opaque placement/size configuration. Only + meaningful for Managed proxies. + properties: + image: + description: image is the boot image reference. + type: string + machineType: + description: machineType is the provider's machine/instance type + (e.g. "e2-micro"). + type: string + region: + description: region is the provider's region identifier (e.g. + "europe-west1"). + type: string + zone: + description: zone is the provider's zone identifier (e.g. "europe-west1-b"). + type: string + type: object + port: + default: 3128 + description: |- + port is the port the proxy listens on once the VM is up. Only + meaningful for Managed proxies; External proxies use endpoint.port. + format: int32 + maximum: 65535 + minimum: 1 + type: integer + provider: + description: |- + provider is the name of a configured provider ("mock", "gcp-eu", ...). + Required iff mode is Managed; must be unset iff mode is External. + Immutable. + maxLength: 63 + minLength: 1 + type: string + required: + - mode type: object + x-kubernetes-validations: + - message: mode is immutable + rule: self.mode == oldSelf.mode + - message: provider is immutable + rule: has(self.provider) == has(oldSelf.provider) && (!has(self.provider) + || self.provider == oldSelf.provider) + - message: provider is required when mode is Managed + rule: self.mode != 'Managed' || has(self.provider) + - message: provider must not be set when mode is External + rule: self.mode != 'External' || !has(self.provider) + - message: endpoint is required when mode is External + rule: self.mode != 'External' || has(self.endpoint) + - message: endpoint must not be set when mode is Managed + rule: self.mode != 'Managed' || !has(self.endpoint) status: description: status defines the observed state of Proxy properties: conditions: description: |- 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. items: description: Condition contains details for one aspect of the current state of this API Resource. @@ -116,6 +310,37 @@ spec: x-kubernetes-list-map-keys: - type x-kubernetes-list-type: map + ip: + description: |- + ip is the proxy's current IP address: the VM's address for Managed + proxies, or spec.endpoint.host for External proxies. + type: string + lastHealthCheckTime: + description: |- + 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. + format: date-time + type: string + latencyMillis: + description: latencyMillis is the latency of the last status-affecting + probe. + format: int64 + type: integer + observedGeneration: + description: observedGeneration is the .metadata.generation last reconciled. + format: int64 + type: integer + phase: + description: |- + phase is a high-level, human-readable summary derived from conditions. + One of Pending, Provisioning, Ready, Unhealthy, Deleting, Failed. + type: string + providerID: + description: |- + providerID is the opaque cloud resource ID returned by the provider. + Empty for External proxies. + type: string type: object required: - spec diff --git a/docs/plans-executions/2026-08-07-1747-proxy-operator.md b/docs/plans-executions/2026-08-07-1747-proxy-operator.md index 3659a04..60595c3 100644 --- a/docs/plans-executions/2026-08-07-1747-proxy-operator.md +++ b/docs/plans-executions/2026-08-07-1747-proxy-operator.md @@ -5,7 +5,7 @@ Pairs with [docs/plans/2026-08-07-1747-proxy-operator.md](../plans/2026-08-07-17 ## Status - [x] Step 0 — Branch and scaffold -- [ ] Step 1 — API types (`api/v1alpha1/proxy_types.go`) +- [x] Step 1 — API types (`api/v1alpha1/proxy_types.go`) - [ ] Step 2 — Provider contract (`internal/provider/`) - [ ] Step 3 — Mock provider (`internal/provider/mock/`) - [ ] Step 4 — Reconciler (`internal/controller/`) @@ -89,3 +89,62 @@ anticipated wasn't needed. Pre-existing `CLAUDE.md`/`CHANGELOG.md` content survi untouched; kubebuilder added its own `README.md`, `AGENTS.md`, `.golangci.yml`, `.devcontainer/`, `Dockerfile` on top of them — those get edited or left as-is in later steps. Committed as `076bc66`. + +## Step 1 — API types (`api/v1alpha1/proxy_types.go`) + +Wrote the full `ProxySpec`/`ProxyStatus`/`Proxy` types per the plan, including the +four corrections called out there (`MaxLeases *int32`, `HealthCheck` with +`+kubebuilder:default={}`, `MinLength=1` on `Provider`/`CloudInit.Inline`, +`Conditions` with `+listType=map`), the 7 CEL `XValidation` rules (6 on `ProxySpec`, +1 on `CloudInitSpec`), and pure helpers in `helpers.go` +(`EffectivePort`/`EffectiveHost`/`HealthCheckOrDefault`/`MaxLeasesOrDefault`) with +table-driven tests in `helpers_test.go`. + +Regenerated deepcopy and the CRD: + +```bash +make manifests generate +# controller-gen rbac:roleName=manager-role crd webhook paths="./..." output:crd:artifacts:config=config/crd/bases +# controller-gen object:headerFile="hack/boilerplate.go.txt",year=2026 paths="./..." +``` + +Confirmed all 7 CEL rules and the `healthCheck` `default: {}` block landed in the +generated CRD as expected: + +```bash +grep -B1 "rule:" config/crd/bases/crawl.example.com_proxies.yaml +# 7 matches, one per XValidation marker written +``` + +Ran the full suite, not just `go build`/`go vet`, since this was a good opportunity to +confirm envtest itself works end to end for the first time: + +```bash +make test +# Setting up envtest binaries for Kubernetes version 1.36... +# .../bin/k8s/1.36.2-darwin-arm64 (confirms the plan's envtest version note) +``` + +This failed on the first run — not because of anything in the new types, but because +kubebuilder's scaffolded placeholder test in `proxy_controller_test.go` creates a bare +`Proxy{}` with no `spec.mode`, which our new required/enum field correctly rejects: + +```text +Proxy.crawl.example.com "test-resource" is invalid: [spec.mode: Unsupported value: "": +supported values: "Managed", "External", ...] +``` + +That's a real envtest apiserver enforcing our schema for the first time, which is +useful confirmation on its own. Patched just the resource literal in that scaffold +test to a minimal valid spec (`Mode: External` + `Endpoint.Host`) rather than +rewriting the file — that whole test gets replaced in Step 4 alongside the real +reconciler, so a deeper fix now would be thrown away. `make test` then passed clean: +`api/v1alpha1` at 20.5% coverage (helpers only — CEL itself isn't unit-testable, it's +exercised by the real apiserver as shown above), `internal/controller` at 66.7%. + +Worth noting: the `go test ./...` command from the plan's own verification section +does *not* work directly for the envtest suite — it needs `KUBEBUILDER_ASSETS` set, +which only `make test` does via `setup-envtest`. Plain `go test ./...` fails the +`internal/controller` package with a `/usr/local/kubebuilder/bin/etcd: no such file` +error that has nothing to do with the code. Use `make test`, not `go test ./...`, +whenever the controller package is in scope. diff --git a/internal/controller/proxy_controller_test.go b/internal/controller/proxy_controller_test.go index 51a1763..e10d5da 100644 --- a/internal/controller/proxy_controller_test.go +++ b/internal/controller/proxy_controller_test.go @@ -54,7 +54,13 @@ var _ = Describe("Proxy Controller", func() { Name: resourceName, Namespace: resourceNamespace, }, - // TODO(user): Specify other spec details if needed. + // A minimal, schema-valid spec so this placeholder test survives the + // CEL/CRD validation added in Step 1. Rewritten wholesale in Step 4 + // alongside the real reconciler and envtest suite. + Spec: crawlv1alpha1.ProxySpec{ + Mode: crawlv1alpha1.ModeExternal, + Endpoint: &crawlv1alpha1.EndpointSpec{Host: "10.0.0.1"}, + }, } Expect(k8sClient.Create(ctx, resource)).To(Succeed()) }