|
| 1 | +--- |
| 2 | +Status: Active |
| 3 | +Owner: HyperFleet Team |
| 4 | +Last Updated: 2026-06-03 |
| 5 | +--- |
| 6 | + |
| 7 | +# Adapter Resource Recreation Flow Design |
| 8 | + |
| 9 | +**Jira**: [HYPERFLEET-837](https://issues.redhat.com/browse/HYPERFLEET-837) |
| 10 | + |
| 11 | +## What & Why |
| 12 | + |
| 13 | +**What**: Design the recreation workflow for resources that cannot be updated in-place (e.g., Kubernetes Jobs with immutable fields). When certain conditions change, the adapter deletes the old resource and creates a new one. |
| 14 | + |
| 15 | +**Why**: Some resources have immutable fields that cannot be patched. Without a clear recreation mechanism: |
| 16 | +- Config authors have no way to express "recreate this resource when X changes" |
| 17 | +- Recreation must be safe across Sentinel's ~10s retry loop (may receive same event multiple times) |
| 18 | +- Deletion may span multiple reconciliation loops due to finalizers or async behavior |
| 19 | +- Creation must never be attempted while deletion is still pending |
| 20 | + |
| 21 | +**Related Documentation:** |
| 22 | +- [Adapter Framework Design](./adapter-frame-design.md) — Core executor architecture |
| 23 | +- [Adapter Status Contract](./adapter-status-contract.md) — Status reporting patterns |
| 24 | + |
| 25 | +### Scope |
| 26 | + |
| 27 | +- CEL expression evaluation for recreation triggers (`lifecycle.recreate.when`) |
| 28 | +- Stateless, transport-agnostic delete+apply flow |
| 29 | +- Handling async deletion (finalizers, eventual consistency) |
| 30 | +- Both Kubernetes and Maestro transports |
| 31 | +- Safe retry across Sentinel's polling loop |
| 32 | + |
| 33 | +### Out of Scope |
| 34 | + |
| 35 | +- Intermediate status conditions during recreation (future enhancement) |
| 36 | +- Stuck deletion timeout behavior and alerts |
| 37 | +- lifecycle.delete logic (covered separately) |
| 38 | + |
| 39 | +--- |
| 40 | + |
| 41 | +## Design |
| 42 | + |
| 43 | +### Flow & Lifecycle |
| 44 | + |
| 45 | +When a resource is configured with `lifecycle.recreate.when`, the executor: |
| 46 | + |
| 47 | +1. **Pre-discovers** the current resource state (if any) so CEL expressions can compare incoming parameters with the current resource to detect changes that require recreation. |
| 48 | + |
| 49 | +2. **Evaluates** `lifecycle.recreate.when` (a CEL expression). If true, enters the recreation flow; otherwise, uses the normal apply path (update in-place or create). |
| 50 | + |
| 51 | +3. **In the recreation flow**: deletes the old resource, waits for it to be fully gone, then creates a new one. This flow is safe to retry — multiple attempts converge to the same end state. |
| 52 | + |
| 53 | +### Configuration Structure |
| 54 | + |
| 55 | +Configure resource recreation by adding a `lifecycle.recreate` block with a `when` expression that detects when the resource should be deleted and recreated: |
| 56 | + |
| 57 | +```yaml |
| 58 | +resources: |
| 59 | + - name: "job" |
| 60 | + transport: |
| 61 | + client: "kubernetes" |
| 62 | + manifest: |
| 63 | + apiVersion: batch/v1 |
| 64 | + kind: Job |
| 65 | + metadata: |
| 66 | + name: "{{ .clusterId }}-job" |
| 67 | + annotations: |
| 68 | + hyperfleet.io/generation: "{{ .generation }}" |
| 69 | + # ... job spec with immutable fields |
| 70 | + |
| 71 | + # Required: discovery allows the CEL expression to compare current vs. desired state |
| 72 | + discovery: |
| 73 | + by_name: "{{ .clusterId }}-job" |
| 74 | + |
| 75 | + # Recreation configuration: when the expression is true, delete old resource and create new |
| 76 | + lifecycle: |
| 77 | + recreate: |
| 78 | + when: |
| 79 | + expression: | |
| 80 | + # Recreate when the generation has changed (indicating spec changed) |
| 81 | + resources.?job.hasValue() |
| 82 | + && string(generation) != resources.?job.metadata.annotations["hyperfleet.io/generation"] |
| 83 | +``` |
| 84 | +
|
| 85 | +**Requirements when `lifecycle.recreate` is configured:** |
| 86 | +- `discovery` block must exist — pre-discovery populates resource state so CEL expressions can compare current vs. desired state |
| 87 | +- `lifecycle.recreate.when.expression` must be set to a non-empty CEL string |
| 88 | +- The expression is validated at adapter startup |
| 89 | +- Recreation works identically for K8s and Maestro transports (no transport-specific branching) |
| 90 | + |
| 91 | +### Recreation Flow |
| 92 | + |
| 93 | +When `lifecycle.recreate.when` evaluates to true: |
| 94 | + |
| 95 | +``` |
| 96 | +1. Check if resource exists (pre-discovery result in context) |
| 97 | + if not found: skip delete, go straight to apply |
| 98 | + |
| 99 | +2. Delete existing resource via TransportClient.DeleteResource() |
| 100 | + (fire-and-forget, returns immediately) |
| 101 | + |
| 102 | +3. Post-delete discovery: check if resource is truly gone |
| 103 | + if 404/NotFound: resource is gone → proceed to apply |
| 104 | + if still present (has deletionTimestamp or other deletion state): |
| 105 | + → store discovered object in context |
| 106 | + → return early with Operation=Recreate, Reason="deletion pending" |
| 107 | + → do NOT call ApplyResource |
| 108 | + if discovery error (non-404): assume still present, return early |
| 109 | + |
| 110 | +4. ApplyResource() with rendered bytes (no RecreateOnChange flag needed) |
| 111 | + |
| 112 | +5. Post-apply discovery + nested discoveries (same as normal apply flow) |
| 113 | +``` |
| 114 | + |
| 115 | +### Happy Path Sequence |
| 116 | + |
| 117 | +```mermaid |
| 118 | +sequenceDiagram |
| 119 | + participant Event as Event<br/>generation=2 |
| 120 | + participant Executor as ResourceExecutor |
| 121 | + participant Transport as K8s/Maestro |
| 122 | + participant Context as ExecutionContext |
| 123 | +
|
| 124 | + Event->>Executor: Process event |
| 125 | + Executor->>Transport: GetResource(Job) |
| 126 | + Transport-->>Context: resources["job"] = {generation=1} |
| 127 | +
|
| 128 | + Note over Executor: Evaluate lifecycle.recreate.when → true |
| 129 | +
|
| 130 | + Executor->>Transport: DeleteResource(Job) |
| 131 | + Transport-->>Executor: 200 OK |
| 132 | +
|
| 133 | + Executor->>Transport: GetResource(Job) |
| 134 | + Transport-->>Executor: 404 Not Found |
| 135 | +
|
| 136 | + Executor->>Transport: ApplyResource(generation=2) |
| 137 | + Transport-->>Executor: 201 Created |
| 138 | +
|
| 139 | + Executor->>Transport: GetResource(Job) |
| 140 | + Transport-->>Context: resources["job"] = {generation=2} |
| 141 | +
|
| 142 | + Executor-->>Event: Success: Recreate |
| 143 | +``` |
| 144 | + |
| 145 | +### Pending Deletion Sequence |
| 146 | + |
| 147 | +This is the critical case: deletion takes multiple reconciliation loops due to finalizers or async behavior. Sentinel sends the same event repeatedly (~10s intervals). Recreation must be safe to retry. |
| 148 | + |
| 149 | +```mermaid |
| 150 | +sequenceDiagram |
| 151 | + participant A1 as Attempt 1<br/>~0s |
| 152 | + participant A2 as Attempt 2<br/>~10s |
| 153 | + participant A3 as Attempt 3<br/>~20s |
| 154 | + participant Executor as ResourceExecutor |
| 155 | + participant Transport as K8s/Maestro |
| 156 | +
|
| 157 | + A1->>Executor: Event (generation=2) |
| 158 | + Executor->>Transport: GetResource → {generation=1, Active} |
| 159 | + Executor->>Transport: DeleteResource |
| 160 | + Transport-->>Executor: 200 OK |
| 161 | +
|
| 162 | + Executor->>Transport: GetResource → {deletionTimestamp, finalizers pending} |
| 163 | + Note over Executor: Still present → Return early<br/>Do NOT apply yet |
| 164 | +
|
| 165 | + A2->>Executor: Event (generation=2, same) |
| 166 | + Executor->>Transport: GetResource → {deletionTimestamp, finalizers pending} |
| 167 | + Executor->>Transport: DeleteResource (re-issue, idempotent) |
| 168 | + Transport-->>Executor: 200 OK |
| 169 | +
|
| 170 | + Executor->>Transport: GetResource → {still present} |
| 171 | + Note over Executor: Still present → Return early |
| 172 | +
|
| 173 | + A3->>Executor: Finalizers complete |
| 174 | + Executor->>Transport: GetResource → 404 Not Found |
| 175 | + Executor->>Transport: ApplyResource(generation=2) |
| 176 | + Transport-->>Executor: 201 Created |
| 177 | + Note over Executor: Success: Recreate |
| 178 | +``` |
| 179 | + |
| 180 | +**Key property**: Delete is idempotent. Re-issuing DELETE when the resource already has `deletionTimestamp` is safe. Sentinel's retry loop naturally handles pending deletions without special logic. |
| 181 | + |
| 182 | +### CEL Evaluation Context |
| 183 | + |
| 184 | +`lifecycle.recreate.when` expressions evaluate with the same context as other CEL expressions: |
| 185 | + |
| 186 | +- **Extracted parameters**: `generation`, `clusterId`, `namespace`, etc. (from event/preconditions) |
| 187 | +- **Discovered resource state**: `resources.?job.metadata.annotations`, `resources.?job.spec`, etc. |
| 188 | +- **Adapter metadata**: `adapter.executionStatus`, `adapter.name`, etc. |
| 189 | +- **Optional chaining**: `resources.?job.hasValue()` → prevents crashes on missing resources |
| 190 | + |
| 191 | +Example expressions: |
| 192 | +``` |
| 193 | +# Recreate when generation annotation mismatches |
| 194 | +resources.?job.hasValue() && string(generation) != resources.?job.metadata.annotations["hyperfleet.io/generation"] |
| 195 | +
|
| 196 | +# Recreate when API spec field changes |
| 197 | +resources.?cluster.hasValue() && resources.?cluster.spec.region != currentRegion |
| 198 | +``` |
| 199 | + |
| 200 | +### Edge Cases & Handling |
| 201 | + |
| 202 | +**Resource doesn't exist at pre-discovery**: If discovery returns 404, skip the delete step and go directly to apply (create new resource). |
| 203 | + |
| 204 | +**Post-delete discovery fails (non-API error)**: Assume resource is still present due to transient error. Return early with "deletion pending" reason. Retry on next Sentinel event. |
| 205 | + |
| 206 | +**CEL accesses non-existent resource**: Use optional chaining (`resources.?job.hasValue() && ...`) to avoid crashes. CEL short-circuits evaluation, so type errors are prevented. |
| 207 | + |
| 208 | +**Deletion in progress when recreation is triggered**: The framework detects if deletion is still pending (finalizers, async behavior) and defers creation. Retries naturally occur on the next Sentinel event (~10s), when the deletion may be complete. |
| 209 | + |
| 210 | +--- |
| 211 | + |
| 212 | +## Responsibilities & Trade-offs |
| 213 | + |
| 214 | +**What the framework provides:** Declarative recreation via CEL expressions. Works identically for K8s and Maestro. Safe to retry (delete is idempotent). |
| 215 | + |
| 216 | +**What config authors own:** |
| 217 | +1. Test recreation flow in staging environment before production |
| 218 | +2. Validate that stuck deletions are prevented through proper RBAC, finalizers, and cleanup logic in their domain |
| 219 | + |
| 220 | +**Trade-off:** Deletion is fire-and-forget; actual deletion may span multiple reconciliation loops. The framework cannot recover from stuck deletions — prevention through proper design in the staging environment is the only viable approach. |
| 221 | + |
| 222 | +**Why `lifecycle.recreate.when` replaces `RecreateOnChange`:** The previous transport-layer flag only worked for K8s (ignored by Maestro) and was not safe to retry across Sentinel's polling loop. The new CEL-based approach is transport-agnostic and stateless. |
0 commit comments