Skip to content

Commit ba0c6ed

Browse files
authored
1. Child-cluster Node API support (the bulk of the branch) (#71)
Routes Node/Pod operations to a CAPI/k0smotron workload (child) cluster while the ScheduledMachine lives on the management cluster. - New CRD field spec.kubeconfigSecretRef (src/crd.rs) — optional reference to a same-namespace <clusterName>-kubeconfig Secret; cross-namespace refs forbidden by design. - src/reconcilers/child_client.rs (+tests, ~1,400 lines) — resolves the right kube::Client (management vs child), caches by Secret with LRU + resourceVersion-based rotation. - src/reconcilers/child_watch.rs (+tests, untracked, ~520 lines) — one Node watcher per child cluster; maps child Node events back to ScheduledMachine reconciles via Controller::reconcile_on. - tests/integration_child_kubeconfig.rs (untracked, 416 lines) — end-to-end kubeconfig resolution tests. - Wiring/support: src/main.rs (controller plumbing), src/metrics.rs (Phase-2 child-cluster metrics), new error variants in the ScheduledMachine lives on the management cluster. - New CRD field spec.kubeconfigSecretRef (src/crd.rs) — optional reference to a same-namespace <clusterName>-kubeconfig Secret; cross-namespace refs resourceVersion-based rotation. - src/reconcilers/child_watch.rs (+tests, untracked, ~520 lines) — one Node watcher per child cluster; maps child Node events back to ScheduledMachine reconciles via Controller::reconcile_on. - tests/integration_child_kubeconfig.rs (untracked, 416 lines) — end-to-end kubeconfig resolution tests. - Wiring/support: src/main.rs (controller plumbing), src/metrics.rs (Phase-2 child-cluster metrics), new error variants in scheduled_machine.rs, Cargo.toml deps (kube unstable-runtime, tokio-stream, base64), and docs docs/src/concepts/child-cluster-kubeconfig.md + examples/scheduledmachine-child-cluster.yaml. 2. Security hardening - RBAC validation of bootstrapSpec/infrastructureSpec — user anti-escalation via VAP authorizer rules (13a/13b) + controller-SA SelfSubjectAccessReview pre-flight (ensure_can_create()); new PermissionDenied error + metric. - Embedded metadata enforcement — loudly reject metadata.name/metadata.namespace (VAP rules 13c–13f + runtime validate_embedded_metadata()), while newly supporting metadata.labels/annotations (reserved-prefix-checked, controller labels win). Required making the embedded metadata preserve-unknown so the field isn't pruned before rejection. - Updated deploy/admission/validatingadmissionpolicy.yaml, regenerated CRD + api.md, threat-model (T1 hardened), admission-validation doc, and SCHEDULED_MACHINE_LABEL constant. Signed-off-by: Erick Bourgeois <erick@jeb.ca>
1 parent 7fc5959 commit ba0c6ed

27 files changed

Lines changed: 4700 additions & 38 deletions

.claude/CHANGELOG.md

Lines changed: 422 additions & 0 deletions
Large diffs are not rendered by default.

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,3 +11,5 @@ __pycache__/
1111
# Generated CALM diagrams (rendered by `make calm-diagrams` / CI).
1212
# Source: docs/architecture/calm/templates/mermaid/*.md.hbs
1313
/docs/src/architecture/
14+
15+
.DS_Store

Cargo.lock

Lines changed: 2 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,10 +29,19 @@ name = "auto-vex-reachability"
2929
path = "src/bin/auto_vex_reachability.rs"
3030

3131
[dependencies]
32-
kube = { version = "3.1", features = ["runtime", "derive", "client", "ws"] }
32+
# `unstable-runtime` is opt-in for `Controller::reconcile_on`, which we
33+
# use in main.rs to feed per-child-cluster Node-watch events into the
34+
# Controller. The feature is "unstable" in the SemVer sense (the API
35+
# may change between minor kube-runtime versions) but is the canonical
36+
# way to push external triggers into the controller loop.
37+
kube = { version = "3.1", features = ["runtime", "derive", "client", "ws", "unstable-runtime"] }
3338
kube-lease-manager = "0.11"
3439
k8s-openapi = { version = "0.27", features = ["latest", "schemars"] }
3540
tokio = { version = "1", features = ["full"] }
41+
# `ReceiverStream` adapter so an `mpsc::Receiver<ObjectRef<…>>` can be
42+
# fed into `Controller::reconcile_on`. Used by main.rs to plumb
43+
# child-cluster Node-watch events back into the controller.
44+
tokio-stream = "0.1"
3645
serde = { version = "1", features = ["derive"] }
3746
serde_json = "1"
3847
serde_yaml = "0.9"
@@ -79,6 +88,10 @@ test-log = "0.2"
7988
tempfile = "3"
8089
tower-test = "0.4"
8190
http = "1"
91+
# Used by src/reconcilers/child_client_tests.rs to build base64-encoded
92+
# Secret payloads for the tower-test mock server. base64 0.22 is already
93+
# in the dependency graph transitively (kube / k8s-openapi pull it).
94+
base64 = "0.22"
8295

8396
[profile.release]
8497
opt-level = 3

deploy/admission/validatingadmissionpolicy.yaml

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,21 @@ spec:
3232
resources: ["scheduledmachines"]
3333
operations: ["CREATE", "UPDATE"]
3434

35+
# Derived values reused by the RBAC authorizer checks below. The group is the
36+
# part of apiVersion before '/'; the resource is the naive lowercase plural of
37+
# kind (lowerAscii + 's'). This mirrors resource_plural() in
38+
# src/reconcilers/helpers.rs so the permission checked here is exactly the one
39+
# the controller will exercise when it creates the resource.
40+
variables:
41+
- name: bootstrapGroup
42+
expression: "object.spec.bootstrapSpec.apiVersion.split('/')[0]"
43+
- name: bootstrapResource
44+
expression: "object.spec.bootstrapSpec.kind.lowerAscii() + 's'"
45+
- name: infraGroup
46+
expression: "object.spec.infrastructureSpec.apiVersion.split('/')[0]"
47+
- name: infraResource
48+
expression: "object.spec.infrastructureSpec.kind.lowerAscii() + 's'"
49+
3550
validations:
3651

3752
# ── 1. clusterName: non-empty ────────────────────────────────────────────
@@ -196,6 +211,82 @@ spec:
196211
message: "spec.infrastructureSpec.kind must not be empty"
197212
reason: Invalid
198213

214+
# ── 13c–13f. Embedded metadata: name/namespace are controller-owned ───────
215+
# The controller owns the created resource's identity: it always names the
216+
# bootstrap/infrastructure resource after the ScheduledMachine and creates
217+
# it in the SM's own namespace (cross-namespace creation is forbidden —
218+
# threat T1). A user-supplied metadata.name/metadata.namespace is therefore
219+
# rejected, not silently ignored.
220+
#
221+
# NOTE: this only works because EmbeddedResource's `metadata` is marked
222+
# `x-kubernetes-preserve-unknown-fields: true` in src/crd.rs. Without it the
223+
# API server PRUNES unknown fields before admission policies run, so the
224+
# field would be gone before this rule could see it. The runtime
225+
# `validate_embedded_metadata()` in src/reconcilers/helpers.rs enforces the
226+
# same rule as defence-in-depth. Only metadata.labels / metadata.annotations
227+
# are user-settable (and are reserved-prefix-checked at reconcile time).
228+
- expression: "!has(object.spec.bootstrapSpec.metadata) || !has(object.spec.bootstrapSpec.metadata.namespace)"
229+
message: >-
230+
spec.bootstrapSpec.metadata.namespace is not permitted — the 5Spot
231+
controller always creates the bootstrap resource in the
232+
ScheduledMachine's own namespace
233+
reason: Forbidden
234+
- expression: "!has(object.spec.bootstrapSpec.metadata) || !has(object.spec.bootstrapSpec.metadata.name)"
235+
message: >-
236+
spec.bootstrapSpec.metadata.name is not permitted — the 5Spot controller
237+
names the bootstrap resource after the ScheduledMachine
238+
reason: Forbidden
239+
- expression: "!has(object.spec.infrastructureSpec.metadata) || !has(object.spec.infrastructureSpec.metadata.namespace)"
240+
message: >-
241+
spec.infrastructureSpec.metadata.namespace is not permitted — the 5Spot
242+
controller always creates the infrastructure resource in the
243+
ScheduledMachine's own namespace
244+
reason: Forbidden
245+
- expression: "!has(object.spec.infrastructureSpec.metadata) || !has(object.spec.infrastructureSpec.metadata.name)"
246+
message: >-
247+
spec.infrastructureSpec.metadata.name is not permitted — the 5Spot
248+
controller names the infrastructure resource after the ScheduledMachine
249+
reason: Forbidden
250+
251+
# ── 13a. RBAC: requesting user may create the bootstrapSpec resource ──────
252+
# Privilege-escalation guard. The 5Spot controller runs with broad RBAC so
253+
# it can create bootstrap/infrastructure/Machine objects on the user's
254+
# behalf. Without this check, a user able to create a ScheduledMachine but
255+
# NOT a K0sWorkerConfig could have the controller create one for them —
256+
# escalating through the controller. We require the *requesting user* to
257+
# independently hold `create` on the embedded bootstrap GVK in the target
258+
# namespace, mirroring how CAPI's own webhooks gate templated resources.
259+
# The controller-side equivalent (its own service account) is enforced at
260+
# reconcile time by ensure_can_create() in src/reconcilers/helpers.rs.
261+
- expression: >-
262+
authorizer.group(variables.bootstrapGroup)
263+
.resource(variables.bootstrapResource)
264+
.namespace(object.metadata.namespace)
265+
.check('create')
266+
.allowed()
267+
message: >-
268+
user is not permitted to create the spec.bootstrapSpec resource type —
269+
creating a ScheduledMachine requires 'create' permission on the
270+
embedded bootstrap resource (RBAC) to prevent privilege escalation
271+
through the 5Spot controller
272+
reason: Forbidden
273+
274+
# ── 13b. RBAC: requesting user may create the infrastructureSpec resource ──
275+
# Same privilege-escalation guard as 13a, applied to the embedded
276+
# infrastructure GVK (e.g. RemoteMachine).
277+
- expression: >-
278+
authorizer.group(variables.infraGroup)
279+
.resource(variables.infraResource)
280+
.namespace(object.metadata.namespace)
281+
.check('create')
282+
.allowed()
283+
message: >-
284+
user is not permitted to create the spec.infrastructureSpec resource
285+
type — creating a ScheduledMachine requires 'create' permission on the
286+
embedded infrastructure resource (RBAC) to prevent privilege escalation
287+
through the 5Spot controller
288+
reason: Forbidden
289+
199290
# ── 14. nodeTaints: key format (RFC-1123 qualified name) ──────────────────
200291
# Mirrors validate_taint_key() in src/crd.rs. The optional "<prefix>/" part
201292
# is a DNS subdomain; the name part is a qualified name. Both capped at 63

deploy/crds/scheduledmachine.yaml

Lines changed: 86 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,3 @@
1-
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.12s
2-
Running `target/debug/crdgen`
31
apiVersion: apiextensions.k8s.io/v1
42
kind: CustomResourceDefinition
53
metadata:
@@ -58,6 +56,21 @@ spec:
5856
kind:
5957
description: Kind of the resource (e.g., 'K0sWorkerConfig', 'RemoteMachine')
6058
type: string
59+
metadata:
60+
description: Optional labels/annotations to stamp on the created resource. Only 'labels' and 'annotations' are honoured; 'name' and 'namespace' are controller-owned and rejected at admission.
61+
properties:
62+
annotations:
63+
additionalProperties:
64+
type: string
65+
description: Annotations merged onto the created resource (reserved prefixes are rejected)
66+
type: object
67+
labels:
68+
additionalProperties:
69+
type: string
70+
description: Labels merged onto the created resource (reserved prefixes are rejected)
71+
type: object
72+
type: object
73+
x-kubernetes-preserve-unknown-fields: true
6174
spec:
6275
description: Provider-specific configuration
6376
type: object
@@ -97,6 +110,21 @@ spec:
97110
kind:
98111
description: Kind of the resource (e.g., 'K0sWorkerConfig', 'RemoteMachine')
99112
type: string
113+
metadata:
114+
description: Optional labels/annotations to stamp on the created resource. Only 'labels' and 'annotations' are honoured; 'name' and 'namespace' are controller-owned and rejected at admission.
115+
properties:
116+
annotations:
117+
additionalProperties:
118+
type: string
119+
description: Annotations merged onto the created resource (reserved prefixes are rejected)
120+
type: object
121+
labels:
122+
additionalProperties:
123+
type: string
124+
description: Labels merged onto the created resource (reserved prefixes are rejected)
125+
type: object
126+
type: object
127+
x-kubernetes-preserve-unknown-fields: true
100128
spec:
101129
description: Provider-specific configuration
102130
type: object
@@ -137,6 +165,62 @@ spec:
137165
default: false
138166
description: When true, immediately removes the machine from cluster
139167
type: boolean
168+
kubeconfigSecretRef:
169+
description: |-
170+
Reference to a Secret in this ScheduledMachine's namespace containing
171+
a kubeconfig for the workload (child) cluster whose Node(s) this
172+
resource manages.
173+
174+
When set, every Node and Pod API call the controller makes on behalf
175+
of this resource — cordon, taint, drain (pod list + delete),
176+
reclaim-agent annotations / labels / ConfigMaps, status enrichment —
177+
is routed through that kubeconfig. CAPI / bootstrap / infrastructure
178+
/ Machine objects continue to use the management cluster's in-cluster
179+
client.
180+
181+
When unset (default), the controller first tries to auto-discover a
182+
Secret named `<spec.clusterName>-kubeconfig` in this same namespace
183+
(CAPI convention). If that Secret does not exist, the management
184+
client is used for Node/Pod operations as well — the degenerate
185+
single-cluster dev/test posture where management ≡ workload cluster.
186+
187+
Cross-namespace Secret references are NOT supported: the Secret MUST
188+
live in this resource's own namespace. This is a security boundary
189+
(cross-namespace would let a tenant in one namespace read a kubeconfig
190+
in another).
191+
192+
The supplied kubeconfig MUST grant: `nodes` get/list/watch/patch and
193+
`pods` get/list/delete in all namespaces of the child cluster, plus
194+
`configmaps` get/create/patch/delete in the reclaim-agent namespace
195+
if `killIfCommands` is also set. See
196+
`docs/src/concepts/child-cluster-kubeconfig.md` for the full RBAC
197+
requirements and threat model.
198+
nullable: true
199+
properties:
200+
key:
201+
default: value
202+
description: |-
203+
Key within the Secret's `data` map whose value is the kubeconfig YAML
204+
document. Defaults to `value` — CAPI's convention for
205+
`<clusterName>-kubeconfig` Secrets generated by the control-plane
206+
provider. Common overrides: `kubeconfig` (some k0smotron flows),
207+
`admin.conf` (kubeadm-style).
208+
maxLength: 253
209+
minLength: 1
210+
pattern: ^[A-Za-z0-9._-]+$
211+
type: string
212+
name:
213+
description: |-
214+
Name of the Secret. RFC-1123 DNS subdomain (max 253 chars).
215+
The Secret MUST live in the same namespace as the ScheduledMachine —
216+
there is no `namespace` field by design.
217+
maxLength: 253
218+
minLength: 1
219+
pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$
220+
type: string
221+
required:
222+
- name
223+
type: object
140224
machineTemplate:
141225
description: |-
142226
Optional configuration for the created CAPI Machine

0 commit comments

Comments
 (0)