Skip to content

Commit a3c5334

Browse files
Add design doc for extracting sans-I/O pattern into reusable crate (#33)
* Add design doc for extracting sans-I/O pattern into reusable crate Analyzes what it would take to extract the generalizable "Functional Core, Imperative Shell" architecture from crates/controlplane into a standalone kube-sans-io crate. Covers generic ReconcileResult<U,S>, Snapshot trait, executor traits, migration phases, and design decisions. https://claude.ai/code/session_01DSEmZoHqrhsQUZwzU2SHoR * Update design doc: dyn dispatch via #[async_trait], jiff over chrono Major changes: - Executor trait uses #[async_trait] for object safety, enabling dyn Executor for test fakes (RecordingExecutor, FailingExecutor) - Single Executor trait with associated types replaces three separate traits - Ships built-in test fakes in kube_sans_io::testing module - ControllerContext holds Box<dyn Executor<...>> for dependency injection - Replace chrono with jiff::Timestamp for datetime operations - Add testing layer summary showing what's fakeable at each level https://claude.ai/code/session_01DSEmZoHqrhsQUZwzU2SHoR * Add structured Linear ticket content for kube-sans-io crate extraction Parent issue + 4 sub-issues (one per migration phase) with detailed acceptance criteria including linting, formatting, and test requirements. https://claude.ai/code/session_01DSEmZoHqrhsQUZwzU2SHoR * Add dependency graph and blocked-by labels to ticket content Each sub-issue now has a "Blocked by" field showing which phase must complete first. Added a visual dependency graph at the top for quick reference. All four phases are strictly sequential. https://claude.ai/code/session_01DSEmZoHqrhsQUZwzU2SHoR --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 91e79fb commit a3c5334

2 files changed

Lines changed: 1005 additions & 0 deletions

File tree

Lines changed: 232 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,232 @@
1+
# Linear Tickets: `kube-sans-io` Crate Extraction
2+
3+
> Structured ticket content for tracking the extraction of the sans-I/O
4+
> controller pattern into a standalone crate. See
5+
> [sans-io-crate-extraction.md](./sans-io-crate-extraction.md) for the full
6+
> design document.
7+
>
8+
> **Project:** Helm Support
9+
10+
## Dependency Graph
11+
12+
```
13+
Sub-issue 1: Create kube-sans-io crate (Phase 1)
14+
15+
16+
Sub-issue 2: Adapt controlplane imports (Phase 2)
17+
│ [blocked by Phase 1]
18+
19+
Sub-issue 3: Executor trait + dyn dispatch (Phase 3)
20+
│ [blocked by Phase 2]
21+
22+
Sub-issue 4: Test fakes + docs (Phase 4)
23+
[blocked by Phase 3]
24+
```
25+
26+
All sub-issues are strictly sequential — each phase depends on the previous
27+
one. The parent issue is complete when all four sub-issues are done.
28+
29+
---
30+
31+
## Parent Issue
32+
33+
**Title:** Extract `kube-sans-io` crate from controlplane
34+
35+
**Description:**
36+
37+
Extract the generalizable "Functional Core, Imperative Shell" controller
38+
pattern from `crates/controlplane` into a standalone `crates/kube-sans-io`
39+
crate that any Kubernetes controller can use to separate effectful operations
40+
from domain logic.
41+
42+
The new crate provides:
43+
- `ReconcileResult<U, S>` — generic effects-as-data struct parameterized over domain types
44+
- `Snapshot` trait — injected `jiff::Timestamp` for deterministic testing
45+
- `NamespacedStore<T>` / `ClusterStore<T>` — common resource storage patterns
46+
- `Executor` trait (`#[async_trait]`, dyn-safe) — apply effects with pluggable backends
47+
- `execute()` orchestration function — correct ordering (status → upserts → deletes)
48+
- Built-in test fakes — `RecordingExecutor`, `FailingExecutor`
49+
50+
Design doc: `docs/design/sans-io-crate-extraction.md`
51+
52+
**Acceptance Criteria:**
53+
54+
- [ ] `crates/kube-sans-io` exists as a workspace member with its own `Cargo.toml`
55+
- [ ] Generic `ReconcileResult<U, S>`, `Snapshot` trait, `Executor` trait, and built-in test fakes are implemented
56+
- [ ] `crates/controlplane` depends on `kube-sans-io` and uses its types (no duplication)
57+
- [ ] `ControllerContext` holds a `Box<dyn Executor<...>>` for dependency injection
58+
- [ ] All existing tests pass: `cargo make dev-test-flow`
59+
- [ ] `cargo make fmt` and `cargo make clippy-flow` pass with zero warnings
60+
- [ ] Design doc at `docs/design/sans-io-crate-extraction.md` is kept up to date
61+
- [ ] Crate has its own unit tests and doc examples
62+
63+
---
64+
65+
## Sub-issue 1: Create `crates/kube-sans-io` with generic types (Phase 1)
66+
67+
**Title:** Create `kube-sans-io` crate with generic `ReconcileResult`, `Snapshot` trait, and store types
68+
69+
**Blocked by:** (none — this is the first phase)
70+
71+
**Description:**
72+
73+
Initialize the new crate and implement the generic types that form the
74+
skeleton of the sans-I/O pattern. This phase is purely additive — no
75+
existing code is modified.
76+
77+
Types to create:
78+
- `ReconcileResult<U, S>` generic struct with `merge()`, `with_requeue()`, `emit_event()`, `has_upserts()`, `has_deletes()`, `has_status_updates()`, `delete_resource()` methods
79+
- `RequeueDecision` enum (`After` / `OnError` / `Never`) with `min()` merging logic
80+
- `ReconcileEvent` and `EventSeverity` types
81+
- `ResourceDelete` type (already uses generic strings)
82+
- `Snapshot` trait with `fn now(&self) -> jiff::Timestamp`
83+
- `NamespacedStore<T>` / `ClusterStore<T>` type aliases with `insert_namespaced()`, `insert_cluster_scoped()`, `get_namespaced()` helpers
84+
85+
Reference: `docs/design/sans-io-crate-extraction.md` §1–3, Phase 1.
86+
87+
Estimated effort: ~200 new lines.
88+
89+
**Acceptance Criteria:**
90+
91+
- [ ] `cargo init crates/kube-sans-io --lib` with workspace membership in root `Cargo.toml`
92+
- [ ] `ReconcileResult<U, S>` struct with all generic methods listed in the design doc
93+
- [ ] `RequeueDecision` enum with `min()` merging — unit test: `min(After(5s), After(10s)) == After(5s)`, `min(Never, After(5s)) == After(5s)`, `min(OnError(_), After(_)) == After(_)`
94+
- [ ] `ReconcileEvent`, `EventSeverity`, `ResourceDelete` types implemented
95+
- [ ] `Snapshot` trait with `fn now(&self) -> jiff::Timestamp`
96+
- [ ] `NamespacedStore<T>` / `ClusterStore<T>` type aliases and helpers (`insert_namespaced`, `insert_cluster_scoped`, `get_namespaced`)
97+
- [ ] Unit tests for `ReconcileResult::merge()` (merges upserts, status updates, deletes, events; picks minimum requeue)
98+
- [ ] Unit tests for snapshot store helpers (insert + retrieve, missing key returns `None`)
99+
- [ ] `cargo make fmt` passes
100+
- [ ] `cargo make clippy-flow` passes with zero warnings
101+
- [ ] No changes to `crates/controlplane` in this phase
102+
103+
---
104+
105+
## Sub-issue 2: Adapt controlplane to import `kube-sans-io` generic types (Phase 2)
106+
107+
**Title:** Replace controlplane's `ReconcileResult` internals with `kube-sans-io` generic types
108+
109+
**Blocked by:** Sub-issue 1 (Phase 1 — crate must exist before controlplane can depend on it)
110+
111+
**Description:**
112+
113+
Wire `crates/controlplane` to depend on `kube-sans-io` and replace its own
114+
`ReconcileResult`, `RequeueDecision`, `ReconcileEvent`, `EventSeverity`, and
115+
`ResourceDelete` with imports from the new crate. Define a type alias and
116+
extension trait for domain-specific convenience methods. Update
117+
`WorldSnapshot` to use `NamespacedStore<T>` / `ClusterStore<T>` and impl
118+
`Snapshot`.
119+
120+
This is a pure mechanical refactor — zero behavioral changes.
121+
122+
Key pattern:
123+
```rust
124+
use kube_sans_io::result::ReconcileResult as BaseResult;
125+
126+
pub type ReconcileResult = BaseResult<ResourceUpsert, StatusUpdate>;
127+
128+
pub trait ReconcileResultExt {
129+
fn upsert_deployment(self, deployment: Deployment) -> Self;
130+
fn upsert_service(self, service: Service) -> Self;
131+
// ...
132+
}
133+
```
134+
135+
Reference: `docs/design/sans-io-crate-extraction.md` §Phase 2.
136+
137+
Estimated effort: ~50 new lines, ~100 changed lines.
138+
139+
**Acceptance Criteria:**
140+
141+
- [ ] `kube-sans-io` added as dependency in `crates/controlplane/Cargo.toml`
142+
- [ ] `ReconcileResult` is a type alias: `type ReconcileResult = kube_sans_io::ReconcileResult<ResourceUpsert, StatusUpdate>`
143+
- [ ] `ReconcileResultExt` extension trait provides domain-specific convenience methods (`upsert_deployment`, `upsert_service`, `update_gateway_status`, etc.)
144+
- [ ] `RequeueDecision`, `ReconcileEvent`, `EventSeverity`, `ResourceDelete` imported from `kube-sans-io` (originals deleted from controlplane)
145+
- [ ] `WorldSnapshot` fields use `NamespacedStore<T>` / `ClusterStore<T>` from `kube-sans-io`
146+
- [ ] `WorldSnapshot` implements `kube_sans_io::Snapshot` trait
147+
- [ ] All existing tests pass unchanged: `cargo nextest run` in `crates/controlplane` — same test cases, same assertions, zero modifications to test files
148+
- [ ] `cargo make fmt` passes
149+
- [ ] `cargo make clippy-flow` passes with zero warnings
150+
- [ ] Zero behavioral changes — pure mechanical refactor verified by identical test results
151+
152+
---
153+
154+
## Sub-issue 3: Implement `Executor` trait with dyn dispatch (Phase 3)
155+
156+
**Title:** Implement `Executor` trait, inject `Box<dyn Executor>` into `ControllerContext`
157+
158+
**Blocked by:** Sub-issue 2 (Phase 2 — controlplane must already use `kube-sans-io` types so the `Executor` trait can reference them)
159+
160+
**Description:**
161+
162+
Define the `kube_sans_io::Executor` trait (using `#[async_trait]` for dyn
163+
safety) and the generic `execute()` orchestration function. Implement the
164+
trait for the existing `ReconcileExecutor`. Refactor `ControllerContext` to
165+
hold `Box<dyn Executor<...>>` for dependency injection. Replace each
166+
controller's manual execute call with `kube_sans_io::execute(result,
167+
ctx.executor.as_ref()).await`.
168+
169+
This is the highest-risk phase because it changes `ControllerContext` from
170+
owning a concrete `ReconcileExecutor` to holding a `Box<dyn Executor<...>>`,
171+
touching every controller file. However the behavioral contract is identical.
172+
173+
Reference: `docs/design/sans-io-crate-extraction.md` §4, Phase 3.
174+
175+
Estimated effort: ~200 new lines, ~80 changed lines.
176+
177+
**Acceptance Criteria:**
178+
179+
- [ ] `Executor` trait defined in `kube-sans-io` with `#[async_trait]`:
180+
- Associated types: `Upsert`, `StatusUpdate`, `Error`
181+
- Methods: `execute_upsert()`, `execute_status_update()`, `execute_delete()`
182+
- [ ] Generic `kube_sans_io::execute()` function with correct orchestration order: events → status updates → upserts → deletes
183+
- [ ] `requeue_to_action()` helper converts `RequeueDecision` to `kube::runtime::controller::Action`
184+
- [ ] `ReconcileExecutor` implements `Executor` trait (match on `ResourceUpsert` / `StatusUpdate` variants)
185+
- [ ] `DynExecutor` type alias: `dyn Executor<Upsert=ResourceUpsert, StatusUpdate=StatusUpdate, Error=ControllerError>`
186+
- [ ] `ControllerContext` holds `executor: Box<DynExecutor>` field
187+
- [ ] `reconcile_gateway()`, `reconcile_gateway_class()`, `reconcile_httproute()` all call `kube_sans_io::execute(result, ctx.executor.as_ref()).await`
188+
- [ ] Status-before-upserts ordering verified with a test using `RecordingExecutor` (from Phase 4, or a temporary inline fake)
189+
- [ ] All existing tests pass: `cargo nextest run`
190+
- [ ] `cargo make fmt` passes
191+
- [ ] `cargo make clippy-flow` passes with zero warnings
192+
193+
---
194+
195+
## Sub-issue 4: Built-in test fakes, documentation, and crate tests (Phase 4)
196+
197+
**Title:** Add `RecordingExecutor`, `FailingExecutor`, doc examples, and comprehensive tests
198+
199+
**Blocked by:** Sub-issue 3 (Phase 3 — `Executor` trait must exist before test fakes can implement it)
200+
201+
**Description:**
202+
203+
Add `RecordingExecutor` and `FailingExecutor` to `kube_sans_io::testing`.
204+
Add comprehensive unit tests for the new crate. Add doc examples showing
205+
usage by a hypothetical non-Gateway controller. Optionally migrate at least
206+
one existing controlplane test to use `RecordingExecutor` as proof of
207+
concept.
208+
209+
Reference: `docs/design/sans-io-crate-extraction.md` §5, Phase 4.
210+
211+
Estimated effort: ~200 new lines.
212+
213+
**Acceptance Criteria:**
214+
215+
- [ ] `RecordingExecutor<U, S>` in `kube_sans_io::testing`:
216+
- Records all upserts, status updates, and deletes via `Mutex<Vec<_>>`
217+
- Accessor methods: `upserts()`, `status_updates()`, `deletes()`
218+
- Implements `Executor` with `#[async_trait]`; error type is `std::convert::Infallible`
219+
- [ ] `FailingExecutor<U, S>` in `kube_sans_io::testing`:
220+
- Wraps `RecordingExecutor` and fails after N operations
221+
- Configurable via `FailingExecutor::new(fail_after: usize)`
222+
- Implements `Executor` with `#[async_trait]`
223+
- [ ] Unit tests for `RecordingExecutor`: captures upserts, status updates, deletes correctly
224+
- [ ] Unit tests for `FailingExecutor`: succeeds for first N operations, fails on N+1
225+
- [ ] Unit tests for `execute()` orchestration:
226+
- Status updates execute before upserts (use `RecordingExecutor` to verify order)
227+
- Delete errors are logged but don't abort remaining deletes
228+
- `RequeueDecision` correctly maps to `Action`
229+
- [ ] Doc examples on `Executor` trait, `ReconcileResult<U, S>`, `Snapshot` trait
230+
- [ ] At least one integration-style test demonstrating fetch→compute→execute with `RecordingExecutor`
231+
- [ ] At least one existing controlplane test migrated to use `RecordingExecutor` as proof of concept (optional but recommended)
232+
- [ ] `cargo make dev-test-flow` passes (full suite: format, lint, build, test)

0 commit comments

Comments
 (0)