|
| 1 | +<!-- |
| 2 | +Copyright (c) 2026 Erick Bourgeois, 5-Spot |
| 3 | +SPDX-License-Identifier: Apache-2.0 |
| 4 | +--> |
| 5 | +# 0006 — Pluggable spot-schedule provider contract via `spec.spotSchedule` and the `spotschedules.5spot.finos.org` API group |
| 6 | + |
| 7 | +- **Status:** Accepted |
| 8 | +- **Date:** 2026-06-13 |
| 9 | +- **Deciders:** Erick Bourgeois, 5-Spot team |
| 10 | +- **Supersedes:** — |
| 11 | +- **Related:** ADR [0007](./0007-crd-multi-version-and-conversion.md) (the |
| 12 | + multi-version/conversion story this contract depends on); |
| 13 | + `ScheduleSpec` + `evaluate_schedule` (`src/crd.rs`, `src/reconcilers/helpers.rs`); |
| 14 | + `ObjectReference`-style refs already in `src/crd.rs`; |
| 15 | + the reclaim-agent Node-watch (`rel-controller-workload-kube-api`) as the |
| 16 | + event-driven watch shape to mirror; contract page |
| 17 | + `docs/src/reference/spot-schedule-contract.md`. |
| 18 | + |
| 19 | +## Context |
| 20 | + |
| 21 | +A `ScheduledMachine` decides "should this machine exist right now?" solely from |
| 22 | +the inline `spec.schedule` (`daysOfWeek` / `hoursOfDay` / `timezone` / |
| 23 | +`enabled`), evaluated by `evaluate_schedule()` in `src/reconcilers/helpers.rs`. |
| 24 | +That model is **closed**: every new activation semantic — exchange calendars, |
| 25 | +statutory holidays and early closes, change-freeze windows, metric-driven |
| 26 | +scale, a manual trading-desk drain — would force a new field on 5-Spot's CRD |
| 27 | +and new branches in its reconciler. The motivating case is capital markets: |
| 28 | +trading-floor compute must follow the *exchange session calendar*, which no |
| 29 | +`daysOfWeek`/`hoursOfDay` expression can encode. |
| 30 | + |
| 31 | +**Options weighed:** |
| 32 | + |
| 33 | +1. **Grow `spec.schedule`** with calendar/holiday/PromQL sub-objects. Rejected: |
| 34 | + unbounded surface area, 5-Spot would own every scheduling dialect forever, |
| 35 | + and operators still couldn't express semantics we didn't anticipate. |
| 36 | +2. **A plugin/script hook** (WASM, embedded expression language) evaluated |
| 37 | + in-process. Rejected: a sandbox and execution-safety burden inside a |
| 38 | + privileged controller, and still not a Kubernetes-native contract. |
| 39 | +3. **A duck-typed external provider resource** that owns the active/inactive |
| 40 | + decision; 5-Spot consumes it generically. **Chosen.** This is the exact |
| 41 | + shape Cluster API uses for `infrastructureRef` / `bootstrap.configRef` |
| 42 | + (a contract over `apiVersion`/`kind`/`name` reading a well-known status |
| 43 | + field) — a pattern 5-Spot already participates in from the *other* side, so |
| 44 | + it is idiomatic here and familiar to operators. |
| 45 | + |
| 46 | +The cost of option 3 is the one recorded below: a provider is an **untrusted |
| 47 | +input** that can start and stop the machines that reference it, and an |
| 48 | +unresolved/late/unhealthy provider must never flap machines. |
| 49 | + |
| 50 | +## Decision |
| 51 | + |
| 52 | +### 1. `spec.spotSchedule` reference on `ScheduledMachine` |
| 53 | + |
| 54 | +`src/crd.rs` is the source of truth; CRD YAML and API docs are regenerated |
| 55 | +(`regen-crds` → `regen-api-docs`), never hand-edited. |
| 56 | + |
| 57 | +```rust |
| 58 | +// on ScheduledMachineSpec — schedule becomes optional (see §3) |
| 59 | +#[serde(skip_serializing_if = "Option::is_none")] |
| 60 | +pub spot_schedule: Option<SpotScheduleRef>, |
| 61 | + |
| 62 | +pub struct SpotScheduleRef { |
| 63 | + pub api_version: String, // "spotschedules.5spot.finos.org/<version>" |
| 64 | + pub kind: String, // e.g. "CapitalMarketsSchedule" |
| 65 | + pub name: String, // object in the SAME namespace as the SM |
| 66 | +} |
| 67 | +``` |
| 68 | + |
| 69 | +`SpotScheduleRef` keeps the existing ref conventions (`deny_unknown_fields`, |
| 70 | +camelCase, schemars bounds). The referenced object **must live in the |
| 71 | +`ScheduledMachine`'s own namespace** — no `namespace` field, no cross-namespace |
| 72 | +references in this version. |
| 73 | + |
| 74 | +### 2. The provider group and the duck-typed contract |
| 75 | + |
| 76 | +The provider API group is **`spotschedules.5spot.finos.org`** — a sub-group of |
| 77 | +the existing `5spot.finos.org` group. A CEL `XValidation` on |
| 78 | +`spotSchedule.apiVersion` pins the **group** to exactly |
| 79 | +`spotschedules.5spot.finos.org` (any served version is accepted, per ADR 0007); |
| 80 | +references to any other group are rejected at admission. |
| 81 | + |
| 82 | +5-Spot reads a **duck-typed `status`** and never the provider `spec`: |
| 83 | + |
| 84 | +| Provider `status` field | Required | Meaning to 5-Spot | |
| 85 | +|---|---|---| |
| 86 | +| `active` (bool) | **yes** | the single source of truth: the machine should be up | |
| 87 | +| `conditions[type=Ready]` | recommended | provider health; `Ready=False` ⇒ **unresolved**, *not* inactive | |
| 88 | +| `observedGeneration` (int) | recommended | staleness detection | |
| 89 | +| `lastTransitionTime` (string) | recommended | observability / transition metrics | |
| 90 | + |
| 91 | +Providers implement `spec` however they like (exchange calendars, PromQL, |
| 92 | +cron, a plain `enabled` toggle). The contract is published in |
| 93 | +`docs/src/reference/spot-schedule-contract.md`. |
| 94 | + |
| 95 | +### 3. Composition with `spec.schedule` (AND), and `killSwitch` precedence |
| 96 | + |
| 97 | +`spec.schedule` becomes `Option<ScheduleSpec>`. A CEL `XValidation` requires |
| 98 | +**at least one** of `schedule` / `spotSchedule` (existing SMs all set |
| 99 | +`schedule`, so this is non-breaking). When **both** are present the machine is |
| 100 | +active **iff the inline time window AND the provider both say active** — this |
| 101 | +supports "the market is open **and** only 09:00–17:00 local". `killSwitch` |
| 102 | +continues to override everything: `killSwitch=true` ⇒ inactive regardless of |
| 103 | +schedule or provider. Precedence, highest first: |
| 104 | + |
| 105 | +``` |
| 106 | +killSwitch > (schedule AND spotSchedule) > schedule-only / spotSchedule-only |
| 107 | +``` |
| 108 | + |
| 109 | +### 4. Unresolved references never flap machines |
| 110 | + |
| 111 | +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: |
| 114 | + |
| 115 | +- sets a `SpotScheduleResolved=False` condition on the `ScheduledMachine` with |
| 116 | + a precise reason (`ProviderCRDNotInstalled`, `ProviderNotFound`, |
| 117 | + `StatusActiveMissing`, `ProviderNotReady`), |
| 118 | +- increments `fivespot_spot_schedule_resolution_errors_total`, and |
| 119 | +- **holds the last known resolved state** — it does **not** tear down a running |
| 120 | + machine because its provider went briefly unreadable. If the reference has |
| 121 | + **never** resolved, the fail-safe default is **inactive** (no machine is |
| 122 | + created on the strength of a reference we cannot read). |
| 123 | + |
| 124 | +This "hold-last-state, fail-inactive-when-never-resolved" rule is the sharpest |
| 125 | +edge of the contract and is tested on both branches. |
| 126 | + |
| 127 | +### 5. Event-driven dynamic watch (no polling) |
| 128 | + |
| 129 | +The controller maintains an in-memory **reverse index** |
| 130 | +`(group, version-agnostic kind, namespace, name) → {ScheduledMachine keys}`, |
| 131 | +updated on SM apply/delete. For each distinct **GVK** referenced, it lazily |
| 132 | +starts a dynamic watch (`Api<DynamicObject>` resolved via `kube::discovery`) |
| 133 | +and maps provider events back through the index to `reconcile_on` the affected |
| 134 | +`ScheduledMachine`s. Streams are stopped when the last referencing SM goes |
| 135 | +away. The index is **rebuilt from the SM list on controller start** — nothing |
| 136 | +is stored outside Kubernetes (5-Spot stays stateless). This mirrors the |
| 137 | +existing reclaim-agent Node-watch and honours the project's |
| 138 | +event-driven-not-polling rule; provider `status.active` transitions drive SM |
| 139 | +reconciles at watch latency, matching the inline-schedule path. |
| 140 | + |
| 141 | +Hard edges handled (and tested): provider CRD installed *after* SMs reference |
| 142 | +it (discovery retry with backoff, surfaced as `Unresolved` meanwhile), provider |
| 143 | +CRD deleted while watched (stream error → re-resolve), controller restart |
| 144 | +(index rebuilt on boot). |
| 145 | + |
| 146 | +### 6. Security boundary — providers are untrusted inputs |
| 147 | + |
| 148 | +- Controller RBAC gains **`get;list;watch` only** on |
| 149 | + `spotschedules.5spot.finos.org` `*` — **no write** to any provider resource. |
| 150 | +- A reference provider's own controller gets `update;patch` on **its own** |
| 151 | + kind's `/status` subresource only (a separate Role, not 5-Spot's ClusterRole). |
| 152 | +- A compromised or buggy provider can flap the machines that *reference* it — |
| 153 | + the **same blast radius as a malicious edit to `spec.schedule.enabled`**, and |
| 154 | + bounded to SMs that opted in by naming it. Mitigations: same-namespace-only |
| 155 | + references, RBAC on the provider CRDs, audit via the `SpotScheduleResolved` |
| 156 | + condition + transition metrics, and a transition-rate alert. This actor and |
| 157 | + these mitigations are added to `docs/src/security/threat-model.md`. |
| 158 | + |
| 159 | +## Consequences |
| 160 | + |
| 161 | +- **Easier:** activation semantics evolve **out of tree** — capital-markets |
| 162 | + calendars, PromQL emitters, change-freeze gates ship as provider CRDs without |
| 163 | + touching 5-Spot's CRD or reconciler; 5-Spot gains **no** new write ability |
| 164 | + (read-only on providers); the inline `spec.schedule` stays the simple path. |
| 165 | +- **Harder / new burden:** a dynamic multi-GVK watch manager + reverse index to |
| 166 | + build and test; a published, versioned **public contract** (ADR 0007) we must |
| 167 | + not break; provider-flapping is a new failure mode requiring debounce-style |
| 168 | + thinking (a per-SM `minimumStateDuration` is noted as possible future work, |
| 169 | + not adopted here). |
| 170 | +- **Operator contract:** the referenced provider object **must pre-exist** in |
| 171 | + the SM's namespace and report `status.active`; absence/unreadiness is a |
| 172 | + loud `SpotScheduleResolved=False` status, never a silent machine teardown. |
| 173 | +- **Ruled out (this version):** cross-namespace / cross-cluster references; |
| 174 | + multiple `spotSchedule` refs per SM (no AND/OR ref-lists); 5-Spot ever |
| 175 | + writing provider status; a provider SDK/conformance kit; the Prometheus |
| 176 | + emitter implementation itself (a planned *consumer* of this contract). |
| 177 | +- **CALM impact:** **updated.** New external node |
| 178 | + `service-spot-schedule-provider`; a `data-asset-spot-schedule-cr`; a |
| 179 | + controller→provider **watch** relationship |
| 180 | + (`rel-controller-spot-schedule-watch`, read-only, group-pinned, least |
| 181 | + privilege control); and a flow `flow-spot-schedule-activation` |
| 182 | + (provider `status.active` transition → SM reconcile → activate/deactivate via |
| 183 | + the existing schedule flows). `make calm-validate` + `make calm-diagrams` |
| 184 | + before implementation. |
0 commit comments