Skip to content

Commit 1bf83d3

Browse files
committed
Populate providerID/nodeRef in reconciler and add watchers
Signed-off-by: Erick Bourgeois <erick@jeb.ca>
1 parent 3af0e03 commit 1bf83d3

8 files changed

Lines changed: 1298 additions & 47 deletions

File tree

.claude/CHANGELOG.md

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,119 @@ The format is based on the regulated environment requirements:
99

1010
---
1111

12+
## [2026-04-19 15:30] - Sign binaries and generate SLSA provenance on push-to-main (not only on release)
13+
14+
**Author:** Erick Bourgeois
15+
16+
### Changed
17+
- `.github/workflows/build.yaml`:
18+
- `sign-artifacts` job: `if` flipped from `github.event_name == 'release'` to `!= 'pull_request'`. Added `attestations: write` and `contents: read` permissions. New step runs `actions/attest-build-provenance@v2` on each signed tarball (amd64 + arm64) — GitHub-native attestation in addition to the Cosign signature.
19+
- `generate-provenance-subjects` job: `if` flipped to `!= 'pull_request'` so SLSA subject hashes are computed on push-to-main too.
20+
- `slsa-provenance` job: `if` flipped to `!= 'pull_request'`; the reusable SLSA generator now runs on every push-to-main. `upload-assets` parameterised to `${{ github.event_name == 'release' }}` so the `.intoto.jsonl` only attaches to the GitHub Release on release events; on push-to-main it lands as a workflow artifact (verifiable via `slsa-verifier` or `gh attestation verify`).
21+
- `upload-release-assets` unchanged — stays `if: github.event_name == 'release'`; its `needs:` on `sign-artifacts`/`slsa-provenance` resolves cleanly whether those jobs ran (push, release) or were skipped (PR).
22+
23+
### Why
24+
Every merge to `main` produces a buildable, distributable artifact — it should carry the same supply-chain assurances as a release. With these changes, a `main` build now has: Cosign keyless signature on the container image (already in place), GitHub Artifact Attestation on the container image (already in place), Cosign signature on each binary tarball (new on `main`), GitHub Artifact Attestation on each binary tarball (new), and SLSA Level 3 provenance for the binaries (new on `main`). Consumers pulling a `main-YYYY-MM-DD` build can now cryptographically verify it the same way they would a release.
25+
26+
### Impact
27+
- [ ] Breaking change
28+
- [ ] Requires cluster rollout
29+
- [x] Config change only (CI/CD workflow)
30+
- [ ] Documentation only
31+
32+
### Operational note
33+
- Push-to-main runs now take longer (extra ~3–5 min for the SLSA generator + attestations). Expected trade-off.
34+
- `sign-artifacts` signed-tarball artifact retention is the repo default (90 days) — long enough for verification and audit, well under the release-asset lifetime.
35+
- Binary SLSA generator dependency pinned at `@v2.1.0` (unchanged) — verify this is the latest patched version periodically.
36+
37+
---
38+
39+
## [2026-04-19 15:00] - Harden OpenSSF Scorecard workflow: SPDX header + pin remaining action
40+
41+
**Author:** Erick Bourgeois
42+
43+
### Changed
44+
- `.github/workflows/scorecard.yaml`: Prepended the project's standard two-line SPDX/Copyright header (`Copyright (c) 2025 Erick Bourgeois, firestoned` + `SPDX-License-Identifier: Apache-2.0`) to match every other workflow in `.github/workflows/`. Pinned the previously unpinned `github/codeql-action/upload-sarif@v3` to the full commit SHA for v3.35.2 (`ce64ddcb0d8d890d2df4a9d1c04ff297367dea2a`) with an inline version comment — brings the last step into line with the other three actions in the file which were already SHA-pinned.
45+
46+
### Why
47+
OpenSSF Scorecard's own `Pinned-Dependencies` check flags float-tagged actions (`@v3`) as a supply-chain risk, so the scorecard workflow itself should score well on that check. The SPDX header is the repo-wide convention; every other workflow has it. Both changes are compliance housekeeping — no behavior change.
48+
49+
### Impact
50+
- [ ] Breaking change
51+
- [ ] Requires cluster rollout
52+
- [x] Config change only
53+
- [ ] Documentation only
54+
55+
---
56+
57+
## [2026-04-19 14:00] - Phases 3 + 4: Event-driven `.watches()` on CAPI Machine and Node
58+
59+
**Author:** Erick Bourgeois
60+
61+
### Changed
62+
- `src/reconcilers/helpers.rs`: Added two pure mapper functions used as secondary-watch reverse-maps on the Controller builder:
63+
- `machine_to_scheduled_machine(&DynamicObject) -> Vec<ObjectRef<ScheduledMachine>>` — reads the `5spot.eribourg.dev/scheduled-machine` label (constant `LABEL_SCHEDULED_MACHINE`) and emits at most one `ObjectRef`. Guards against missing/empty/whitespace label values, missing namespace, and imposter labels with similar prefixes.
64+
- `node_to_scheduled_machines<'a, I: IntoIterator<Item = &'a ScheduledMachine>>(&Node, I) -> Vec<ObjectRef<ScheduledMachine>>` — returns all SMs whose `status.nodeRef.name` matches `node.metadata.name`. Returns multiple refs on conflict so the reconciler can surface the issue. O(N) per Node event; documented as acceptable for current scale.
65+
- `src/reconcilers/mod.rs`: Re-exports both mappers alongside the existing `error_policy`, `evaluate_schedule`, `should_process_resource`.
66+
- `src/main.rs`: Extended the `Controller` builder with `.watches_with(...)` on CAPI `Machine` (`ApiResource` from GVK, label-filtered `watcher::Config`) and `.watches(...)` on `Node`. The Node mapper closure captures a clone of `controller.store()` (`reflector::Store<ScheduledMachine>`) and calls `state()` on each event to avoid out-of-band API lists.
67+
- `src/reconcilers/helpers_tests.rs`: Added 14 new unit tests — 7 for `machine_to_scheduled_machine` (positive match, missing label, no labels, empty value, whitespace value, missing namespace, wrong-prefix imposter) and 7 for `node_to_scheduled_machines` (single match, multi-match conflict, no match, empty list, SM-without-status, Node-without-name, empty `nodeRef.name`). Covers positive, negative, and exception paths per the project's 100%-coverage rule.
68+
69+
### Why
70+
Closes roadmap Phases 3 + 4 of `event-driven-watches-and-status-enrichment.md`. The `ScheduledMachine` controller previously only watched its own CR — all downstream state was polled during reconciles, which CLAUDE.md explicitly forbids ("ALWAYS use event-driven programming… as opposed to polling"). With these two watches, CAPI setting `status.nodeRef` or a Node being drained now enqueues an immediate reconcile via kube-rs's watch stream (<1s debounce) instead of waiting for the next periodic requeue. The label-based reverse map on Machines uses the label we already stamp on every child — zero new ownership semantics, no `controller: true` owner references introduced, no contention with CAPI's own controllers.
71+
72+
### Impact
73+
- [ ] Breaking change
74+
- [x] Requires cluster rollout — controller now opens additional watches (CAPI `Machine` with label selector; `Node` cluster-wide)
75+
- [ ] Config change only
76+
- [ ] Documentation only
77+
78+
### Operational note
79+
- RBAC: the controller already has `get`/`list`/`watch` on CAPI `Machine` (used for drain lookup) and needs `list`/`watch` on core `Node`. Verify `get,list,watch` on `nodes` is present in the ClusterRole before rollout; if not, add it in the same rollout.
80+
- Observability: the existing `reconcile_queue_depth` metric will show enqueues driven by Machine and Node events in addition to SM events.
81+
82+
---
83+
84+
## [2026-04-19 10:00] - Phase 2: Close test-coverage gap for async CAPI helpers
85+
86+
**Author:** Erick Bourgeois
87+
88+
### Changed
89+
- `src/reconcilers/helpers_tests.rs`: Added 14 `tower-test`-backed unit tests covering positive, negative, and exception paths for the three async helpers introduced in the previous Phase 2 entry:
90+
- `fetch_capi_machine` — 200 returns `Some`, 404 returns `Ok(None)`, 500 and 403 map to `CapiError`.
91+
- `patch_machine_refs_status` — both-fields patch asserts full body shape, single-field patches assert the other key is **omitted** (not null) so merge-patch never clears existing values, both-`None` case asserts **zero HTTP traffic**, 500 and 404 map to `KubeError`.
92+
- `get_node_from_machine` — success returns the node name, machine-404 and nodeRef-missing both return `Ok(None)`, 500 propagates as `CapiError`.
93+
94+
### Why
95+
Per durable user guidance: every function (public **and** private) must have unit tests covering the happy path, negative cases, and exception/error paths. The original Phase 2 change landed with tests only for the pure `extract_machine_refs` function — the three async helpers, which actually touch the Kubernetes API surface, were undertested. This entry closes that gap so the coverage floor now matches the project rule (not just CLAUDE.md's "public function" minimum).
96+
97+
### Impact
98+
- [ ] Breaking change
99+
- [ ] Requires cluster rollout
100+
- [ ] Config change only
101+
- [x] Documentation only (test-only change — no runtime behaviour modified)
102+
103+
---
104+
105+
## [2026-04-18 12:00] - Phase 2: Populate providerID and nodeRef from CAPI Machine
106+
107+
**Author:** Erick Bourgeois
108+
109+
### Changed
110+
- `src/reconcilers/helpers.rs`: Added three new helpers — `extract_machine_refs` (pure function pulling `providerID` + full `NodeRef` out of a CAPI Machine `DynamicObject`), `fetch_capi_machine` (typed 404 → `Ok(None)` wrapper around the dynamic GET), and `patch_machine_refs_status` (merge-patches both fields onto `ScheduledMachine.status`). Refactored `get_node_from_machine` to delegate to the first two helpers for the drain path.
111+
- `src/reconcilers/scheduled_machine.rs`: `handle_active_phase` happy path now fetches the CAPI Machine, extracts refs, and patches them onto the SM status. Failures are logged and ignored — status enrichment must never block reconciliation.
112+
- `src/reconcilers/helpers_tests.rs`: Added 6 TDD cases for `extract_machine_refs` covering fully populated, empty, providerID-only, nodeRef-without-uid, incomplete-nodeRef-returns-None, and malformed-providerID-ignored.
113+
114+
### Why
115+
Phase 2 of the event-driven watches + status enrichment roadmap. The schema landed in Phase 1; this change actually populates the new fields every time the reconciler visits an active machine, so `kubectl get sm -o jsonpath='{.status.providerID}{"\t"}{.status.nodeRef.name}'` returns meaningful values.
116+
117+
### Impact
118+
- [ ] Breaking change
119+
- [x] Requires cluster rollout — controller image carries the new reconcile logic
120+
- [ ] Config change only
121+
- [ ] Documentation only
122+
123+
---
124+
12125
## [2026-04-16 17:00] - Phase 1: Enrich ScheduledMachine status with providerID and full nodeRef
13126

14127
**Author:** Erick Bourgeois

.github/workflows/build.yaml

Lines changed: 18 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -531,15 +531,18 @@ jobs:
531531
output-filename: trivy-${{ github.event_name }}-${{ matrix.variant.name }}.json
532532

533533
# ─────────────────────────────────────────────────────────────────────────────
534-
# Release only: sign binaries with Cosign.
534+
# Push-to-main and release: sign binaries with Cosign and emit GitHub
535+
# artifact attestations. Skipped on PRs.
535536
# ─────────────────────────────────────────────────────────────────────────────
536537
sign-artifacts:
537538
name: ✍️ Sign Binary Artifacts
538-
if: github.event_name == 'release'
539+
if: github.event_name != 'pull_request'
539540
runs-on: ubuntu-latest
540541
needs: [build, extract-version]
541542
permissions:
542543
id-token: write
544+
attestations: write
545+
contents: read
543546
strategy:
544547
fail-fast: false
545548
matrix:
@@ -570,6 +573,11 @@ jobs:
570573
with:
571574
artifact-path: artifacts/${{ matrix.platform.artifact_name }}/${{ matrix.platform.artifact_name }}.tar.gz
572575

576+
- name: Attest binary tarball provenance
577+
uses: actions/attest-build-provenance@v2
578+
with:
579+
subject-path: artifacts/${{ matrix.platform.artifact_name }}/${{ matrix.platform.artifact_name }}.tar.gz
580+
573581
- name: Upload signed artifacts
574582
uses: actions/upload-artifact@v4
575583
with:
@@ -579,11 +587,11 @@ jobs:
579587
artifacts/${{ matrix.platform.artifact_name }}/${{ matrix.platform.artifact_name }}.tar.gz.bundle
580588
581589
# ─────────────────────────────────────────────────────────────────────────────
582-
# Release only: compute SHA-256 hashes of signed tarballs for SLSA.
590+
# Push-to-main and release: compute SHA-256 hashes of signed tarballs for SLSA.
583591
# ─────────────────────────────────────────────────────────────────────────────
584592
generate-provenance-subjects:
585593
name: 🔬 Generate SLSA Provenance Subjects
586-
if: github.event_name == 'release'
594+
if: github.event_name != 'pull_request'
587595
runs-on: ubuntu-latest
588596
needs: [sign-artifacts, extract-version]
589597
outputs:
@@ -605,11 +613,14 @@ jobs:
605613
echo "hashes=$(cat checksums.txt | base64 -w0)" >> "$GITHUB_OUTPUT"
606614
607615
# ─────────────────────────────────────────────────────────────────────────────
608-
# Release only: SLSA Level 3 provenance via the official generator.
616+
# Push-to-main and release: SLSA Level 3 provenance via the official generator.
617+
# `upload-assets` only applies on release (there is no GitHub Release object
618+
# on a push-to-main to attach assets to); on push the provenance lands as a
619+
# workflow artifact, verifiable with slsa-verifier or `gh attestation verify`.
609620
# ─────────────────────────────────────────────────────────────────────────────
610621
slsa-provenance:
611622
name: 🏛️ Generate SLSA Provenance
612-
if: github.event_name == 'release'
623+
if: github.event_name != 'pull_request'
613624
needs: [generate-provenance-subjects, extract-version]
614625
permissions:
615626
actions: read
@@ -618,7 +629,7 @@ jobs:
618629
uses: slsa-framework/slsa-github-generator/.github/workflows/generator_generic_slsa3.yml@v2.1.0
619630
with:
620631
base64-subjects: "${{ needs.generate-provenance-subjects.outputs.hashes }}"
621-
upload-assets: true
632+
upload-assets: ${{ github.event_name == 'release' }}
622633
provenance-name: "${{ needs.extract-version.outputs.version }}.intoto.jsonl"
623634

624635
# ─────────────────────────────────────────────────────────────────────────────

.github/workflows/scorecard.yaml

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
# Copyright (c) 2025 Erick Bourgeois, firestoned
2+
# SPDX-License-Identifier: Apache-2.0
3+
#
4+
# This workflow uses actions that are not certified by GitHub. They are provided
5+
# by a third-party and are governed by separate terms of service, privacy
6+
# policy, and support documentation.
7+
8+
name: Scorecard supply-chain security
9+
on:
10+
# For Branch-Protection check. Only the default branch is supported. See
11+
# https://github.com/ossf/scorecard/blob/main/docs/checks.md#branch-protection
12+
branch_protection_rule:
13+
# To guarantee Maintained check is occasionally updated. See
14+
# https://github.com/ossf/scorecard/blob/main/docs/checks.md#maintained
15+
schedule:
16+
- cron: '24 10 * * 3'
17+
push:
18+
branches: [ "main" ]
19+
20+
# Declare default permissions as read only.
21+
permissions: read-all
22+
23+
jobs:
24+
analysis:
25+
name: Scorecard analysis
26+
runs-on: ubuntu-latest
27+
# `publish_results: true` only works when run from the default branch. conditional can be removed if disabled.
28+
if: github.event.repository.default_branch == github.ref_name || github.event_name == 'pull_request'
29+
permissions:
30+
# Needed to upload the results to code-scanning dashboard.
31+
security-events: write
32+
# Needed to publish results and get a badge (see publish_results below).
33+
id-token: write
34+
# Uncomment the permissions below if installing in a private repository.
35+
# contents: read
36+
# actions: read
37+
38+
steps:
39+
- name: "Checkout code"
40+
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
41+
with:
42+
persist-credentials: false
43+
44+
- name: "Run analysis"
45+
uses: ossf/scorecard-action@f49aabe0b5af0936a0987cfb85d86b75731b0186 # v2.4.1
46+
with:
47+
results_file: results.sarif
48+
results_format: sarif
49+
# (Optional) "write" PAT token. Uncomment the `repo_token` line below if:
50+
# - you want to enable the Branch-Protection check on a *public* repository, or
51+
# - you are installing Scorecard on a *private* repository
52+
# To create the PAT, follow the steps in https://github.com/ossf/scorecard-action?tab=readme-ov-file#authentication-with-fine-grained-pat-optional.
53+
# repo_token: ${{ secrets.SCORECARD_TOKEN }}
54+
55+
# Public repositories:
56+
# - Publish results to OpenSSF REST API for easy access by consumers
57+
# - Allows the repository to include the Scorecard badge.
58+
# - See https://github.com/ossf/scorecard-action#publishing-results.
59+
# For private repositories:
60+
# - `publish_results` will always be set to `false`, regardless
61+
# of the value entered here.
62+
publish_results: true
63+
64+
# (Optional) Uncomment file_mode if you have a .gitattributes with files marked export-ignore
65+
# file_mode: git
66+
67+
# Upload the results as artifacts (optional). Commenting out will disable uploads of run results in SARIF
68+
# format to the repository Actions tab.
69+
- name: "Upload artifact"
70+
uses: actions/upload-artifact@4cec3d8aa04e39d1a68397de0c4cd6fb9dce8ec1 # v4.6.1
71+
with:
72+
name: SARIF file
73+
path: results.sarif
74+
retention-days: 5
75+
76+
# Upload the results to GitHub's code scanning dashboard (optional).
77+
# Commenting out will disable upload of results to your repo's Code Scanning dashboard
78+
- name: "Upload to code-scanning"
79+
uses: github/codeql-action/upload-sarif@ce64ddcb0d8d890d2df4a9d1c04ff297367dea2a # v3.35.2
80+
with:
81+
sarif_file: results.sarif

0 commit comments

Comments
 (0)