Skip to content

Commit 9fcd72b

Browse files
committed
1. Child-cluster Node API support (the bulk of the branch)
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 9fcd72b

28 files changed

Lines changed: 4794 additions & 62 deletions

.claude/CHANGELOG.md

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

.github/workflows/docs.yaml

Lines changed: 93 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,8 @@ name: Documentation
66
on:
77
# Build-only triggers — exercise the docs pipeline so breakage is
88
# caught at PR / merge time, but do NOT publish to GitHub Pages.
9-
# Deployment is gated on the `release` event below.
9+
# Deployment is gated on the Build workflow completing successfully
10+
# for a release (see workflow_run trigger and deploy job below).
1011
push:
1112
branches:
1213
- main
@@ -19,18 +20,25 @@ on:
1920
- 'docs/**'
2021
- 'src/**/*.rs'
2122
- '.github/workflows/docs.yaml'
22-
# Deploy trigger — fires when an operator publishes a GitHub Release.
23-
# The docs site published to Pages is what end users see, so we tie
24-
# publish cadence to the release cadence: every release ships matching
25-
# docs, and intermediate main commits don't churn the live site.
26-
release:
27-
types: [published]
23+
# Deploy trigger — chained to the Build workflow's completion rather
24+
# than the raw `release: published` event so docs only ship when the
25+
# release's binaries / container images / signatures actually built
26+
# cleanly. The deploy job below filters strictly on
27+
# workflow_run.event == 'release' and conclusion == 'success';
28+
# PR / push-to-main Build completions still fire this workflow as a
29+
# redundant docs-pipeline sanity check, but never deploy.
30+
workflow_run:
31+
workflows: ["Build"]
32+
types: [completed]
2833
workflow_dispatch:
2934

35+
# Least-privilege default for all jobs. `id-token: write` (OIDC, needed only by
36+
# actions/deploy-pages) is NOT granted here — it is scoped to the deploy job
37+
# alone, so the build job that checks out code never holds it. `pages: write`
38+
# is required by the release-only Setup Pages step in the build job.
3039
permissions:
3140
contents: read
3241
pages: write
33-
id-token: write
3442

3543
# Build runs on every PR / push and can race with itself — cancel
3644
# in-progress to keep CI from queuing redundant builds. Release deploys
@@ -39,8 +47,8 @@ permissions:
3947
# the `cancel-in-progress` expression below disables cancellation only
4048
# for the release event.
4149
concurrency:
42-
group: pages-${{ github.event_name == 'release' && github.event.release.tag_name || github.ref }}
43-
cancel-in-progress: ${{ github.event_name != 'release' }}
50+
group: pages-${{ github.event_name == 'workflow_run' && github.event.workflow_run.head_branch || github.ref }}
51+
cancel-in-progress: ${{ !(github.event_name == 'workflow_run' && github.event.workflow_run.event == 'release') }}
4452

4553
env:
4654
CARGO_TERM_COLOR: always
@@ -87,11 +95,36 @@ jobs:
8795
name: 📚 Build Documentation
8896
needs: [calm-validate, calm-diagrams]
8997
runs-on: ubuntu-latest
98+
# SECURITY — pwn-request guard. `workflow_run` is a privileged trigger: it
99+
# runs in the default-branch context with the repo's secrets and this
100+
# workflow's write permissions, even when the triggering Build came from a
101+
# fork PR. Checking out `workflow_run.head_sha` and running build steps
102+
# (make docs / poetry install / npm install) for a PR Build would therefore
103+
# execute untrusted code in a privileged context (CodeQL "Checkout of
104+
# untrusted code in a privileged context").
105+
#
106+
# Gate the job so the workflow_run path runs ONLY for a SUCCESSFUL RELEASE
107+
# Build — where head_sha is a trusted release-tag commit on the protected
108+
# branch. PR / push / workflow_dispatch builds still run (in their own
109+
# non-privileged contexts) and already validate the docs pipeline, so no
110+
# coverage is lost; the previous "redundant sanity check on PR Build
111+
# completions" was exactly the untrusted path and is now removed.
112+
if: >-
113+
github.event_name != 'workflow_run' ||
114+
(github.event.workflow_run.event == 'release' &&
115+
github.event.workflow_run.conclusion == 'success')
90116
steps:
91117
- name: Checkout repository
92118
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
93119
with:
94120
fetch-depth: 0 # Full history for git-revision-date-localized plugin
121+
# workflow_run executes in the default-branch context (main),
122+
# so without an explicit ref the docs would be built from main
123+
# even on a release-tagged Build. Pin to the SHA the Build ran
124+
# against so released docs reflect released code. For other
125+
# events (push / pull_request / workflow_dispatch) fall back to
126+
# the workflow's triggering ref.
127+
ref: ${{ github.event_name == 'workflow_run' && github.event.workflow_run.head_sha || github.ref }}
95128

96129
- name: Download rendered CALM diagrams
97130
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
@@ -132,8 +165,14 @@ jobs:
132165
cd docs
133166
poetry config virtualenvs.in-project true
134167
135-
- name: Cache Poetry dependencies
136-
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
168+
# SECURITY — cache poisoning. RESTORE the cache in every context but only
169+
# SAVE it from a trusted push to the default branch (below). This stops an
170+
# untrusted PR (or any non-default-branch run) from writing files into a
171+
# cache that the default branch later restores (CodeQL "Cache Poisoning
172+
# via caching of untrusted files").
173+
- name: Restore Poetry cache
174+
id: poetry-cache
175+
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
137176
with:
138177
path: docs/.venv
139178
key: ${{ runner.os }}-poetry-${{ hashFiles('docs/pyproject.toml') }}
@@ -145,8 +184,18 @@ jobs:
145184
cd docs
146185
poetry install --no-interaction --no-ansi
147186
148-
- name: Cache cargo build
149-
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
187+
- name: Save Poetry cache
188+
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
189+
uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
190+
with:
191+
path: docs/.venv
192+
key: ${{ steps.poetry-cache.outputs.cache-primary-key }}
193+
194+
# SECURITY — cache poisoning (see Poetry cache note above). Restore
195+
# everywhere; save only from a trusted default-branch push.
196+
- name: Restore cargo build cache
197+
id: cargo-cache
198+
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
150199
with:
151200
path: |
152201
~/.cargo/registry
@@ -163,6 +212,16 @@ jobs:
163212
SKIP_CALM_DIAGRAMS: "1"
164213
run: make docs
165214

215+
- name: Save cargo build cache
216+
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
217+
uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
218+
with:
219+
path: |
220+
~/.cargo/registry
221+
~/.cargo/git
222+
target
223+
key: ${{ steps.cargo-cache.outputs.cache-primary-key }}
224+
166225
- name: Check for broken links
167226
if: github.event_name == 'pull_request'
168227
continue-on-error: true
@@ -173,28 +232,38 @@ jobs:
173232
npm install -g "linkinator@${LINKINATOR_VERSION}"
174233
linkinator docs/site/ --recurse --skip "rustdoc/.*" --verbosity error
175234
176-
# Pages setup + artifact upload only happen when the trigger is a
177-
# release publication. PR / push runs build the site (proving it
178-
# compiles cleanly) but never produce a deploy artifact.
235+
# Pages setup + artifact upload only happen when this workflow was
236+
# triggered by a successful Build run for a release. PR / push runs
237+
# (and Build completions for non-release events) build the site to
238+
# prove it compiles cleanly, but never produce a deploy artifact.
179239
- name: Setup Pages
180-
if: github.event_name == 'release'
240+
if: github.event_name == 'workflow_run' && github.event.workflow_run.event == 'release' && github.event.workflow_run.conclusion == 'success'
181241
uses: actions/configure-pages@45bfe0192ca1faeb007ade9deae92b16b8254a0d # v6.0.0
182242

183243
- name: Upload Pages artifact
184-
if: github.event_name == 'release'
244+
if: github.event_name == 'workflow_run' && github.event.workflow_run.event == 'release' && github.event.workflow_run.conclusion == 'success'
185245
uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5.0.0
186246
with:
187247
path: docs/site
188248

189249
deploy:
190250
name: 🚀 Deploy to GitHub Pages
191-
# Gated on the `release: published` event — a main push or PR build
192-
# validates the docs but does NOT publish them. The published site
193-
# tracks release cadence so end users see a stable, versioned doc
194-
# set rather than every intermediate main commit.
195-
if: github.event_name == 'release'
251+
# Gated on the Build workflow finishing successfully for a release.
252+
# This is stricter than the prior `release: published` trigger:
253+
# if Build fails (e.g., a binary won't compile, a Cosign signature
254+
# step errors, an SBOM upload breaks), docs no longer publish for
255+
# that tag. PR / push-to-main runs and Build completions whose
256+
# triggering event was not a release validate the docs pipeline but
257+
# do NOT publish.
258+
if: github.event_name == 'workflow_run' && github.event.workflow_run.event == 'release' && github.event.workflow_run.conclusion == 'success'
196259
needs: build
197260
runs-on: ubuntu-latest
261+
# OIDC token for actions/deploy-pages is scoped here, not workflow-wide,
262+
# so only this deploy job (which runs no untrusted code) can mint it.
263+
permissions:
264+
contents: read
265+
pages: write
266+
id-token: write
198267
environment:
199268
name: github-pages
200269
url: ${{ steps.deployment.outputs.page_url }}

.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

0 commit comments

Comments
 (0)