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:
2026-08-07 21:01:56 +02:00
parent 26991fbe13
commit f28766fce3
8 changed files with 985 additions and 41 deletions

View File

@@ -40,12 +40,17 @@
"Bash(go install *)", "Bash(go install *)",
"Bash(go env *)", "Bash(go env *)",
"Bash(git rm *)", "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": [ "additionalDirectories": [
"/Users/jan.novak/srv/go/egress-proxies-operator/.claude", "/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/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"
] ]
} }
} }

87
api/v1alpha1/helpers.go Normal file
View 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
}

View 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)
}
})
}
}

View File

@@ -21,46 +21,302 @@ import (
"k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/runtime"
) )
// EDIT THIS FILE! THIS IS SCAFFOLDING FOR YOU TO OWN! // ProvisioningMode selects who owns the lifecycle of the proxy VM.
// NOTE: json tags are required. Any new fields you add must have json tags for the fields to be serialized. type ProvisioningMode string
// ProxySpec defines the desired state of Proxy const (
type ProxySpec struct { // ModeManaged means the operator creates, monitors, and deletes the VM.
// INSERT ADDITIONAL SPEC FIELDS - desired state of cluster ModeManaged ProvisioningMode = "Managed"
// Important: Run "make" to regenerate code after modifying this file // ModeExternal means the VM exists outside the operator's control; the
// The following markers will use OpenAPI v3 schema to validate the value // operator only tracks and healthchecks it.
// More info: https://book.kubebuilder.io/reference/markers/crd-validation.html 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 // +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. // ProxyStatus defines the observed state of Proxy.
type ProxyStatus struct { type ProxyStatus struct {
// INSERT ADDITIONAL STATUS FIELD - define observed state of cluster // phase is a high-level, human-readable summary derived from conditions.
// Important: Run "make" to regenerate code after modifying this file // One of Pending, Provisioning, Ready, Unhealthy, Deleting, Failed.
// +optional
Phase ProxyPhase `json:"phase,omitempty"`
// For Kubernetes API conventions, see: // providerID is the opaque cloud resource ID returned by the provider.
// https://github.com/kubernetes/community/blob/master/contributors/devel/sig-architecture/api-conventions.md#typical-status-properties // 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. // 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 types are Provisioned and Healthy.
//
// 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.
// +listType=map // +listType=map
// +listMapKey=type // +listMapKey=type
// +optional // +optional
Conditions []metav1.Condition `json:"conditions,omitempty"` 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:object:root=true
// +kubebuilder:subresource:status // +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 // Proxy is the Schema for the proxies API
type Proxy struct { type Proxy struct {

View File

@@ -25,6 +25,76 @@ import (
"k8s.io/apimachinery/pkg/runtime" "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. // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *Proxy) DeepCopyInto(out *Proxy) { func (in *Proxy) DeepCopyInto(out *Proxy) {
*out = *in *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. // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *ProxySpec) DeepCopyInto(out *ProxySpec) { func (in *ProxySpec) DeepCopyInto(out *ProxySpec) {
*out = *in *out = *in
if in.Foo != nil { if in.Placement != nil {
in, out := &in.Foo, &out.Foo in, out := &in.Placement, &out.Placement
*out = new(string) *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 **out = **in
} }
} }
@@ -114,6 +211,10 @@ func (in *ProxyStatus) DeepCopyInto(out *ProxyStatus) {
(*in)[i].DeepCopyInto(&(*out)[i]) (*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. // 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) in.DeepCopyInto(out)
return 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
}

View File

@@ -11,10 +11,31 @@ spec:
kind: Proxy kind: Proxy
listKind: ProxyList listKind: ProxyList
plural: proxies plural: proxies
shortNames:
- px
singular: proxy singular: proxy
scope: Namespaced scope: Namespaced
versions: 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: schema:
openAPIV3Schema: openAPIV3Schema:
description: Proxy is the Schema for the proxies API description: Proxy is the Schema for the proxies API
@@ -39,25 +60,198 @@ spec:
spec: spec:
description: spec defines the desired state of Proxy description: spec defines the desired state of Proxy
properties: properties:
foo: attributes:
description: foo is an example field of Proxy. Edit proxy_types.go additionalProperties:
to remove/update 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: string
type: object 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: status:
description: status defines the observed state of Proxy description: status defines the observed state of Proxy
properties: properties:
conditions: conditions:
description: |- description: |-
conditions represent the current state of the Proxy resource. 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 types are Provisioned and Healthy.
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.
items: items:
description: Condition contains details for one aspect of the current description: Condition contains details for one aspect of the current
state of this API Resource. state of this API Resource.
@@ -116,6 +310,37 @@ spec:
x-kubernetes-list-map-keys: x-kubernetes-list-map-keys:
- type - type
x-kubernetes-list-type: map 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 type: object
required: required:
- spec - spec

View File

@@ -5,7 +5,7 @@ Pairs with [docs/plans/2026-08-07-1747-proxy-operator.md](../plans/2026-08-07-17
## Status ## Status
- [x] Step 0 — Branch and scaffold - [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 2 — Provider contract (`internal/provider/`)
- [ ] Step 3 — Mock provider (`internal/provider/mock/`) - [ ] Step 3 — Mock provider (`internal/provider/mock/`)
- [ ] Step 4 — Reconciler (`internal/controller/`) - [ ] 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`, 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 `.devcontainer/`, `Dockerfile` on top of them — those get edited or left as-is in
later steps. Committed as `076bc66`. 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.

View File

@@ -54,7 +54,13 @@ var _ = Describe("Proxy Controller", func() {
Name: resourceName, Name: resourceName,
Namespace: resourceNamespace, 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()) Expect(k8sClient.Create(ctx, resource)).To(Succeed())
} }