Skip to content

Commit 9e882e1

Browse files
authored
Phase 2-4: true event-driven watch, a ScheduledMachine.spec.spotSchedule reference now actually drives the machine's active/inactive decision (pull-on-reconcile) (#83)
* Phase 1: add spotSchedule to CRD and move to v1beta1 Signed-off-by: Erick Bourgeois <erick@jeb.ca> * Phase 2 and 3: true event-driven watch, a ScheduledMachine.spec.spotSchedule reference now actually drives the machine's active/inactive decision (pull-on-reconcile) Phase 4: least-privilege RBAC, admission posture, and threat model are now current. Signed-off-by: Erick Bourgeois <erick@jeb.ca> --------- Signed-off-by: Erick Bourgeois <erick@jeb.ca>
1 parent 883db3b commit 9e882e1

26 files changed

Lines changed: 2337 additions & 51 deletions

.claude/CHANGELOG.md

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

1010
---
1111

12+
## [2026-06-14 14:30] - Spot-schedule RBAC + admission + threat model (Phase 4)
13+
14+
**Author:** Erick Bourgeois
15+
16+
### Changed
17+
- `deploy/deployment/rbac/clusterrole.yaml`: NEW rule — `get;list;watch` on
18+
`spotschedules.5spot.finos.org` `*` (read-only, no write). Unblocks the Phase 3
19+
provider watch; least-privilege per ADR 0006 §6 (providers are untrusted
20+
inputs the controller never writes).
21+
- `deploy/admission/validatingadmissionpolicy.yaml`: **security fix**
22+
`matchConstraints.apiVersions` now covers `v1beta1` as well as `v1alpha1`.
23+
Since Phase 1 made `v1beta1` the served/storage version, every `v1beta1`
24+
ScheduledMachine (i.e. every `spotSchedule` machine) was **bypassing the
25+
entire VAP** — the bootstrap/infra allowlist and privilege-escalation guards
26+
included. Also: the four schedule rules are now guarded with
27+
`!has(object.spec.schedule) || …` (schedule is optional since v1beta1, so a
28+
valid spotSchedule-only machine is no longer wrongly rejected), and NEW rules
29+
7a (at least one of schedule/spotSchedule) + 7b (spotSchedule group pin)
30+
mirror the CRD CEL and `validate_activation_source()`.
31+
- `docs/src/security/threat-model.md`: NEW actor (spot-schedule provider,
32+
untrusted), threat T8a (provider-driven activation control — mitigated:
33+
read-only RBAC, same-namespace, group-pinned, auditable) and D5 (provider
34+
flapping — partially mitigated: back-off + lifecycle timers + transition
35+
metric/alert).
36+
- `docs/src/security/admission-validation.md`: documented the dual-version
37+
match, the optional-schedule guard, and rules 7a/7b.
38+
- `docs/src/operations/monitoring.md`: suggested `SpotScheduleProviderFlapping`
39+
and `SpotScheduleProviderUnresolved` Prometheus alerts.
40+
- `docs/architecture/calm/architecture.json`: `rel-controller-spot-schedule-watch`
41+
least-privilege control evidence now cites the implemented ClusterRole + VAP.
42+
`make calm-validate` clean.
43+
44+
### Why
45+
Phase 4: realize ADR 0006 §6's least-privilege, providers-are-untrusted posture
46+
in the deployed manifests, and bring the threat model current. The VAP
47+
version-match fix also closes an admission-bypass regression that Phase 1's
48+
v1beta1 bump introduced.
49+
50+
### Impact
51+
- [ ] Breaking change
52+
- [x] Requires cluster rollout (re-apply ClusterRole + ValidatingAdmissionPolicy)
53+
- [ ] Config change only
54+
- [ ] Documentation only
55+
56+
## [2026-06-14 11:00] - Spot-schedule dynamic event-driven provider watch (Phase 3)
57+
58+
**Author:** Erick Bourgeois
59+
60+
### Changed
61+
- `src/reconcilers/spot_schedule_watch.rs`: NEW. `ReverseIndex` (pure:
62+
`(gvk, ns, name)` → referencing `ScheduledMachine`s, with per-SM record so a
63+
changed/removed `spec.spotSchedule` updates precisely) + `provider_key_for`.
64+
`SpotScheduleWatchManager` owns the index and lazily runs **one
65+
`kube::runtime::watcher` stream per referenced provider GVK** (event-driven,
66+
not polling); `sync_watchers` reconciles the running watcher set against the
67+
index's referenced GVKs (spawn new, abort orphaned). Per-GVK task resolves the
68+
`ApiResource` via `discovery::pinned_kind` with back-off
69+
(`SPOT_SCHEDULE_DISCOVERY_RETRY_SECS`, handles CRD-installed-late and
70+
CRD-deleted-while-watched), maps each provider event back through the index,
71+
and enqueues the referencing SMs.
72+
- `src/main.rs`: a standalone `ScheduledMachine` reflector feeds SM apply/delete
73+
into the manager (index rebuilt from the reflector's initial list on restart —
74+
nothing stored outside Kubernetes); a second `Controller::reconcile_on`
75+
consumes the manager's mpsc so provider `status.active` flips trigger SM
76+
reconciles at watch latency.
77+
- `src/constants.rs`: `SPOT_SCHEDULE_EVENT_CHANNEL_CAP`,
78+
`SPOT_SCHEDULE_DISCOVERY_RETRY_SECS`.
79+
- `src/reconcilers/mod.rs`: export the watch types + `compose_should_be_active`.
80+
- Tests: `spot_schedule_watch_tests.rs` (13 — ReverseIndex register/replace/
81+
deregister/lookup/gvks, `provider_key_for`, manager observe/forget watcher
82+
lifecycle incl. shared-GVK and watcher-stop-on-last-deref);
83+
`tests/integration_spot_schedule.rs` (4 — create→active, flip→inactive,
84+
delete→unresolved+hold/fail-inactive across a mock kube API, and the watch
85+
index→reconcile mapping). 658 lib tests green.
86+
- Docs: contract page status → Phase 3.
87+
88+
### Why
89+
Phase 3 of the spot-schedule provider roadmap: remove the Phase-2 pull latency.
90+
Provider status transitions are now a reconcile **trigger** (a true watch over
91+
the `spotschedules.5spot.finos.org` group), not something re-checked only on the
92+
controller's own timer — satisfying the project's watch-not-poll rule. Realizes
93+
the `rel-controller-spot-schedule-watch` relationship + `flow-spot-schedule-activation`
94+
already modeled in CALM at Phase 0 (no ADR/CALM change).
95+
96+
### Impact
97+
- [ ] Breaking change
98+
- [x] Requires cluster rollout (new controller image; RBAC for
99+
`spotschedules.5spot.finos.org` get/list/watch lands in Phase 4)
100+
- [ ] Config change only
101+
- [ ] Documentation only
102+
103+
## [2026-06-13 18:45] - Spot-schedule resolver + composition + status surface (Phase 2)
104+
105+
**Author:** Erick Bourgeois
106+
107+
### Changed
108+
- `src/reconcilers/spot_schedule.rs`: NEW resolver module. `SpotScheduleVerdict`
109+
enum (`Active`/`Inactive`/`Unresolved`); pure `verdict_from_status` (duck-typed
110+
`status.active` + `Ready` extraction); async `resolve_spot_schedule`
111+
(`discovery::pinned_kind` + `get_opt`). Transient API errors propagate (retry);
112+
CRD-not-installed / object-absent / status-missing / not-Ready are `Unresolved`
113+
verdicts, never errors or panics. **Ready semantics finalized:** absent `Ready`
114+
`active` authoritative; present non-`True` `Ready` ⇒ unresolved (resolves an
115+
internal inconsistency in ADR 0006 §4 / contract table — both updated).
116+
- `src/reconcilers/helpers.rs`: NEW `compose_should_be_active` — AND composition
117+
of the inline schedule and the provider verdict, with hold-last-state on
118+
Unresolved (fail-inactive when never resolved). `killSwitch` precedence is
119+
upstream.
120+
- `src/crd.rs`: replaced Phase-1 `effective_schedule()` with `is_enabled()` (a
121+
spotSchedule-only machine is always enabled; the provider governs activity).
122+
NEW `SpotScheduleStatus` + `ScheduledMachineStatus.spot_schedule` field (the
123+
durable hold-last-state surface: `resolved`/`active`/`reason`/`message`/
124+
`providerGeneration`/`lastTransitionTime`).
125+
- `src/reconcilers/scheduled_machine.rs`: `reconcile_inner` now evaluates the
126+
optional inline schedule, resolves the provider (pull-on-reconcile), composes
127+
`should_be_active`, records metrics, and patches `status.spotSchedule`
128+
(`persist_spot_schedule_status`, a `/status` merge patch that coexists with the
129+
phase/condition writes). Handler `enabled` guards use `is_enabled()`.
130+
- `src/metrics.rs`: NEW `fivespot_spot_schedule_resolutions_total{namespace,kind,result}`,
131+
`_resolution_errors_total{namespace,kind,reason}`,
132+
`_transitions_total{namespace,kind}` (+ record fns; bounded cardinality, never
133+
keyed by name).
134+
- `deploy/crds/scheduledmachine.yaml` regenerated (status.spotSchedule);
135+
`docs/src/reference/api.md` regenerated.
136+
- Tests: `spot_schedule_tests.rs` (verdict truth table + 3 mock-client resolve
137+
paths), `helpers_tests.rs` (12-case composition truth table),
138+
`scheduled_machine_tests.rs` (persist `/status` patch), `metrics_tests.rs`,
139+
`crd_tests.rs` (status round-trip + is_enabled). 645 lib tests green.
140+
- Docs: ADR 0006 + contract page `Ready` clarification; contract page status →
141+
Phase 2; `docs/src/operations/monitoring.md` spot-schedule metrics table.
142+
143+
### Why
144+
Phase 2 of the spot-schedule provider roadmap: make a `spec.spotSchedule`
145+
reference actually drive the machine's active/inactive decision (pull-on-reconcile),
146+
with the ADR-specified hold-last-state fail-safe and an observable status surface.
147+
The event-driven watch (Phase 3) and the reference-provider controller (Phase 5)
148+
build on this.
149+
150+
### Impact
151+
- [ ] Breaking change
152+
- [x] Requires cluster rollout (apply regenerated CRDs + new controller image)
153+
- [ ] Config change only
154+
- [ ] Documentation only
155+
12156
## [2026-06-13 16:30] - Spot-schedule provider API: CRD scaffolding + ScheduledMachine v1beta1 (Phase 1)
13157

14158
**Author:** Erick Bourgeois

deploy/admission/validatingadmissionpolicy.yaml

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,8 +27,12 @@ spec:
2727

2828
matchConstraints:
2929
resourceRules:
30+
# Both served versions must be validated (ADR 0007). v1beta1 is the
31+
# storage/current version (adds spec.spotSchedule and an optional
32+
# spec.schedule); v1alpha1 is the frozen, deprecated legacy version. A
33+
# version omitted here would BYPASS every validation below.
3034
- apiGroups: ["5spot.finos.org"]
31-
apiVersions: ["v1alpha1"]
35+
apiVersions: ["v1alpha1", "v1beta1"]
3236
resources: ["scheduledmachines"]
3337
operations: ["CREATE", "UPDATE"]
3438

@@ -117,11 +121,19 @@ spec:
117121
(format: <positive integer> followed by s, m, or h)
118122
reason: Invalid
119123

124+
# NOTE on the schedule rules below: `spec.schedule` is OPTIONAL since
125+
# v1beta1 (ADR 0006) — a machine may instead delegate to a spotSchedule
126+
# provider. Each schedule rule is therefore guarded with
127+
# `!has(object.spec.schedule) || …` so a valid spotSchedule-only machine is
128+
# not rejected. The "at least one of schedule/spotSchedule" invariant is
129+
# enforced by rule 7a below (and by the CRD's own x-kubernetes-validations).
130+
120131
# ── 4. Cron XOR explicit day/hour windows ────────────────────────────────
121132
# Using cron together with daysOfWeek or hoursOfDay is ambiguous.
122133
# The controller silently ignores daysOfWeek/hoursOfDay when cron is set;
123134
# we reject the spec at admission to prevent silent data loss.
124135
- expression: |
136+
!has(object.spec.schedule) ||
125137
!has(object.spec.schedule.cron) ||
126138
(object.spec.schedule.daysOfWeek.size() == 0 &&
127139
object.spec.schedule.hoursOfDay.size() == 0)
@@ -132,6 +144,7 @@ spec:
132144
133145
# ── 5. Without cron, both day and hour windows must be provided ───────────
134146
- expression: |
147+
!has(object.spec.schedule) ||
135148
has(object.spec.schedule.cron) ||
136149
(object.spec.schedule.daysOfWeek.size() > 0 &&
137150
object.spec.schedule.hoursOfDay.size() > 0)
@@ -145,6 +158,7 @@ spec:
145158
# or a comma-separated combination (mon-wed,fri-sun).
146159
# Trivially true for empty arrays (cron path).
147160
- expression: |
161+
!has(object.spec.schedule) ||
148162
object.spec.schedule.daysOfWeek.all(d,
149163
d.matches('^(mon|tue|wed|thu|fri|sat|sun)(-(mon|tue|wed|thu|fri|sat|sun))?(,(mon|tue|wed|thu|fri|sat|sun)(-(mon|tue|wed|thu|fri|sat|sun))?)*$'))
150164
message: >-
@@ -158,13 +172,35 @@ spec:
158172
# at runtime by the schedule evaluator.
159173
# Trivially true for empty arrays (cron path).
160174
- expression: |
175+
!has(object.spec.schedule) ||
161176
object.spec.schedule.hoursOfDay.all(h,
162177
h.matches('^[0-9]{1,2}(-[0-9]{1,2})?(,[0-9]{1,2}(-[0-9]{1,2})?)*$'))
163178
message: >-
164179
spec.schedule.hoursOfDay items must be hours or ranges
165180
(e.g. '9', '9-17', '0-9,18-23')
166181
reason: Invalid
167182
183+
# ── 7a. At least one activation source ────────────────────────────────────
184+
# A machine must declare an inline schedule, a spotSchedule provider, or
185+
# both (ADR 0006). Mirrors the CRD x-kubernetes-validations and the runtime
186+
# validate_activation_source() in src/reconcilers/helpers.rs.
187+
- expression: "has(object.spec.schedule) || has(object.spec.spotSchedule)"
188+
message: >-
189+
at least one of spec.schedule or spec.spotSchedule must be set
190+
reason: Invalid
191+
192+
# ── 7b. spotSchedule.apiVersion group pin ─────────────────────────────────
193+
# The provider reference group MUST be spotschedules.5spot.finos.org
194+
# (ADR 0006 §2 / §6) — providers are untrusted inputs and the controller's
195+
# read-only RBAC is scoped to exactly this group. Mirrors the CRD field CEL
196+
# and the runtime group check.
197+
- expression: |
198+
!has(object.spec.spotSchedule) ||
199+
object.spec.spotSchedule.apiVersion.startsWith('spotschedules.5spot.finos.org/')
200+
message: >-
201+
spec.spotSchedule.apiVersion group must be spotschedules.5spot.finos.org
202+
reason: Invalid
203+
168204
# ── 8. bootstrapSpec.apiVersion must use a namespaced API group ───────────
169205
# Core API versions (e.g. "v1") have no '/' and must never be used for
170206
# bootstrap resources — rejects the same case as validate_api_group() at runtime.

deploy/crds/scheduledmachine.yaml

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -673,6 +673,47 @@ spec:
673673
(Pending, ShuttingDown, Inactive, Disabled, Terminated, Error) is
674674
reported as `False`.
675675
type: boolean
676+
spotSchedule:
677+
description: |-
678+
Spot-schedule provider resolution state (ADR 0006), present only when
679+
`spec.spotSchedule` is set. Carries the last resolved/held provider
680+
`active` value (the input to hold-last-state composition), the
681+
resolution reason/message, and the provider's observed generation.
682+
nullable: true
683+
properties:
684+
active:
685+
description: |-
686+
Last known provider `status.active`. Held across an unresolved reconcile
687+
(hold-last-state); `None` until the provider first resolves.
688+
nullable: true
689+
type: boolean
690+
lastTransitionTime:
691+
description: When `active` last transitioned (RFC3339).
692+
nullable: true
693+
type: string
694+
message:
695+
description: Human-readable resolution detail.
696+
nullable: true
697+
type: string
698+
providerGeneration:
699+
description: The provider's `status.observedGeneration` at the last resolution.
700+
format: int64
701+
nullable: true
702+
type: integer
703+
reason:
704+
description: Machine-readable resolution reason (a `REASON_SPOT_SCHEDULE_*` value).
705+
nullable: true
706+
type: string
707+
resolved:
708+
description: |-
709+
Whether the referenced provider resolved into an authoritative verdict
710+
this reconcile. `false` mirrors a `SpotScheduleResolved=False`
711+
condition — the provider CRD is absent, the object is missing, it
712+
exposes no `status.active`, or it is not `Ready`.
713+
type: boolean
714+
required:
715+
- resolved
716+
type: object
676717
type: object
677718
required:
678719
- spec
@@ -1204,6 +1245,47 @@ spec:
12041245
(Pending, ShuttingDown, Inactive, Disabled, Terminated, Error) is
12051246
reported as `False`.
12061247
type: boolean
1248+
spotSchedule:
1249+
description: |-
1250+
Spot-schedule provider resolution state (ADR 0006), present only when
1251+
`spec.spotSchedule` is set. Carries the last resolved/held provider
1252+
`active` value (the input to hold-last-state composition), the
1253+
resolution reason/message, and the provider's observed generation.
1254+
nullable: true
1255+
properties:
1256+
active:
1257+
description: |-
1258+
Last known provider `status.active`. Held across an unresolved reconcile
1259+
(hold-last-state); `None` until the provider first resolves.
1260+
nullable: true
1261+
type: boolean
1262+
lastTransitionTime:
1263+
description: When `active` last transitioned (RFC3339).
1264+
nullable: true
1265+
type: string
1266+
message:
1267+
description: Human-readable resolution detail.
1268+
nullable: true
1269+
type: string
1270+
providerGeneration:
1271+
description: The provider's `status.observedGeneration` at the last resolution.
1272+
format: int64
1273+
nullable: true
1274+
type: integer
1275+
reason:
1276+
description: Machine-readable resolution reason (a `REASON_SPOT_SCHEDULE_*` value).
1277+
nullable: true
1278+
type: string
1279+
resolved:
1280+
description: |-
1281+
Whether the referenced provider resolved into an authoritative verdict
1282+
this reconcile. `false` mirrors a `SpotScheduleResolved=False`
1283+
condition — the provider CRD is absent, the object is missing, it
1284+
exposes no `status.active`, or it is not `Ready`.
1285+
type: boolean
1286+
required:
1287+
- resolved
1288+
type: object
12071289
type: object
12081290
required:
12091291
- spec

deploy/deployment/rbac/clusterrole.yaml

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,20 @@ rules:
2828
resources: ["scheduledmachines/finalizers"]
2929
verbs: ["update"]
3030

31+
# Spot-schedule provider resources (ADR 0006). The controller WATCHES and
32+
# READS provider objects in the spotschedules.5spot.finos.org group to resolve
33+
# `spec.spotSchedule` into a duck-typed status.active verdict — it never reads
34+
# the provider spec semantics and NEVER writes a provider object. Resources is
35+
# a wildcard because 5-Spot is provider-agnostic (CapitalMarketsSchedule today,
36+
# any third-party provider kind tomorrow); verbs are deliberately read-only —
37+
# providers are untrusted inputs whose blast radius is bounded to the
38+
# same-namespace ScheduledMachines that reference them (ADR 0006 §6). The
39+
# provider's OWN controller holds update/patch on its kind's /status (a
40+
# separate Role it ships, not this ClusterRole).
41+
- apiGroups: ["spotschedules.5spot.finos.org"]
42+
resources: ["*"]
43+
verbs: ["get", "list", "watch"]
44+
3145
# CAPI Machine management
3246
- apiGroups: ["cluster.x-k8s.io"]
3347
resources: ["machines", "machines/status"]

docs/adr/0006-pluggable-spot-schedule-provider-contract.md

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -109,8 +109,12 @@ killSwitch > (schedule AND spotSchedule) > schedule-only / spotSchedule-only
109109
### 4. Unresolved references never flap machines
110110

111111
A reference is **Unresolved** when the provider CRD is not installed, the named
112-
object is absent, it has no `status.active`, or its `Ready` condition is
113-
`False`/missing. On Unresolved, 5-Spot:
112+
object is absent, it has no `status.active`, or it carries a `Ready` condition
113+
whose status is **not `True`**. `Ready` is *recommended, not required*: a
114+
provider that **omits** `Ready` has its `status.active` taken as authoritative
115+
(resolved) — only a *present, non-`True`* `Ready` marks the reference
116+
unresolved, which is how a provider explicitly says "do not trust my `active`
117+
right now." On Unresolved, 5-Spot:
114118

115119
- sets a `SpotScheduleResolved=False` condition on the `ScheduledMachine` with
116120
a precise reason (`ProviderCRDNotInstalled`, `ProviderNotFound`,

docs/architecture/calm/architecture.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -724,7 +724,7 @@
724724
"config": {
725725
"framework": "NIST SP 800-53 Rev. 5",
726726
"controls": ["AC-3", "AC-4", "AC-6", "SI-10"],
727-
"evidence": "docs/adr/0006-pluggable-spot-schedule-provider-contract.md, docs/src/security/threat-model.md"
727+
"evidence": "deploy/deployment/rbac/clusterrole.yaml (spotschedules.5spot.finos.org get/list/watch only), deploy/admission/validatingadmissionpolicy.yaml (rules 7a/7b), docs/adr/0006-pluggable-spot-schedule-provider-contract.md, docs/src/security/threat-model.md"
728728
}
729729
}
730730
]

0 commit comments

Comments
 (0)