Skip to main content

greentic_deploy_spec/engine/
revisions.rs

1//! Pure revision-lifecycle verb semantics (Phase D PR-4.2b).
2//!
3//! The revision verb group (`stage` / `warm` / `drain` / `archive`) follows
4//! the PR-4.2a engine contract: pure `&mut Environment` transforms with no
5//! I/O, no clock (`now` is a parameter), and no key material. Both
6//! `LocalFsStore` (greentic-deployer, behind a flock) and the
7//! operator-store-server (behind SQLite CAS) drive the SAME functions, so
8//! lifecycle semantics cannot drift between local and remote.
9//!
10//! # Persist rule (read before calling)
11//!
12//! These transforms mutate the borrowed [`Environment`] in place. Callers
13//! own persistence and MUST apply this rule:
14//!
15//! - `Ok(_)` — persist the environment.
16//! - `Err(e)` with [`RevisionLifecycleError::env_mutated`] `== true`
17//!   (today: only `HealthGateFailed`) — the revision was flipped to
18//!   `Failed`; persist the environment, THEN surface the error
19//!   (committed-on-error, mirrors the local store's contract).
20//! - any other `Err(_)` — the environment may be partially walked;
21//!   DISCARD it (reload before reuse), never persist.
22//!
23//! # Wire shapes
24//!
25//! [`StageRevisionPayload`] and [`WarmRevisionPayload`] double as the A8
26//! request bodies (`POST /environments/{env}/revisions` and
27//! `POST /environments/{env}/revisions/{rid}/warm`);
28//! [`RevisionTransitionOutcome`] is the response body for
29//! warm/drain/archive. The wire-format tests at the bottom pin the
30//! encoding. The A8 `Idempotency-Key` rides the HTTP header, never these
31//! bodies.
32
33use chrono::{DateTime, Utc};
34use serde::{Deserialize, Serialize};
35use std::path::PathBuf;
36use thiserror::Error;
37
38use crate::environment::Environment;
39use crate::ids::{BundleId, DeploymentId, RevisionId};
40use crate::revision::{PackListEntry, Revision, RevisionLifecycle, is_valid_transition};
41use crate::version::SchemaVersion;
42use greentic_types::EnvId;
43
44// ---------------------------------------------------------------------------
45// Health-gate types (moved from greentic-deployer `environment::lifecycle`
46// in PR-4.2b; semantics unchanged, serde added for the A8 wire)
47// ---------------------------------------------------------------------------
48
49/// Identifies which health-gate check failed in [`HealthGateFailure`].
50///
51/// The four checks correspond to the warm/ready gate's responsibilities
52/// per `plans/next-gen-deployment.md` B9: route table, runtime config,
53/// signature status, provider health.
54#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
55#[serde(rename_all = "kebab-case")]
56pub enum HealthCheckId {
57    /// Static route table validates against the revision's pack list.
58    RouteTable,
59    /// Materialized `runtime-config.json` loads and validates.
60    RuntimeConfig,
61    /// Revision's `signature_sidecar_ref` exists and verifies.
62    SignatureStatus,
63    /// Providers in the revision's pack list are reachable / healthy.
64    ProviderHealth,
65}
66
67/// Why a warm/ready health gate rejected a revision. Shipped by the deployer
68/// CLI inside [`WarmRevisionPayload`] (the gate is evaluated client-side —
69/// closures don't cross the A8 wire) and surfaced inside
70/// [`RevisionLifecycleError::HealthGateFailed`] after the transform has
71/// flipped the revision's lifecycle to `Failed`.
72///
73/// `failed_checks` MAY be empty (e.g. the gate aborted before any check
74/// completed) — `message` always carries human-readable detail.
75#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
76pub struct HealthGateFailure {
77    pub failed_checks: Vec<HealthCheckId>,
78    pub message: String,
79}
80
81/// Identifies a `TrafficSplit` (by its `(deployment_id, bundle_id)` key)
82/// for error-reporting purposes. Surfaced in
83/// [`RevisionLifecycleError::ActiveTrafficReference`] so operators can
84/// locate the splits they need to rebalance before retrying the archive.
85#[derive(Debug, Clone, PartialEq, Eq)]
86pub struct ActiveSplitRef {
87    pub deployment_id: DeploymentId,
88    pub bundle_id: BundleId,
89    /// Weight (basis points) of the *archived* revision's entry in this
90    /// split. Lets callers distinguish "100% live route" from "partial
91    /// canary" without re-loading the env.
92    pub weight_bps: u32,
93}
94
95// ---------------------------------------------------------------------------
96// Error surface
97// ---------------------------------------------------------------------------
98
99/// Failures produced by the pure revision-lifecycle transforms. The pure
100/// twin of greentic-deployer's `environment::lifecycle::LifecycleError`
101/// (which adds a storage variant on top); each backend maps these onto its
102/// own error vocabulary — `LocalFsStore` → `LifecycleError`/`StoreError`,
103/// the operator-store-server → [`crate::remote::RemoteStoreError`].
104#[derive(Debug, Clone, PartialEq, Eq, Error)]
105pub enum RevisionLifecycleError {
106    /// The targeted revision was not present in the environment.
107    #[error("revision `{revision_id}` not found in env `{env_id}`")]
108    NotFound {
109        env_id: EnvId,
110        revision_id: RevisionId,
111    },
112    /// The spec matrix rejected an edge inside the requested chain. This is
113    /// a programming error in the caller, not a runtime conflict — every
114    /// chain passed in by the verb functions below is hand-curated.
115    #[error("spec rejects transition `{from:?} → {to:?}`")]
116    InvalidTransition {
117        from: RevisionLifecycle,
118        to: RevisionLifecycle,
119    },
120    /// The revision was loaded in a state that does not start any edge in
121    /// the accepted chain (or, for `warm`, no longer carries the lifecycle
122    /// the caller observed at gate-evaluation time). `actual` carries the
123    /// lifecycle the transform found; `expected_starts` lists the states it
124    /// would have accepted.
125    #[error(
126        "revision `{revision_id}` is in `{actual:?}`; expected one of {expected_starts:?} to apply the requested transition"
127    )]
128    Conflict {
129        revision_id: RevisionId,
130        actual: RevisionLifecycle,
131        expected_starts: Vec<RevisionLifecycle>,
132    },
133    /// The caller passed an empty `accepted_chain`. Internal-API misuse.
134    #[error("transition chain is empty; cannot apply any state change")]
135    EmptyChain,
136    /// The archive path (`prune_from_splits = true`) was invoked against a
137    /// revision still referenced by one or more live traffic splits.
138    /// Callers must rebalance traffic via `gtc op traffic set` before
139    /// retrying.
140    #[error(
141        "revision `{revision_id}` is still referenced by {} live traffic split(s); rebalance via `gtc op traffic set` before archiving", splits.len()
142    )]
143    ActiveTrafficReference {
144        revision_id: RevisionId,
145        splits: Vec<ActiveSplitRef>,
146    },
147    /// The warm/ready health gate rejected the revision after the chain
148    /// reached its final state. The transform has flipped the revision's
149    /// lifecycle to `Failed` IN the borrowed environment — per the module's
150    /// persist rule the caller MUST persist before surfacing this error.
151    #[error(
152        "revision `{revision_id}` failed health gate ({} check(s) failed): {message}",
153        failed_checks.len()
154    )]
155    HealthGateFailed {
156        revision_id: RevisionId,
157        failed_checks: Vec<HealthCheckId>,
158        message: String,
159    },
160    /// A revision with this id already exists in the environment. The
161    /// stage verb's `revision_id` is caller-supplied (the bundle staging
162    /// step names its rev_dir after the ULID before the verb runs), so a
163    /// retry of a lost stage response replays the same id — appending a
164    /// second copy would corrupt every `revision_id` lookup. Backends map
165    /// this to their create-on-existing conflict (HTTP 409).
166    #[error("revision `{revision_id}` already exists in env `{env_id}`")]
167    DuplicateRevision {
168        env_id: EnvId,
169        revision_id: RevisionId,
170    },
171    /// The deployment the verb references does not exist in the environment.
172    #[error("deployment `{deployment_id}` not found in env `{env_id}`")]
173    DeploymentNotFound {
174        env_id: EnvId,
175        deployment_id: DeploymentId,
176    },
177}
178
179impl RevisionLifecycleError {
180    /// Whether the borrowed environment was mutated before this error was
181    /// returned (and therefore MUST be persisted by the caller). True only
182    /// for [`Self::HealthGateFailed`] — every other error leaves the
183    /// environment in a discard-only state.
184    pub fn env_mutated(&self) -> bool {
185        matches!(self, Self::HealthGateFailed { .. })
186    }
187}
188
189// ---------------------------------------------------------------------------
190// Verb payloads (wire DTOs)
191// ---------------------------------------------------------------------------
192
193/// Inputs to `EnvironmentMutations::stage_revision`, and the A8
194/// `POST /environments/{env_id}/revisions` request body.
195///
196/// `revision_id` is supplied by the caller because the bundle staging step
197/// (extract + lock-pin + pack-config materialization) runs OUTSIDE the env
198/// lock and names its on-disk `rev_dir` after the ULID.
199#[derive(Debug, Clone, Serialize, Deserialize)]
200pub struct StageRevisionPayload {
201    pub revision_id: RevisionId,
202    pub deployment_id: DeploymentId,
203    pub bundle_digest: String,
204    /// Registry reference the bundle was resolved from, recorded on the
205    /// revision so a remote worker can pull it at boot. `None` for a
206    /// locally-staged bundle. See [`Revision::bundle_source_uri`].
207    #[serde(default, skip_serializing_if = "Option::is_none")]
208    pub bundle_source_uri: Option<String>,
209    pub pack_list: Vec<PackListEntry>,
210    pub pack_list_lock_ref: PathBuf,
211    pub pack_config_refs: Vec<PathBuf>,
212    pub config_digest: String,
213    pub signature_sidecar_ref: PathBuf,
214    pub drain_seconds: u32,
215}
216
217/// Inputs to `EnvironmentMutations::warm_revision`, and the A8
218/// `POST /environments/{env_id}/revisions/{rid}/warm` request body
219/// (`revision_id` rides in the body too — the server cross-checks it
220/// against the URL).
221///
222/// The closure-based health gate can't cross the HTTP wire, so the deployer
223/// CLI evaluates runner health locally and ships the typed outcome:
224/// `Ok(())` advances the revision to `Ready`; `Err(failure)` flips it to
225/// `Failed` atomically.
226#[derive(Debug, Clone, Serialize, Deserialize)]
227pub struct WarmRevisionPayload {
228    pub revision_id: RevisionId,
229    /// The client-evaluated health-gate outcome, encoded on the wire as
230    /// `{"ok": true}` / `{"ok": false, "failure": {…}}`.
231    #[serde(with = "health_gate_wire")]
232    pub health_gate: Result<(), HealthGateFailure>,
233    /// The revision lifecycle the caller observed at gate-evaluation time.
234    /// The transform re-checks that the revision still carries this
235    /// lifecycle before applying the pre-evaluated gate result; on mismatch
236    /// it rejects with [`RevisionLifecycleError::Conflict`] so a stale gate
237    /// outcome is never applied to env state it didn't observe.
238    ///
239    /// The idempotent-retry path (revision already `Ready`) skips the check
240    /// — the gate fires only when the chain actually advances.
241    pub expected_lifecycle: RevisionLifecycle,
242}
243
244/// Wire encoding for [`WarmRevisionPayload::health_gate`]: serde's built-in
245/// `Result` representation (externally tagged `Ok`/`Err`) is unidiomatic
246/// JSON, so the field encodes as an explicit object instead.
247mod health_gate_wire {
248    use super::HealthGateFailure;
249    use serde::{Deserialize, Deserializer, Serialize, Serializer};
250
251    #[derive(Serialize, Deserialize)]
252    #[serde(deny_unknown_fields)]
253    struct Repr {
254        ok: bool,
255        #[serde(default, skip_serializing_if = "Option::is_none")]
256        failure: Option<HealthGateFailure>,
257    }
258
259    pub fn serialize<S: Serializer>(
260        value: &Result<(), HealthGateFailure>,
261        serializer: S,
262    ) -> Result<S::Ok, S::Error> {
263        let repr = match value {
264            Ok(()) => Repr {
265                ok: true,
266                failure: None,
267            },
268            Err(failure) => Repr {
269                ok: false,
270                failure: Some(failure.clone()),
271            },
272        };
273        repr.serialize(serializer)
274    }
275
276    pub fn deserialize<'de, D: Deserializer<'de>>(
277        deserializer: D,
278    ) -> Result<Result<(), HealthGateFailure>, D::Error> {
279        let repr = Repr::deserialize(deserializer)?;
280        match (repr.ok, repr.failure) {
281            (true, None) => Ok(Ok(())),
282            (false, Some(failure)) => Ok(Err(failure)),
283            (true, Some(_)) => Err(serde::de::Error::custom(
284                "health gate cannot be `ok: true` and carry a `failure`",
285            )),
286            (false, None) => Err(serde::de::Error::custom(
287                "health gate `ok: false` must carry a `failure`",
288            )),
289        }
290    }
291}
292
293/// Outcome of the warm/drain/archive verbs, and the A8 response body for
294/// their routes. Carries the post-transition revision, the environment
295/// after the mutation, and the starting lifecycle (the archive
296/// eviction-vs-retirement discriminator in the deployer CLI's telemetry
297/// emit).
298#[derive(Debug, Clone, Serialize, Deserialize)]
299pub struct RevisionTransitionOutcome {
300    pub revision: Revision,
301    pub environment: Environment,
302    pub starting_lifecycle: RevisionLifecycle,
303}
304
305/// Intermediate result of the pure warm/drain/archive transforms: the
306/// caller pairs it with the (now persisted) environment to build a
307/// [`RevisionTransitionOutcome`].
308#[derive(Debug, Clone)]
309pub struct RevisionTransition {
310    pub revision: Revision,
311    pub starting_lifecycle: RevisionLifecycle,
312}
313
314// ---------------------------------------------------------------------------
315// Pure transforms
316// ---------------------------------------------------------------------------
317
318/// Walk `revision_id` through an `accepted_chain` of `(from, to)` edges,
319/// advancing the revision through every legal hop until it lands in the
320/// final edge's `to` state (the pure core of the deployer's A5 storage
321/// guard — see that module's docs for the full narrative).
322///
323/// **Idempotent on the final state:** a revision already at the chain's
324/// final state succeeds without `Conflict`; `on_final` still runs (e.g.
325/// re-stamps `warmed_at`) but `health_gate` does NOT (the revision is
326/// already committed at its target state, possibly with live traffic — a
327/// transient gate failure must not demote it).
328///
329/// `expected_start` is the warm verb's PR-3a.6b lifecycle precondition:
330/// when `Some`, the revision's current lifecycle must equal it before any
331/// edge applies, or the walk rejects with
332/// [`RevisionLifecycleError::Conflict`]. The check is skipped when the
333/// revision already sits at the chain's final state — the gate never fires
334/// on that idempotent-retry path, so a stale snapshot is harmless there.
335///
336/// `prune_from_splits = true` is the archive-path knob: refuses with
337/// [`RevisionLifecycleError::ActiveTrafficReference`] while any traffic
338/// split still references the revision, otherwise removes the revision
339/// from each matching `BundleDeployment::current_revisions` tracking list.
340///
341/// On gate failure the revision's lifecycle is flipped to `Failed` in
342/// place and [`RevisionLifecycleError::HealthGateFailed`] is returned —
343/// see the module's persist rule.
344///
345/// Returns the post-transition revision together with the lifecycle it
346/// started from (the archive eviction-vs-retirement discriminator).
347pub fn walk_revision_chain<F, G>(
348    env: &mut Environment,
349    revision_id: RevisionId,
350    accepted_chain: &[(RevisionLifecycle, RevisionLifecycle)],
351    expected_start: Option<RevisionLifecycle>,
352    on_final: F,
353    prune_from_splits: bool,
354    health_gate: G,
355) -> Result<RevisionTransition, RevisionLifecycleError>
356where
357    F: FnOnce(&mut Revision),
358    G: FnOnce(&Environment, &Revision) -> Result<(), HealthGateFailure>,
359{
360    if accepted_chain.is_empty() {
361        return Err(RevisionLifecycleError::EmptyChain);
362    }
363    let final_state = accepted_chain
364        .last()
365        .map(|(_, to)| *to)
366        .expect("chain non-empty: checked above");
367
368    let idx = env
369        .revisions
370        .iter()
371        .position(|r| r.revision_id == revision_id)
372        .ok_or_else(|| RevisionLifecycleError::NotFound {
373            env_id: env.environment_id.clone(),
374            revision_id,
375        })?;
376    let starting_lifecycle = env.revisions[idx].lifecycle;
377
378    if let Some(expected) = expected_start
379        && starting_lifecycle != final_state
380        && starting_lifecycle != expected
381    {
382        return Err(RevisionLifecycleError::Conflict {
383            revision_id,
384            actual: starting_lifecycle,
385            expected_starts: vec![expected],
386        });
387    }
388
389    let mut chain_advanced = false;
390    for (from, to) in accepted_chain {
391        if env.revisions[idx].lifecycle == *from {
392            if !is_valid_transition(*from, *to) {
393                return Err(RevisionLifecycleError::InvalidTransition {
394                    from: *from,
395                    to: *to,
396                });
397            }
398            env.revisions[idx].lifecycle = *to;
399            chain_advanced = true;
400        }
401    }
402
403    if env.revisions[idx].lifecycle != final_state {
404        let expected_starts = accepted_chain.iter().map(|(from, _)| *from).collect();
405        return Err(RevisionLifecycleError::Conflict {
406            revision_id,
407            actual: env.revisions[idx].lifecycle,
408            expected_starts,
409        });
410    }
411
412    if prune_from_splits {
413        // Refuse to archive a revision that still routes live traffic.
414        // Blindly pruning would either silently drop the route (100%
415        // single-entry split) or produce weights that no longer sum to
416        // 10,000 bps (the spec invariant).
417        let active_refs: Vec<ActiveSplitRef> = env
418            .traffic_splits
419            .iter()
420            .flat_map(|split| {
421                split
422                    .entries
423                    .iter()
424                    .filter(|entry| entry.revision_id == revision_id)
425                    .map(|entry| ActiveSplitRef {
426                        deployment_id: split.deployment_id,
427                        bundle_id: split.bundle_id.clone(),
428                        weight_bps: entry.weight_bps,
429                    })
430            })
431            .collect();
432        if !active_refs.is_empty() {
433            return Err(RevisionLifecycleError::ActiveTrafficReference {
434                revision_id,
435                splits: active_refs,
436            });
437        }
438    }
439
440    // Health gate fires ONLY when the chain actually advanced — an
441    // idempotent retry against an already-final revision skips it (see the
442    // doc comment above). On the chain-advanced path the gate sees the
443    // post-chain `(env, revision)` view; rejection flips lifecycle to
444    // `Failed` (caller persists per the module rule).
445    if chain_advanced && let Err(failure) = health_gate(env, &env.revisions[idx]) {
446        let prior = env.revisions[idx].lifecycle;
447        if !is_valid_transition(prior, RevisionLifecycle::Failed) {
448            // Caller passed a chain whose final state can't transition to
449            // Failed (e.g. an archive-style chain). Bail — there's no
450            // spec-legal way to record "failed gate" from here.
451            return Err(RevisionLifecycleError::InvalidTransition {
452                from: prior,
453                to: RevisionLifecycle::Failed,
454            });
455        }
456        env.revisions[idx].lifecycle = RevisionLifecycle::Failed;
457        return Err(RevisionLifecycleError::HealthGateFailed {
458            revision_id,
459            failed_checks: failure.failed_checks,
460            message: failure.message,
461        });
462    }
463
464    on_final(&mut env.revisions[idx]);
465
466    if prune_from_splits {
467        // No live traffic references at this point (guard above). Remove
468        // the revision from each matching deployment's tracking list;
469        // traffic splits themselves are untouched.
470        let deployment_id = env.revisions[idx].deployment_id;
471        for bundle in env.bundles.iter_mut() {
472            if bundle.deployment_id == deployment_id {
473                bundle.current_revisions.retain(|rid| *rid != revision_id);
474            }
475        }
476    }
477
478    Ok(RevisionTransition {
479        revision: env.revisions[idx].clone(),
480        starting_lifecycle,
481    })
482}
483
484/// Stage a fresh revision under `payload.deployment_id`: resolve the
485/// deployment's `bundle_id`, assign the next per-deployment sequence
486/// number, and push a `Staged` revision stamped at `now`.
487pub fn stage_revision(
488    env: &mut Environment,
489    payload: StageRevisionPayload,
490    now: DateTime<Utc>,
491) -> Result<Revision, RevisionLifecycleError> {
492    if env
493        .revisions
494        .iter()
495        .any(|r| r.revision_id == payload.revision_id)
496    {
497        return Err(RevisionLifecycleError::DuplicateRevision {
498            env_id: env.environment_id.clone(),
499            revision_id: payload.revision_id,
500        });
501    }
502    let bundle_id = env
503        .bundles
504        .iter()
505        .find(|b| b.deployment_id == payload.deployment_id)
506        .map(|b| b.bundle_id.clone())
507        .ok_or_else(|| RevisionLifecycleError::DeploymentNotFound {
508            env_id: env.environment_id.clone(),
509            deployment_id: payload.deployment_id,
510        })?;
511    let next_sequence = env
512        .revisions
513        .iter()
514        .filter(|r| r.deployment_id == payload.deployment_id)
515        .map(|r| r.sequence)
516        .max()
517        .unwrap_or(0)
518        + 1;
519    let revision = Revision {
520        schema: SchemaVersion::new(SchemaVersion::REVISION_V1),
521        revision_id: payload.revision_id,
522        env_id: env.environment_id.clone(),
523        bundle_id,
524        deployment_id: payload.deployment_id,
525        sequence: next_sequence,
526        created_at: now,
527        bundle_digest: payload.bundle_digest,
528        bundle_source_uri: payload.bundle_source_uri,
529        pack_list: payload.pack_list,
530        pack_list_lock_ref: payload.pack_list_lock_ref,
531        pack_config_refs: payload.pack_config_refs,
532        config_digest: payload.config_digest,
533        signature_sidecar_ref: payload.signature_sidecar_ref,
534        lifecycle: RevisionLifecycle::Staged,
535        staged_at: Some(now),
536        warmed_at: None,
537        drain_seconds: payload.drain_seconds,
538        abort_metrics: Vec::new(),
539    };
540    env.revisions.push(revision.clone());
541    Ok(revision)
542}
543
544/// Drive a revision through `Staged → Warming → Ready` and apply the
545/// client-evaluated health-gate outcome from the payload.
546///
547/// **Lifecycle precondition (PR-3a.6b).** The gate was evaluated outside
548/// any lock; `payload.expected_lifecycle` records the lifecycle observed at
549/// gate-evaluation time and is re-checked here so a stale gate outcome is
550/// never applied. The check is skipped on the idempotent-retry path
551/// (revision already `Ready`) — the gate fires only when the chain
552/// actually advances, so the precondition is moot there.
553///
554/// `now` stamps `warmed_at` (also re-stamped on idempotent retry).
555pub fn warm_revision(
556    env: &mut Environment,
557    payload: WarmRevisionPayload,
558    now: DateTime<Utc>,
559) -> Result<RevisionTransition, RevisionLifecycleError> {
560    let WarmRevisionPayload {
561        revision_id,
562        health_gate,
563        expected_lifecycle,
564    } = payload;
565    walk_revision_chain(
566        env,
567        revision_id,
568        &[
569            (RevisionLifecycle::Staged, RevisionLifecycle::Warming),
570            (RevisionLifecycle::Warming, RevisionLifecycle::Ready),
571        ],
572        Some(expected_lifecycle),
573        |r| {
574            r.warmed_at = Some(now);
575        },
576        false,
577        // FnOnce closure consumes the pre-evaluated outcome — the chain
578        // walker only fires the gate when the chain actually advanced.
579        |_env, _rev| health_gate,
580    )
581}
582
583/// Transition a `Ready` revision to `Draining`. Pure lifecycle stamp — the
584/// in-flight drain dance (sessions, WebSocket cleanup) is owned by
585/// `greentic-start`.
586pub fn drain_revision(
587    env: &mut Environment,
588    revision_id: RevisionId,
589) -> Result<RevisionTransition, RevisionLifecycleError> {
590    walk_revision_chain(
591        env,
592        revision_id,
593        &[(RevisionLifecycle::Ready, RevisionLifecycle::Draining)],
594        None,
595        |_| {},
596        false,
597        |_env, _rev| Ok(()),
598    )
599}
600
601/// Archive a revision, walking any of `Staged | Warming | Ready | Failed`
602/// to `Archived` in one hop and the post-drain `Draining → Inactive →
603/// Archived` walk end-to-end. Refuses if the revision still routes live
604/// traffic — callers rebalance via `gtc op traffic set` first.
605pub fn archive_revision(
606    env: &mut Environment,
607    revision_id: RevisionId,
608) -> Result<RevisionTransition, RevisionLifecycleError> {
609    walk_revision_chain(
610        env,
611        revision_id,
612        &[
613            (RevisionLifecycle::Staged, RevisionLifecycle::Archived),
614            (RevisionLifecycle::Warming, RevisionLifecycle::Archived),
615            (RevisionLifecycle::Ready, RevisionLifecycle::Archived),
616            (RevisionLifecycle::Failed, RevisionLifecycle::Archived),
617            (RevisionLifecycle::Draining, RevisionLifecycle::Inactive),
618            (RevisionLifecycle::Inactive, RevisionLifecycle::Archived),
619        ],
620        None,
621        |_| {},
622        true,
623        |_env, _rev| Ok(()),
624    )
625}
626
627#[cfg(test)]
628mod tests {
629    use super::*;
630    use crate::bundle_deployment::{
631        BundleDeployment, BundleDeploymentStatus, RevenueShareEntry, RouteBinding, TenantSelector,
632    };
633    use crate::ids::{CustomerId, PackId, PartyId};
634    use crate::traffic_split::{TrafficSplit, TrafficSplitEntry};
635    use crate::version::SemVer;
636    use chrono::TimeZone;
637    use serde_json::json;
638    use std::collections::BTreeMap;
639
640    fn env_id() -> EnvId {
641        EnvId::try_from("local").unwrap()
642    }
643
644    fn fixed_now() -> DateTime<Utc> {
645        Utc.with_ymd_and_hms(2026, 6, 12, 12, 0, 0).unwrap()
646    }
647
648    fn deployment(deployment_id: DeploymentId) -> BundleDeployment {
649        BundleDeployment {
650            schema: SchemaVersion::new(SchemaVersion::BUNDLE_DEPLOYMENT_V1),
651            deployment_id,
652            env_id: env_id(),
653            bundle_id: BundleId::new("fast2flow"),
654            customer_id: CustomerId::new("local-dev"),
655            status: BundleDeploymentStatus::Active,
656            current_revisions: Vec::new(),
657            route_binding: RouteBinding {
658                hosts: vec!["fast2flow.local".to_string()],
659                path_prefixes: Vec::new(),
660                tenant_selector: TenantSelector {
661                    tenant: "default".to_string(),
662                    team: "default".to_string(),
663                },
664            },
665            revenue_share: vec![RevenueShareEntry {
666                party_id: PartyId::new("greentic"),
667                basis_points: 10_000,
668            }],
669            revenue_policy_ref: PathBuf::from("revenue.json"),
670            usage: None,
671            created_at: fixed_now(),
672            authorization_ref: PathBuf::from("auth.json"),
673            config_overrides: BTreeMap::new(),
674        }
675    }
676
677    fn env_with_deployment(deployment_id: DeploymentId) -> Environment {
678        let mut env = super::super::fresh_environment(
679            &env_id(),
680            "local".to_string(),
681            crate::environment::EnvironmentHostConfig {
682                env_id: env_id(),
683                region: None,
684                tenant_org_id: None,
685                listen_addr: None,
686                public_base_url: None,
687                gui_enabled: None,
688            },
689            Default::default(),
690            Default::default(),
691            Default::default(),
692        );
693        env.bundles.push(deployment(deployment_id));
694        env
695    }
696
697    fn stage_payload(deployment_id: DeploymentId) -> StageRevisionPayload {
698        StageRevisionPayload {
699            revision_id: RevisionId::new(),
700            deployment_id,
701            bundle_digest: "sha256:00".to_string(),
702            bundle_source_uri: None,
703            pack_list: vec![PackListEntry {
704                pack_id: PackId::new("greentic.test.pack"),
705                version: SemVer::new(1, 0, 0),
706                digest: "sha256:00".to_string(),
707                source_uri: None,
708            }],
709            pack_list_lock_ref: PathBuf::from("pack-list.lock"),
710            pack_config_refs: Vec::new(),
711            config_digest: "sha256:00".to_string(),
712            signature_sidecar_ref: PathBuf::from("rev.sig"),
713            drain_seconds: 30,
714        }
715    }
716
717    fn warm_payload(revision_id: RevisionId) -> WarmRevisionPayload {
718        WarmRevisionPayload {
719            revision_id,
720            health_gate: Ok(()),
721            expected_lifecycle: RevisionLifecycle::Staged,
722        }
723    }
724
725    // --- stage ---
726
727    #[test]
728    fn stage_assigns_per_deployment_sequence_and_staged_lifecycle() {
729        let deployment_id = DeploymentId::new();
730        let mut env = env_with_deployment(deployment_id);
731
732        let first = stage_revision(&mut env, stage_payload(deployment_id), fixed_now()).unwrap();
733        assert_eq!(first.sequence, 1);
734        assert_eq!(first.lifecycle, RevisionLifecycle::Staged);
735        assert_eq!(first.staged_at, Some(fixed_now()));
736        assert_eq!(first.bundle_id, BundleId::new("fast2flow"));
737
738        let second = stage_revision(&mut env, stage_payload(deployment_id), fixed_now()).unwrap();
739        assert_eq!(second.sequence, 2);
740        assert_eq!(env.revisions.len(), 2);
741    }
742
743    /// A distributor-sourced revision pins the registry ref a remote worker
744    /// pulls the bundle from; a locally-staged one leaves it `None`.
745    #[test]
746    fn stage_records_bundle_source_uri_when_supplied() {
747        let deployment_id = DeploymentId::new();
748        let mut env = env_with_deployment(deployment_id);
749
750        let local = stage_revision(&mut env, stage_payload(deployment_id), fixed_now()).unwrap();
751        assert_eq!(local.bundle_source_uri, None);
752
753        let pullable = StageRevisionPayload {
754            bundle_source_uri: Some("oci://ghcr.io/greenticai/bundles/demo@sha256:abc".to_string()),
755            ..stage_payload(deployment_id)
756        };
757        let staged = stage_revision(&mut env, pullable, fixed_now()).unwrap();
758        assert_eq!(
759            staged.bundle_source_uri.as_deref(),
760            Some("oci://ghcr.io/greenticai/bundles/demo@sha256:abc")
761        );
762    }
763
764    #[test]
765    fn stage_rejects_duplicate_revision_id() {
766        let deployment_id = DeploymentId::new();
767        let mut env = env_with_deployment(deployment_id);
768
769        let payload = stage_payload(deployment_id);
770        let dup = StageRevisionPayload {
771            revision_id: payload.revision_id,
772            ..stage_payload(deployment_id)
773        };
774        stage_revision(&mut env, payload, fixed_now()).unwrap();
775        let err = stage_revision(&mut env, dup, fixed_now()).unwrap_err();
776        assert_eq!(
777            err,
778            RevisionLifecycleError::DuplicateRevision {
779                env_id: env_id(),
780                revision_id: env.revisions[0].revision_id,
781            }
782        );
783        assert_eq!(env.revisions.len(), 1);
784        assert!(!err.env_mutated());
785    }
786
787    #[test]
788    fn stage_rejects_unknown_deployment() {
789        let mut env = env_with_deployment(DeploymentId::new());
790        let other = DeploymentId::new();
791        let err = stage_revision(&mut env, stage_payload(other), fixed_now()).unwrap_err();
792        assert_eq!(
793            err,
794            RevisionLifecycleError::DeploymentNotFound {
795                env_id: env_id(),
796                deployment_id: other,
797            }
798        );
799        assert!(env.revisions.is_empty());
800    }
801
802    // --- warm ---
803
804    #[test]
805    fn warm_advances_staged_to_ready_and_stamps_warmed_at() {
806        let deployment_id = DeploymentId::new();
807        let mut env = env_with_deployment(deployment_id);
808        let rev = stage_revision(&mut env, stage_payload(deployment_id), fixed_now()).unwrap();
809
810        let out = warm_revision(&mut env, warm_payload(rev.revision_id), fixed_now()).unwrap();
811        assert_eq!(out.revision.lifecycle, RevisionLifecycle::Ready);
812        assert_eq!(out.revision.warmed_at, Some(fixed_now()));
813        assert_eq!(out.starting_lifecycle, RevisionLifecycle::Staged);
814        assert_eq!(env.revisions[0].lifecycle, RevisionLifecycle::Ready);
815    }
816
817    #[test]
818    fn warm_gate_failure_flips_to_failed_and_marks_env_mutated() {
819        let deployment_id = DeploymentId::new();
820        let mut env = env_with_deployment(deployment_id);
821        let rev = stage_revision(&mut env, stage_payload(deployment_id), fixed_now()).unwrap();
822
823        let mut payload = warm_payload(rev.revision_id);
824        payload.health_gate = Err(HealthGateFailure {
825            failed_checks: vec![HealthCheckId::RouteTable],
826            message: "route table invalid".to_string(),
827        });
828        let err = warm_revision(&mut env, payload, fixed_now()).unwrap_err();
829        assert!(err.env_mutated(), "gate failure must demand persistence");
830        assert!(matches!(
831            err,
832            RevisionLifecycleError::HealthGateFailed { ref failed_checks, .. }
833                if failed_checks == &vec![HealthCheckId::RouteTable]
834        ));
835        assert_eq!(env.revisions[0].lifecycle, RevisionLifecycle::Failed);
836        assert_eq!(env.revisions[0].warmed_at, None, "on_final must not run");
837    }
838
839    #[test]
840    fn warm_stale_expected_lifecycle_conflicts_without_mutation() {
841        let deployment_id = DeploymentId::new();
842        let mut env = env_with_deployment(deployment_id);
843        let rev = stage_revision(&mut env, stage_payload(deployment_id), fixed_now()).unwrap();
844        env.revisions[0].lifecycle = RevisionLifecycle::Warming;
845
846        // Caller observed `Staged`; a concurrent mutation advanced to Warming.
847        let err = warm_revision(&mut env, warm_payload(rev.revision_id), fixed_now()).unwrap_err();
848        assert!(matches!(
849            err,
850            RevisionLifecycleError::Conflict {
851                actual: RevisionLifecycle::Warming,
852                ..
853            }
854        ));
855        assert!(!err.env_mutated());
856    }
857
858    #[test]
859    fn warm_idempotent_retry_skips_precondition_and_gate() {
860        let deployment_id = DeploymentId::new();
861        let mut env = env_with_deployment(deployment_id);
862        let rev = stage_revision(&mut env, stage_payload(deployment_id), fixed_now()).unwrap();
863        env.revisions[0].lifecycle = RevisionLifecycle::Ready;
864
865        // Stale snapshot + a failing gate: both must be ignored on retry.
866        let mut payload = warm_payload(rev.revision_id);
867        payload.health_gate = Err(HealthGateFailure {
868            failed_checks: vec![],
869            message: "transient".to_string(),
870        });
871        let later = Utc.with_ymd_and_hms(2026, 6, 12, 13, 0, 0).unwrap();
872        let out = warm_revision(&mut env, payload, later).unwrap();
873        assert_eq!(out.revision.lifecycle, RevisionLifecycle::Ready);
874        assert_eq!(out.revision.warmed_at, Some(later), "on_final re-stamps");
875        assert_eq!(out.starting_lifecycle, RevisionLifecycle::Ready);
876    }
877
878    // --- drain / archive ---
879
880    #[test]
881    fn drain_requires_ready() {
882        let deployment_id = DeploymentId::new();
883        let mut env = env_with_deployment(deployment_id);
884        let rev = stage_revision(&mut env, stage_payload(deployment_id), fixed_now()).unwrap();
885
886        let err = drain_revision(&mut env, rev.revision_id).unwrap_err();
887        assert!(matches!(err, RevisionLifecycleError::Conflict { .. }));
888
889        env.revisions[0].lifecycle = RevisionLifecycle::Ready;
890        let out = drain_revision(&mut env, rev.revision_id).unwrap();
891        assert_eq!(out.revision.lifecycle, RevisionLifecycle::Draining);
892        assert_eq!(out.starting_lifecycle, RevisionLifecycle::Ready);
893    }
894
895    #[test]
896    fn archive_walks_draining_to_archived_and_prunes_tracking_list() {
897        let deployment_id = DeploymentId::new();
898        let mut env = env_with_deployment(deployment_id);
899        let rev = stage_revision(&mut env, stage_payload(deployment_id), fixed_now()).unwrap();
900        env.revisions[0].lifecycle = RevisionLifecycle::Draining;
901        env.bundles[0].current_revisions.push(rev.revision_id);
902
903        let out = archive_revision(&mut env, rev.revision_id).unwrap();
904        assert_eq!(out.revision.lifecycle, RevisionLifecycle::Archived);
905        assert_eq!(out.starting_lifecycle, RevisionLifecycle::Draining);
906        assert!(env.bundles[0].current_revisions.is_empty());
907    }
908
909    #[test]
910    fn archive_refuses_live_traffic_reference() {
911        let deployment_id = DeploymentId::new();
912        let mut env = env_with_deployment(deployment_id);
913        let rev = stage_revision(&mut env, stage_payload(deployment_id), fixed_now()).unwrap();
914        env.revisions[0].lifecycle = RevisionLifecycle::Ready;
915        env.traffic_splits.push(TrafficSplit {
916            schema: SchemaVersion::new(SchemaVersion::TRAFFIC_SPLIT_V1),
917            env_id: env_id(),
918            deployment_id,
919            bundle_id: BundleId::new("fast2flow"),
920            generation: 0,
921            entries: vec![TrafficSplitEntry {
922                revision_id: rev.revision_id,
923                weight_bps: 10_000,
924            }],
925            updated_at: fixed_now(),
926            updated_by: "tester".to_string(),
927            idempotency_key: "k1".to_string(),
928            authorization_ref: PathBuf::from("auth.json"),
929            previous_split_ref: None,
930        });
931
932        let err = archive_revision(&mut env, rev.revision_id).unwrap_err();
933        let RevisionLifecycleError::ActiveTrafficReference { splits, .. } = err else {
934            panic!("expected ActiveTrafficReference, got {err:?}");
935        };
936        assert_eq!(splits.len(), 1);
937        assert_eq!(splits[0].weight_bps, 10_000);
938    }
939
940    #[test]
941    fn unknown_revision_is_not_found() {
942        let mut env = env_with_deployment(DeploymentId::new());
943        let missing = RevisionId::new();
944        let err = drain_revision(&mut env, missing).unwrap_err();
945        assert_eq!(
946            err,
947            RevisionLifecycleError::NotFound {
948                env_id: env_id(),
949                revision_id: missing,
950            }
951        );
952    }
953
954    // --- Wire-format pinning ---
955
956    #[test]
957    fn stage_payload_wire_format_is_pinned() {
958        let deployment_id = DeploymentId::new();
959        let payload = stage_payload(deployment_id);
960        let value = serde_json::to_value(&payload).unwrap();
961        assert_eq!(
962            value,
963            json!({
964                "revision_id": payload.revision_id.to_string(),
965                "deployment_id": deployment_id.to_string(),
966                "bundle_digest": "sha256:00",
967                "pack_list": [{
968                    "pack_id": "greentic.test.pack",
969                    "version": "1.0.0",
970                    "digest": "sha256:00",
971                }],
972                "pack_list_lock_ref": "pack-list.lock",
973                "pack_config_refs": [],
974                "config_digest": "sha256:00",
975                "signature_sidecar_ref": "rev.sig",
976                "drain_seconds": 30,
977            })
978        );
979        let back: StageRevisionPayload = serde_json::from_value(value).unwrap();
980        assert_eq!(back.revision_id, payload.revision_id);
981    }
982
983    #[test]
984    fn warm_payload_wire_format_is_pinned() {
985        let revision_id = RevisionId::new();
986        let ok = warm_payload(revision_id);
987        assert_eq!(
988            serde_json::to_value(&ok).unwrap(),
989            json!({
990                "revision_id": revision_id.to_string(),
991                "health_gate": {"ok": true},
992                "expected_lifecycle": "staged",
993            })
994        );
995
996        let mut failing = warm_payload(revision_id);
997        failing.health_gate = Err(HealthGateFailure {
998            failed_checks: vec![HealthCheckId::RouteTable, HealthCheckId::ProviderHealth],
999            message: "boom".to_string(),
1000        });
1001        let value = serde_json::to_value(&failing).unwrap();
1002        assert_eq!(
1003            value["health_gate"],
1004            json!({
1005                "ok": false,
1006                "failure": {
1007                    "failed_checks": ["route-table", "provider-health"],
1008                    "message": "boom",
1009                },
1010            })
1011        );
1012        let back: WarmRevisionPayload = serde_json::from_value(value).unwrap();
1013        assert_eq!(back.health_gate, failing.health_gate);
1014    }
1015
1016    #[test]
1017    fn warm_payload_rejects_contradictory_health_gate() {
1018        let err = serde_json::from_value::<WarmRevisionPayload>(json!({
1019            "revision_id": RevisionId::new().to_string(),
1020            "health_gate": {"ok": true, "failure": {"failed_checks": [], "message": "x"}},
1021            "expected_lifecycle": "staged",
1022        }))
1023        .unwrap_err();
1024        assert!(err.to_string().contains("cannot be `ok: true`"), "{err}");
1025
1026        let err = serde_json::from_value::<WarmRevisionPayload>(json!({
1027            "revision_id": RevisionId::new().to_string(),
1028            "health_gate": {"ok": false},
1029            "expected_lifecycle": "staged",
1030        }))
1031        .unwrap_err();
1032        assert!(err.to_string().contains("must carry a `failure`"), "{err}");
1033    }
1034
1035    #[test]
1036    fn revision_transition_outcome_round_trips() {
1037        let deployment_id = DeploymentId::new();
1038        let mut env = env_with_deployment(deployment_id);
1039        let rev = stage_revision(&mut env, stage_payload(deployment_id), fixed_now()).unwrap();
1040        let outcome = RevisionTransitionOutcome {
1041            revision: rev,
1042            environment: env,
1043            starting_lifecycle: RevisionLifecycle::Staged,
1044        };
1045        let value = serde_json::to_value(&outcome).unwrap();
1046        assert_eq!(value["starting_lifecycle"], "staged");
1047        let back: RevisionTransitionOutcome = serde_json::from_value(value).unwrap();
1048        assert_eq!(back.revision.revision_id, outcome.revision.revision_id);
1049        assert_eq!(back.starting_lifecycle, RevisionLifecycle::Staged);
1050    }
1051}