proxy-operator: Kubernetes operator for crawling-proxy fleets #1
@@ -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
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"
|
"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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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())
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user