1use std::path::PathBuf;
27
28use greentic_deploy_spec::{
29 BundleId, DeploymentId, EnvId, PackId, PackListEntry, Revision, RevisionId, RevisionLifecycle,
30 SemVer, is_valid_transition,
31};
32use serde::{Deserialize, Serialize};
33use serde_json::{Value, json};
34
35use crate::environment::{
36 EnvironmentReads, EnvironmentStore, LocalFsStore, StageRevisionPayload, WarmRevisionPayload,
37};
38use crate::rollout_telemetry::emit_lifecycle_event;
39use greentic_deploy_spec::Environment;
40use greentic_telemetry::RolloutEvent;
41
42use super::{
43 AuditCtx, OpError, OpFlags, OpOutcome, audit_and_record, map_store_err_preserving_noun,
44 mint_idempotency_key,
45};
46
47const NOUN: &str = "revisions";
48
49#[derive(Debug, Clone, Serialize, Deserialize)]
50pub struct RevisionStagePayload {
51 pub environment_id: String,
52 pub deployment_id: String,
53 #[serde(default, skip_serializing_if = "Option::is_none")]
61 pub revision_id: Option<String>,
62 #[serde(default, skip_serializing_if = "Option::is_none")]
67 pub idempotency_key: Option<String>,
68 #[serde(default, skip_serializing_if = "Option::is_none")]
75 pub bundle_path: Option<PathBuf>,
76 #[serde(default = "default_bundle_digest")]
77 pub bundle_digest: String,
78 #[serde(default, skip_serializing_if = "Option::is_none")]
86 pub bundle_source_uri: Option<String>,
87 #[serde(default)]
88 pub pack_list: Vec<PackListEntryPayload>,
89 #[serde(default)]
95 pub pack_list_lock_ref: PathBuf,
96 #[serde(default = "default_config_digest")]
97 pub config_digest: String,
98 #[serde(default = "default_signature_sidecar_ref")]
99 pub signature_sidecar_ref: PathBuf,
100 #[serde(default = "default_drain_seconds")]
101 pub drain_seconds: u32,
102}
103
104pub(super) fn default_bundle_digest() -> String {
105 "sha256:00".to_string()
106}
107pub(super) fn default_config_digest() -> String {
108 "sha256:00".to_string()
109}
110pub(super) fn default_signature_sidecar_ref() -> PathBuf {
111 PathBuf::from("rev.sig")
112}
113pub(super) fn default_drain_seconds() -> u32 {
114 30
115}
116
117pub(super) fn parse_pack_list(
121 entries: Vec<PackListEntryPayload>,
122) -> Result<Vec<PackListEntry>, OpError> {
123 entries
124 .into_iter()
125 .map(|e| {
126 Ok::<_, OpError>(PackListEntry {
127 pack_id: PackId::new(e.pack_id),
128 version: e
129 .version
130 .parse::<SemVer>()
131 .map_err(|err| OpError::InvalidArgument(format!("pack version: {err}")))?,
132 digest: e.digest,
133 source_uri: e.source_uri,
134 })
135 })
136 .collect()
137}
138
139#[derive(Debug, Clone, Serialize, Deserialize)]
140pub struct PackListEntryPayload {
141 pub pack_id: String,
142 pub version: String,
143 pub digest: String,
144 #[serde(default, skip_serializing_if = "Option::is_none")]
145 pub source_uri: Option<String>,
146}
147
148#[derive(Debug, Clone, Serialize, Deserialize)]
149pub struct RevisionTransitionPayload {
150 pub environment_id: String,
151 pub revision_id: String,
152 #[serde(default, skip_serializing_if = "Option::is_none")]
159 pub idempotency_key: Option<String>,
160}
161
162#[derive(Debug, Clone, Serialize, Deserialize)]
163pub struct RevisionSummary {
164 pub revision_id: String,
165 pub deployment_id: String,
166 pub bundle_id: String,
167 pub sequence: u64,
168 pub lifecycle: RevisionLifecycle,
169}
170
171impl From<&Revision> for RevisionSummary {
172 fn from(r: &Revision) -> Self {
173 Self {
174 revision_id: r.revision_id.to_string(),
175 deployment_id: r.deployment_id.to_string(),
176 bundle_id: r.bundle_id.as_str().to_string(),
177 sequence: r.sequence,
178 lifecycle: r.lifecycle,
179 }
180 }
181}
182
183pub fn stage(
186 store: &LocalFsStore,
187 flags: &OpFlags,
188 payload: Option<RevisionStagePayload>,
189) -> Result<OpOutcome, OpError> {
190 if flags.schema_only {
191 return Ok(OpOutcome::new(NOUN, "stage", stage_schema()));
192 }
193 let payload = resolve_payload::<RevisionStagePayload>(flags, payload)?;
194 let env_id = parse_env_id(&payload.environment_id)?;
195 let deployment_id = parse_deployment_id(&payload.deployment_id)?;
196 let pack_list = if payload.bundle_path.is_some() {
202 Vec::new()
203 } else {
204 parse_pack_list(payload.pack_list)?
205 };
206 if !is_valid_transition(RevisionLifecycle::Inactive, RevisionLifecycle::Staged) {
207 return Err(OpError::Conflict(
208 "spec rejects inactive → staged".to_string(),
209 ));
210 }
211 let ctx = AuditCtx {
212 env_id: env_id.clone(),
213 noun: NOUN,
214 verb: "stage",
215 target: json!({
216 "deployment_id": deployment_id.to_string(),
217 "lifecycle_to": "staged",
218 }),
219 idempotency_key: None,
220 };
221 let RevisionStagePayload {
222 bundle_path,
223 bundle_digest: payload_bundle_digest,
224 bundle_source_uri,
225 pack_list_lock_ref: payload_pack_list_lock_ref,
226 config_digest,
227 signature_sidecar_ref,
228 drain_seconds,
229 ..
230 } = payload;
231 audit_and_record(store, ctx, |_committed| {
232 let env = store.load(&env_id).map_err(map_store_err_preserving_noun)?;
238 let bundle_id = env
239 .bundles
240 .iter()
241 .find(|b| b.deployment_id == deployment_id)
242 .map(|b| b.bundle_id.clone())
243 .ok_or_else(|| {
244 OpError::NotFound(format!(
245 "deployment `{deployment_id}` not found in env `{env_id}`"
246 ))
247 })?;
248
249 let revision_id = crate::environment::mint_revision_id();
254 let env_dir = store.env_dir(&env_id)?;
255 let drop_rev_dir = || {
259 let rev_dir = env_dir.join("revisions").join(revision_id.to_string());
260 let _ = std::fs::remove_dir_all(&rev_dir);
261 };
262
263 let has_bundle = bundle_path.is_some();
268 let (bundle_digest, revision_pack_list, pack_list_lock_ref, pack_config_refs) =
269 match bundle_path {
270 Some(bundle_path) => {
271 let staged = super::bundle_stage::stage_local_bundle(
272 &env_dir,
273 revision_id,
274 &bundle_path,
275 )?;
276 let mut lock_derived_pack_list: Vec<PackListEntry> =
282 Vec::with_capacity(staged.lock.packs.len());
283 let mut pinned_pack_ids: std::collections::HashSet<String> =
284 std::collections::HashSet::with_capacity(staged.lock.packs.len());
285 for lp in &staged.lock.packs {
286 let pack_id = lp.pack_id.clone();
287 pinned_pack_ids.insert(pack_id.as_str().to_string());
288 lock_derived_pack_list.push(PackListEntry::from_lock_primitives(
289 pack_id,
290 lp.digest.clone(),
291 ));
292 }
293 let rev_dir = env_dir.join("revisions").join(revision_id.to_string());
294 let pack_config_refs = super::pack_config_stage::materialize_pack_configs(
298 &env_dir,
299 &rev_dir,
300 revision_id,
301 &env_id,
302 &bundle_id,
303 &pinned_pack_ids,
304 )
305 .inspect_err(|_| drop_rev_dir())?;
306 (
307 staged.bundle_digest,
308 lock_derived_pack_list,
309 staged.pack_list_lock_ref,
310 pack_config_refs,
311 )
312 }
313 None => (
314 payload_bundle_digest,
315 pack_list,
316 payload_pack_list_lock_ref,
317 Vec::new(),
318 ),
319 };
320
321 let store_payload = StageRevisionPayload {
322 revision_id,
323 deployment_id,
324 bundle_digest,
325 bundle_source_uri,
326 pack_list: revision_pack_list,
327 pack_list_lock_ref,
328 pack_config_refs,
329 config_digest,
330 signature_sidecar_ref,
331 drain_seconds,
332 };
333 let revision = store
339 .stage_revision(&env_id, store_payload, mint_idempotency_key())
340 .inspect_err(|_| {
341 if has_bundle {
342 drop_rev_dir();
343 }
344 })
345 .map_err(map_store_err_preserving_noun)?;
346 let outcome = OpOutcome::new(
347 NOUN,
348 "stage",
349 serde_json::to_value(RevisionSummary::from(&revision))
350 .expect("RevisionSummary is json-safe"),
351 );
352 Ok((outcome, super::AuditGens::NONE))
353 })
354}
355
356pub fn warm(
365 store: &LocalFsStore,
366 flags: &OpFlags,
367 payload: Option<RevisionTransitionPayload>,
368) -> Result<OpOutcome, OpError> {
369 warm_with_health_gate(store, flags, payload, |_env, _revision| Ok(()))
370}
371
372pub fn warm_with_health_gate<G>(
403 store: &LocalFsStore,
404 flags: &OpFlags,
405 payload: Option<RevisionTransitionPayload>,
406 health_gate: G,
407) -> Result<OpOutcome, OpError>
408where
409 G: FnOnce(
410 &greentic_deploy_spec::Environment,
411 &Revision,
412 ) -> Result<(), crate::environment::HealthGateFailure>,
413{
414 if flags.schema_only {
415 return Ok(OpOutcome::new(NOUN, "warm", transition_schema()));
416 }
417 let payload = resolve_payload::<RevisionTransitionPayload>(flags, payload)?;
418 let env_id = parse_env_id(&payload.environment_id)?;
419 let revision_id = parse_revision_id(&payload.revision_id)?;
420 let idempotency_key = super::resolve_idempotency_key(payload.idempotency_key)?;
421
422 let env = store.load(&env_id)?;
430 let current_revision = env
431 .revisions
432 .iter()
433 .find(|r| r.revision_id == revision_id)
434 .ok_or_else(|| {
435 OpError::NotFound(format!(
436 "revision `{revision_id}` not found in env `{env_id}`"
437 ))
438 })?;
439 let current_lifecycle = current_revision.lifecycle;
440
441 let mut synthesized = current_revision.clone();
446 synthesized.lifecycle = RevisionLifecycle::Ready;
447 let gate_result = health_gate(&env, &synthesized);
451
452 let op = "warm";
454 let ctx = AuditCtx {
455 env_id: env_id.clone(),
456 noun: NOUN,
457 verb: op,
458 target: json!({
459 "revision_id": revision_id.to_string(),
460 "lifecycle_to": RevisionLifecycle::Ready,
461 }),
462 idempotency_key: Some(idempotency_key.as_str().to_string()),
463 };
464 audit_and_record(store, ctx, |committed| {
465 let store_result = store
466 .warm_revision(
467 &env_id,
468 WarmRevisionPayload {
469 revision_id,
470 health_gate: gate_result,
471 expected_lifecycle: current_lifecycle,
472 },
473 idempotency_key,
474 )
475 .inspect_err(|err| {
476 if err.is_committed_after_save() {
477 committed.mark_committed();
478 }
479 });
480 match &store_result {
483 Err(crate::environment::StoreError::Lifecycle(inner))
484 if matches!(
485 inner.as_ref(),
486 crate::environment::LifecycleError::HealthGateFailed { .. }
487 ) =>
488 {
489 committed.mark_committed();
490 if let Ok(env_for_emit) = store.load(&env_id)
493 && let Some(rev_for_emit) = env_for_emit
494 .revisions
495 .iter()
496 .find(|r| r.revision_id == revision_id)
497 {
498 emit_for_op(op, true, None, &env_for_emit, rev_for_emit);
499 }
500 return Err(map_store_err_preserving_noun(store_result.unwrap_err()));
501 }
502 _ => {}
503 }
504 let outcome = store_result.map_err(map_store_err_preserving_noun)?;
505 committed.mark_committed();
506 emit_for_op(
507 op,
508 false,
509 Some(outcome.starting_lifecycle),
510 &outcome.environment,
511 &outcome.revision,
512 );
513 let summary = RevisionSummary::from(&outcome.revision);
514 let op_outcome = OpOutcome::new(
515 NOUN,
516 op,
517 serde_json::to_value(summary).expect("RevisionSummary is json-safe"),
518 );
519 Ok((op_outcome, super::AuditGens::NONE))
520 })
521}
522
523pub fn drain(
527 store: &LocalFsStore,
528 flags: &OpFlags,
529 payload: Option<RevisionTransitionPayload>,
530) -> Result<OpOutcome, OpError> {
531 if flags.schema_only {
532 return Ok(OpOutcome::new(NOUN, "drain", transition_schema()));
533 }
534 typed_transition(
535 store,
536 flags,
537 payload,
538 "drain",
539 RevisionLifecycle::Draining,
540 |env_id, revision_id, key| store.drain_revision(env_id, revision_id, key),
541 )
542}
543
544pub fn archive(
554 store: &LocalFsStore,
555 flags: &OpFlags,
556 payload: Option<RevisionTransitionPayload>,
557) -> Result<OpOutcome, OpError> {
558 if flags.schema_only {
559 return Ok(OpOutcome::new(NOUN, "archive", transition_schema()));
560 }
561 typed_transition(
562 store,
563 flags,
564 payload,
565 "archive",
566 RevisionLifecycle::Archived,
567 |env_id, revision_id, key| store.archive_revision(env_id, revision_id, key),
568 )
569}
570
571pub fn list(
573 store: &dyn EnvironmentReads,
574 flags: &OpFlags,
575 env_id: &str,
576) -> Result<OpOutcome, OpError> {
577 if flags.schema_only {
578 return Ok(OpOutcome::new(
579 NOUN,
580 "list",
581 json!({"input_schema": "env_id positional"}),
582 ));
583 }
584 let env_id = parse_env_id(env_id)?;
585 if !store.env_exists(&env_id)? {
586 return Err(OpError::NotFound(format!("environment `{env_id}`")));
587 }
588 let env = store.load_env(&env_id)?;
589 let revisions: Vec<RevisionSummary> = env.revisions.iter().map(RevisionSummary::from).collect();
590 Ok(OpOutcome::new(
591 NOUN,
592 "list",
593 json!({"environment_id": env_id.as_str(), "revisions": revisions}),
594 ))
595}
596
597fn typed_transition<F>(
610 store: &LocalFsStore,
611 flags: &OpFlags,
612 payload: Option<RevisionTransitionPayload>,
613 op: &'static str,
614 lifecycle_to: RevisionLifecycle,
615 call_verb: F,
616) -> Result<OpOutcome, OpError>
617where
618 F: FnOnce(
619 &greentic_deploy_spec::EnvId,
620 greentic_deploy_spec::RevisionId,
621 greentic_deploy_spec::IdempotencyKey,
622 ) -> Result<
623 crate::environment::RevisionTransitionOutcome,
624 crate::environment::StoreError,
625 >,
626{
627 let payload = resolve_payload::<RevisionTransitionPayload>(flags, payload)?;
628 let env_id = parse_env_id(&payload.environment_id)?;
629 let revision_id = parse_revision_id(&payload.revision_id)?;
630 let idempotency_key = super::resolve_idempotency_key(payload.idempotency_key)?;
635 let ctx = AuditCtx {
636 env_id: env_id.clone(),
637 noun: NOUN,
638 verb: op,
639 target: json!({
644 "revision_id": revision_id.to_string(),
645 "lifecycle_to": lifecycle_to,
646 }),
647 idempotency_key: Some(idempotency_key.as_str().to_string()),
648 };
649 audit_and_record(store, ctx, |committed| {
650 let outcome = call_verb(&env_id, revision_id, idempotency_key)
657 .inspect_err(|err| {
658 if err.is_committed_after_save() {
659 committed.mark_committed();
660 }
661 })
662 .map_err(map_store_err_preserving_noun)?;
663 committed.mark_committed();
666 emit_for_op(
667 op,
668 false,
669 Some(outcome.starting_lifecycle),
670 &outcome.environment,
671 &outcome.revision,
672 );
673 let summary = RevisionSummary::from(&outcome.revision);
674 let op_outcome = OpOutcome::new(
675 NOUN,
676 op,
677 serde_json::to_value(summary).expect("RevisionSummary is json-safe"),
678 );
679 Ok((op_outcome, super::AuditGens::NONE))
680 })
681}
682
683pub(crate) fn emit_for_op(
703 op: &'static str,
704 gate_failed: bool,
705 starting_lifecycle: Option<RevisionLifecycle>,
706 env: &Environment,
707 revision: &Revision,
708) {
709 match (op, gate_failed) {
710 ("warm", false) => {
711 emit_lifecycle_event(RolloutEvent::HealthGatePassed, env, revision);
712 emit_lifecycle_event(RolloutEvent::RevisionWarmed, env, revision);
713 }
714 ("warm", true) => {
715 emit_lifecycle_event(RolloutEvent::HealthGateFailed, env, revision);
716 }
717 ("drain", false) => {
718 emit_lifecycle_event(RolloutEvent::RevisionDraining, env, revision);
719 }
720 ("archive", false) if starting_lifecycle == Some(RevisionLifecycle::Draining) => {
721 emit_lifecycle_event(RolloutEvent::RevisionEvicted, env, revision);
722 }
723 _ => {}
724 }
725}
726
727pub fn payload_from_stage_args(
732 args: super::dispatch::RevisionStageArgs,
733) -> Result<Option<RevisionStagePayload>, OpError> {
734 let super::dispatch::RevisionStageArgs {
735 env_id,
736 deployment,
737 bundle,
738 } = args;
739 if env_id.is_none() && deployment.is_none() && bundle.is_none() {
741 return Ok(None);
742 }
743 let environment_id = env_id.ok_or_else(|| {
744 OpError::InvalidArgument("revisions stage: missing positional `<env_id>`".to_string())
745 })?;
746 let deployment_id = deployment.ok_or_else(|| {
747 OpError::InvalidArgument("revisions stage: missing `--deployment <ULID>`".to_string())
748 })?;
749 let bundle_path = bundle.ok_or_else(|| {
755 OpError::InvalidArgument(
756 "revisions stage: missing `--bundle <PATH>`. The direct CLI path stages a local \
757 .gtbundle; use `--answers <file>` for the legacy verbatim path."
758 .to_string(),
759 )
760 })?;
761 Ok(Some(RevisionStagePayload {
762 environment_id,
763 deployment_id,
764 revision_id: None,
766 idempotency_key: None,
767 bundle_path: Some(bundle_path),
768 bundle_digest: default_bundle_digest(),
769 bundle_source_uri: None,
773 pack_list: Vec::new(),
774 pack_list_lock_ref: PathBuf::new(),
775 config_digest: default_config_digest(),
776 signature_sidecar_ref: default_signature_sidecar_ref(),
777 drain_seconds: default_drain_seconds(),
778 }))
779}
780
781fn resolve_payload<T: serde::de::DeserializeOwned>(
782 flags: &OpFlags,
783 payload: Option<T>,
784) -> Result<T, OpError> {
785 if let Some(p) = payload {
786 return Ok(p);
787 }
788 if let Some(path) = &flags.answers {
789 return super::load_answers::<T>(path);
790 }
791 Err(OpError::InvalidArgument(
792 "no payload provided: pass --answers <path> or supply the payload directly".to_string(),
793 ))
794}
795
796fn parse_env_id(raw: &str) -> Result<EnvId, OpError> {
797 EnvId::try_from(raw).map_err(|e| OpError::InvalidArgument(format!("environment_id: {e}")))
798}
799
800fn parse_deployment_id(raw: &str) -> Result<DeploymentId, OpError> {
801 use std::str::FromStr;
802 let ulid = ulid::Ulid::from_str(raw)
803 .map_err(|e| OpError::InvalidArgument(format!("deployment_id: {e}")))?;
804 Ok(DeploymentId(ulid))
805}
806
807fn parse_revision_id(raw: &str) -> Result<RevisionId, OpError> {
808 use std::str::FromStr;
809 let ulid = ulid::Ulid::from_str(raw)
810 .map_err(|e| OpError::InvalidArgument(format!("revision_id: {e}")))?;
811 Ok(RevisionId(ulid))
812}
813
814#[allow(dead_code)]
815fn discard_bundle(_id: &BundleId) {
816 }
820
821fn stage_schema() -> Value {
822 json!({
823 "$schema": "https://json-schema.org/draft/2020-12/schema",
824 "title": "RevisionStagePayload",
825 "type": "object",
826 "required": ["environment_id", "deployment_id"],
827 "additionalProperties": false,
828 "properties": {
829 "environment_id": {"type": "string"},
830 "deployment_id": {"type": "string", "description": "ULID"},
831 "bundle_path": {"type": "string", "description": "Local .gtbundle to extract + pin; derives bundle_digest/pack_list/pack_list_lock_ref"},
832 "bundle_digest": {"type": "string"},
833 "bundle_source_uri": {"type": "string", "description": "oci:// / repo:// / store:// ref the bundle was resolved from; makes the revision pullable by a remote worker. Omit for local-serve-only"},
834 "pack_list": {"type": "array"},
835 "pack_list_lock_ref": {"type": "string"},
836 "config_digest": {"type": "string"},
837 "signature_sidecar_ref": {"type": "string"},
838 "drain_seconds": {"type": "integer", "minimum": 0}
839 }
840 })
841}
842
843fn transition_schema() -> Value {
844 json!({
845 "$schema": "https://json-schema.org/draft/2020-12/schema",
846 "title": "RevisionTransitionPayload",
847 "type": "object",
848 "required": ["environment_id", "revision_id"],
849 "additionalProperties": false,
850 "properties": {
851 "environment_id": {"type": "string"},
852 "revision_id": {"type": "string", "description": "ULID"},
853 "idempotency_key": {
854 "type": "string",
855 "description": "Optional A8 §2 caller-supplied key for safe retry replay; minted per-invocation when omitted."
856 }
857 }
858 })
859}
860
861#[cfg(test)]
862mod tests {
863 use super::*;
864 use crate::cli::tests_common::{make_bundle_deployment, make_env};
865 use tempfile::tempdir;
866
867 #[test]
874 fn transition_schema_lists_idempotency_key() {
875 let schema = transition_schema();
876 assert!(
877 schema.pointer("/properties/idempotency_key").is_some(),
878 "transition_schema must list `idempotency_key` so --schema-driven \
879 callers can supply the A8 retry key (schema: {schema:#})"
880 );
881 }
882
883 #[test]
889 fn stage_schema_lists_bundle_source_uri() {
890 let schema = stage_schema();
891 assert!(
892 schema.pointer("/properties/bundle_source_uri").is_some(),
893 "stage_schema must list `bundle_source_uri` so --schema-driven \
894 callers can record the bundle's registry source (schema: {schema:#})"
895 );
896 }
897
898 fn seed_env_with_deployment(store: &LocalFsStore) -> DeploymentId {
899 let mut env = make_env("local");
900 let deployment = make_bundle_deployment("local", "fast2flow");
901 let did = deployment.deployment_id;
902 env.bundles.push(deployment);
903 store.save(&env).unwrap();
904 did
905 }
906
907 fn stage_payload(deployment_id: &DeploymentId) -> RevisionStagePayload {
908 RevisionStagePayload {
909 environment_id: "local".to_string(),
910 deployment_id: deployment_id.to_string(),
911 revision_id: None,
912 idempotency_key: None,
913 bundle_path: None,
914 bundle_digest: "sha256:00".to_string(),
915 bundle_source_uri: None,
916 pack_list: vec![PackListEntryPayload {
917 pack_id: "greentic.test.pack".to_string(),
918 version: "1.0.0".to_string(),
919 digest: "sha256:00".to_string(),
920 source_uri: None,
921 }],
922 pack_list_lock_ref: PathBuf::new(),
923 config_digest: default_config_digest(),
924 signature_sidecar_ref: default_signature_sidecar_ref(),
925 drain_seconds: default_drain_seconds(),
926 }
927 }
928
929 #[test]
930 fn stage_creates_revision_in_staged() {
931 let dir = tempdir().unwrap();
932 let store = LocalFsStore::new(dir.path());
933 let did = seed_env_with_deployment(&store);
934 let outcome = stage(&store, &OpFlags::default(), Some(stage_payload(&did))).unwrap();
935 assert_eq!(
936 outcome.result.get("lifecycle").and_then(|v| v.as_str()),
937 Some("staged")
938 );
939 assert_eq!(
940 outcome.result.get("sequence").and_then(|v| v.as_u64()),
941 Some(1)
942 );
943 }
944
945 #[test]
951 fn stage_records_bundle_source_uri_on_the_revision() {
952 let dir = tempdir().unwrap();
953 let store = LocalFsStore::new(dir.path());
954 let did = seed_env_with_deployment(&store);
955
956 let mut payload = stage_payload(&did);
957 payload.bundle_source_uri =
958 Some("oci://ghcr.io/greenticai/bundles/demo@sha256:abc".to_string());
959 stage(&store, &OpFlags::default(), Some(payload)).unwrap();
960
961 let env = store.load(&parse_env_id("local").unwrap()).unwrap();
962 assert_eq!(
963 env.revisions[0].bundle_source_uri.as_deref(),
964 Some("oci://ghcr.io/greenticai/bundles/demo@sha256:abc")
965 );
966 }
967
968 #[test]
973 fn stage_with_bundle_on_named_env_is_not_authz_denied() {
974 let dir = tempdir().unwrap();
975 let store = LocalFsStore::new(dir.path());
976 let mut env = make_env("prod");
977 let deployment = make_bundle_deployment("prod", "fast2flow");
978 let did = deployment.deployment_id;
979 env.bundles.push(deployment);
980 store.save(&env).unwrap();
981
982 let fixture = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
983 .join("testdata/bundles/perf-smoke-bundle.gtbundle");
984 let mut payload = stage_payload(&did);
985 payload.environment_id = "prod".to_string();
986 payload.bundle_path = Some(fixture);
987
988 let result = stage(&store, &OpFlags::default(), Some(payload));
989 assert!(
990 !matches!(result, Err(OpError::Unauthorized { .. })),
991 "named env stage must not be authz-denied; got {result:?}"
992 );
993 }
994
995 #[test]
996 fn stage_with_local_bundle_pins_packs_into_lockfile() {
997 use greentic_deploy_spec::PackListLock;
998 use sha2::{Digest, Sha256};
999
1000 let dir = tempdir().unwrap();
1001 let store = LocalFsStore::new(dir.path());
1002 let did = seed_env_with_deployment(&store);
1003
1004 let fixture = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
1005 .join("testdata/bundles/perf-smoke-bundle.gtbundle");
1006 let mut payload = stage_payload(&did);
1007 payload.bundle_path = Some(fixture);
1008 payload.pack_list = vec![PackListEntryPayload {
1010 pack_id: "should.be.ignored".to_string(),
1011 version: "9.9.9".to_string(),
1012 digest: "sha256:ff".to_string(),
1013 source_uri: None,
1014 }];
1015
1016 let outcome = stage(&store, &OpFlags::default(), Some(payload)).unwrap();
1017 assert_eq!(
1018 outcome.result.get("lifecycle").and_then(|v| v.as_str()),
1019 Some("staged")
1020 );
1021 let rid = outcome
1022 .result
1023 .get("revision_id")
1024 .and_then(|v| v.as_str())
1025 .unwrap()
1026 .to_string();
1027
1028 let env_id = EnvId::try_from("local").unwrap();
1030 let env = store.load(&env_id).unwrap();
1031 let revision = env
1032 .revisions
1033 .iter()
1034 .find(|r| r.revision_id.to_string() == rid)
1035 .expect("revision persisted");
1036 assert!(
1037 revision.bundle_digest.starts_with("sha256:") && revision.bundle_digest != "sha256:00",
1038 "bundle_digest should be the real archive hash, got {}",
1039 revision.bundle_digest
1040 );
1041 assert!(
1046 !revision.pack_list.is_empty(),
1047 "pack_list should be populated from the lock"
1048 );
1049
1050 let env_dir = store.env_dir(&env_id).unwrap();
1051 let lock_path = env_dir.join(&revision.pack_list_lock_ref);
1052 assert!(lock_path.is_file(), "pack-list.lock must be a regular file");
1053
1054 let lock: PackListLock =
1055 serde_json::from_slice(&std::fs::read(&lock_path).unwrap()).unwrap();
1056 assert_eq!(lock.revision_id, revision.revision_id);
1057 assert!(!lock.packs.is_empty(), "fixture bundle has a .gtpack");
1058
1059 for pack in &lock.packs {
1060 assert!(pack.path.is_relative(), "lock path must be env-relative");
1062 let pack_path = env_dir.join(&pack.path);
1063 assert!(
1064 pack_path.is_file(),
1065 "extracted .gtpack must exist: {}",
1066 pack_path.display()
1067 );
1068 let bytes = std::fs::read(&pack_path).unwrap();
1070 let expected = format!("sha256:{}", hex::encode(Sha256::digest(&bytes)));
1071 assert_eq!(pack.digest, expected, "lock digest must match the file");
1072 }
1073 }
1074
1075 #[test]
1079 fn stage_args_without_bundle_is_rejected() {
1080 let did = DeploymentId::new();
1081 let args = crate::cli::dispatch::RevisionStageArgs {
1082 env_id: Some("local".to_string()),
1083 deployment: Some(did.to_string()),
1084 bundle: None,
1085 };
1086 let err = payload_from_stage_args(args).unwrap_err();
1087 let msg = format!("{err}");
1088 assert!(
1089 matches!(err, OpError::InvalidArgument(_)) && msg.contains("--bundle"),
1090 "expected a missing --bundle error, got: {msg}"
1091 );
1092 }
1093
1094 #[test]
1097 fn stage_args_empty_defers_to_answers() {
1098 let args = crate::cli::dispatch::RevisionStageArgs {
1099 env_id: None,
1100 deployment: None,
1101 bundle: None,
1102 };
1103 assert!(payload_from_stage_args(args).unwrap().is_none());
1104 }
1105
1106 #[test]
1110 fn stage_bundle_digest_is_bound_to_staged_copy_not_input() {
1111 use sha2::{Digest, Sha256};
1112
1113 let dir = tempdir().unwrap();
1114 let store = LocalFsStore::new(dir.path());
1115 let did = seed_env_with_deployment(&store);
1116
1117 let fixture = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
1120 .join("testdata/bundles/perf-smoke-bundle.gtbundle");
1121 let input = dir.path().join("input.gtbundle");
1122 std::fs::copy(&fixture, &input).unwrap();
1123
1124 let mut payload = stage_payload(&did);
1125 payload.bundle_path = Some(input.clone());
1126 let outcome = stage(&store, &OpFlags::default(), Some(payload)).unwrap();
1127 let rid = outcome
1128 .result
1129 .get("revision_id")
1130 .and_then(|v| v.as_str())
1131 .unwrap()
1132 .to_string();
1133
1134 let env_id = EnvId::try_from("local").unwrap();
1135 let env = store.load(&env_id).unwrap();
1136 let revision = env
1137 .revisions
1138 .iter()
1139 .find(|r| r.revision_id.to_string() == rid)
1140 .unwrap();
1141
1142 let env_dir = store.env_dir(&env_id).unwrap();
1144 let staged = env_dir.join("revisions").join(&rid).join("bundle.gtbundle");
1145 assert!(staged.is_file(), "staged bundle copy must persist");
1146 let staged_digest = format!(
1147 "sha256:{}",
1148 hex::encode(Sha256::digest(std::fs::read(&staged).unwrap()))
1149 );
1150 assert_eq!(revision.bundle_digest, staged_digest);
1151
1152 std::fs::write(&input, b"tampered-after-stage").unwrap();
1155 let staged_digest_after = format!(
1156 "sha256:{}",
1157 hex::encode(Sha256::digest(std::fs::read(&staged).unwrap()))
1158 );
1159 assert_eq!(
1160 revision.bundle_digest, staged_digest_after,
1161 "input mutation must not change the staged artifact's digest"
1162 );
1163 }
1164
1165 #[test]
1166 fn warm_advances_to_ready() {
1167 let dir = tempdir().unwrap();
1168 let store = LocalFsStore::new(dir.path());
1169 let did = seed_env_with_deployment(&store);
1170 let staged = stage(&store, &OpFlags::default(), Some(stage_payload(&did))).unwrap();
1171 let rid = staged
1172 .result
1173 .get("revision_id")
1174 .and_then(|v| v.as_str())
1175 .unwrap()
1176 .to_string();
1177 let warmed = warm(
1178 &store,
1179 &OpFlags::default(),
1180 Some(RevisionTransitionPayload {
1181 environment_id: "local".to_string(),
1182 revision_id: rid,
1183 idempotency_key: None,
1184 }),
1185 )
1186 .unwrap();
1187 assert_eq!(
1188 warmed.result.get("lifecycle").and_then(|v| v.as_str()),
1189 Some("ready")
1190 );
1191 }
1192
1193 #[test]
1194 fn drain_after_warm_succeeds() {
1195 let dir = tempdir().unwrap();
1196 let store = LocalFsStore::new(dir.path());
1197 let did = seed_env_with_deployment(&store);
1198 let staged = stage(&store, &OpFlags::default(), Some(stage_payload(&did))).unwrap();
1199 let rid = staged
1200 .result
1201 .get("revision_id")
1202 .and_then(|v| v.as_str())
1203 .unwrap()
1204 .to_string();
1205 warm(
1206 &store,
1207 &OpFlags::default(),
1208 Some(RevisionTransitionPayload {
1209 environment_id: "local".to_string(),
1210 revision_id: rid.clone(),
1211 idempotency_key: None,
1212 }),
1213 )
1214 .unwrap();
1215 let drained = drain(
1216 &store,
1217 &OpFlags::default(),
1218 Some(RevisionTransitionPayload {
1219 environment_id: "local".to_string(),
1220 revision_id: rid,
1221 idempotency_key: None,
1222 }),
1223 )
1224 .unwrap();
1225 assert_eq!(
1226 drained.result.get("lifecycle").and_then(|v| v.as_str()),
1227 Some("draining")
1228 );
1229 }
1230
1231 #[test]
1232 fn drain_from_staged_errors() {
1233 let dir = tempdir().unwrap();
1234 let store = LocalFsStore::new(dir.path());
1235 let did = seed_env_with_deployment(&store);
1236 let staged = stage(&store, &OpFlags::default(), Some(stage_payload(&did))).unwrap();
1237 let rid = staged
1238 .result
1239 .get("revision_id")
1240 .and_then(|v| v.as_str())
1241 .unwrap()
1242 .to_string();
1243 let err = drain(
1244 &store,
1245 &OpFlags::default(),
1246 Some(RevisionTransitionPayload {
1247 environment_id: "local".to_string(),
1248 revision_id: rid,
1249 idempotency_key: None,
1250 }),
1251 )
1252 .unwrap_err();
1253 assert!(matches!(err, OpError::Conflict(_)), "got {err:?}");
1254 }
1255
1256 #[test]
1257 fn archive_prunes_current_revisions_when_no_live_traffic() {
1258 let dir = tempdir().unwrap();
1264 let store = LocalFsStore::new(dir.path());
1265 let mut env = make_env("local");
1266 let mut deployment = make_bundle_deployment("local", "fast2flow");
1267 let did = deployment.deployment_id;
1268 let revision = crate::cli::tests_common::make_revision(
1269 "local",
1270 "fast2flow",
1271 &did,
1272 1,
1273 RevisionLifecycle::Ready,
1274 );
1275 let rid = revision.revision_id;
1276 deployment.current_revisions.push(rid);
1277 env.bundles.push(deployment);
1278 env.revisions.push(revision);
1279 store.save(&env).unwrap();
1280
1281 let outcome = archive(
1282 &store,
1283 &OpFlags::default(),
1284 Some(RevisionTransitionPayload {
1285 environment_id: "local".to_string(),
1286 revision_id: rid.to_string(),
1287 idempotency_key: None,
1288 }),
1289 )
1290 .unwrap();
1291 assert_eq!(
1292 outcome.result.get("lifecycle").and_then(|v| v.as_str()),
1293 Some("archived")
1294 );
1295
1296 let env = store.load(&EnvId::try_from("local").unwrap()).unwrap();
1297 assert!(
1298 env.bundles[0].current_revisions.is_empty(),
1299 "current_revisions should be pruned"
1300 );
1301 }
1302
1303 #[test]
1304 fn archive_refuses_when_revision_is_in_live_traffic_split() {
1305 let dir = tempdir().unwrap();
1310 let store = LocalFsStore::new(dir.path());
1311 let mut env = make_env("local");
1312 let mut deployment = make_bundle_deployment("local", "fast2flow");
1313 let did = deployment.deployment_id;
1314 let revision = crate::cli::tests_common::make_revision(
1315 "local",
1316 "fast2flow",
1317 &did,
1318 1,
1319 RevisionLifecycle::Ready,
1320 );
1321 let rid = revision.revision_id;
1322 deployment.current_revisions.push(rid);
1323 let split = crate::cli::tests_common::make_traffic_split(
1324 "local",
1325 "fast2flow",
1326 &did,
1327 &rid,
1328 "test-key",
1329 );
1330 env.bundles.push(deployment);
1331 env.revisions.push(revision);
1332 env.traffic_splits.push(split);
1333 store.save(&env).unwrap();
1334
1335 let err = archive(
1336 &store,
1337 &OpFlags::default(),
1338 Some(RevisionTransitionPayload {
1339 environment_id: "local".to_string(),
1340 revision_id: rid.to_string(),
1341 idempotency_key: None,
1342 }),
1343 )
1344 .unwrap_err();
1345 match err {
1346 OpError::Conflict(msg) => {
1347 assert!(
1348 msg.contains("live traffic split")
1349 && msg.contains("rebalance via `gtc op traffic set`"),
1350 "expected actionable conflict message, got: {msg}"
1351 );
1352 }
1353 other => panic!("expected Conflict, got `{other:?}`"),
1354 }
1355
1356 let env = store.load(&EnvId::try_from("local").unwrap()).unwrap();
1358 assert_eq!(env.revisions[0].lifecycle, RevisionLifecycle::Ready);
1359 assert_eq!(env.traffic_splits.len(), 1);
1360 assert!(env.bundles[0].current_revisions.contains(&rid));
1361 }
1362
1363 #[test]
1364 fn archive_completes_a_drained_revision_through_inactive() {
1365 let dir = tempdir().unwrap();
1369 let store = LocalFsStore::new(dir.path());
1370 let mut env = make_env("local");
1371 let deployment = make_bundle_deployment("local", "fast2flow");
1372 let did = deployment.deployment_id;
1373 let revision = crate::cli::tests_common::make_revision(
1374 "local",
1375 "fast2flow",
1376 &did,
1377 1,
1378 RevisionLifecycle::Inactive,
1379 );
1380 let rid = revision.revision_id;
1381 env.bundles.push(deployment);
1382 env.revisions.push(revision);
1383 store.save(&env).unwrap();
1384
1385 let outcome = archive(
1386 &store,
1387 &OpFlags::default(),
1388 Some(RevisionTransitionPayload {
1389 environment_id: "local".to_string(),
1390 revision_id: rid.to_string(),
1391 idempotency_key: None,
1392 }),
1393 )
1394 .unwrap();
1395 assert_eq!(
1396 outcome.result.get("lifecycle").and_then(|v| v.as_str()),
1397 Some("archived")
1398 );
1399 }
1400
1401 #[test]
1402 fn list_reflects_stage_calls() {
1403 let dir = tempdir().unwrap();
1404 let store = LocalFsStore::new(dir.path());
1405 let did = seed_env_with_deployment(&store);
1406 stage(&store, &OpFlags::default(), Some(stage_payload(&did))).unwrap();
1407 stage(&store, &OpFlags::default(), Some(stage_payload(&did))).unwrap();
1408 let listed = list(&store, &OpFlags::default(), "local").unwrap();
1409 let revs = listed
1410 .result
1411 .get("revisions")
1412 .and_then(|v| v.as_array())
1413 .unwrap();
1414 assert_eq!(revs.len(), 2);
1415 let seqs: Vec<u64> = revs
1417 .iter()
1418 .filter_map(|r| r.get("sequence").and_then(|v| v.as_u64()))
1419 .collect();
1420 assert_eq!(seqs, vec![1, 2]);
1421 }
1422
1423 #[test]
1429 fn warm_with_passing_gate_lands_ready() {
1430 let dir = tempdir().unwrap();
1431 let store = LocalFsStore::new(dir.path());
1432 let did = seed_env_with_deployment(&store);
1433 let staged = stage(&store, &OpFlags::default(), Some(stage_payload(&did))).unwrap();
1434 let rid = staged
1435 .result
1436 .get("revision_id")
1437 .and_then(|v| v.as_str())
1438 .unwrap()
1439 .to_string();
1440 let warmed = warm_with_health_gate(
1441 &store,
1442 &OpFlags::default(),
1443 Some(RevisionTransitionPayload {
1444 environment_id: "local".to_string(),
1445 revision_id: rid,
1446 idempotency_key: None,
1447 }),
1448 |_env, _revision| Ok(()),
1449 )
1450 .unwrap();
1451 assert_eq!(
1452 warmed.result.get("lifecycle").and_then(|v| v.as_str()),
1453 Some("ready")
1454 );
1455 }
1456
1457 #[test]
1462 fn warm_with_failing_gate_persists_failed_and_returns_conflict() {
1463 let dir = tempdir().unwrap();
1464 let store = LocalFsStore::new(dir.path());
1465 let did = seed_env_with_deployment(&store);
1466 let staged = stage(&store, &OpFlags::default(), Some(stage_payload(&did))).unwrap();
1467 let rid_str = staged
1468 .result
1469 .get("revision_id")
1470 .and_then(|v| v.as_str())
1471 .unwrap()
1472 .to_string();
1473
1474 let err = warm_with_health_gate(
1475 &store,
1476 &OpFlags::default(),
1477 Some(RevisionTransitionPayload {
1478 environment_id: "local".to_string(),
1479 revision_id: rid_str.clone(),
1480 idempotency_key: None,
1481 }),
1482 |_env, _revision| {
1483 Err(crate::environment::HealthGateFailure {
1484 failed_checks: vec![crate::environment::HealthCheckId::RuntimeConfig],
1485 message: "runtime-config.json missing".to_string(),
1486 })
1487 },
1488 )
1489 .unwrap_err();
1490 assert!(matches!(err, OpError::Conflict(_)), "got {err:?}");
1491 let msg = format!("{err}");
1492 assert!(msg.contains("warm/ready health gate"), "msg: {msg}");
1493 assert!(msg.contains("RuntimeConfig"), "msg: {msg}");
1494
1495 let env_id = EnvId::try_from("local").unwrap();
1497 let env = store.load(&env_id).unwrap();
1498 assert_eq!(env.revisions.len(), 1);
1499 assert_eq!(env.revisions[0].lifecycle, RevisionLifecycle::Failed);
1500 }
1501
1502 #[test]
1510 fn warm_failing_gate_with_audit_failure_returns_audit_error() {
1511 let dir = tempdir().unwrap();
1512 let store = LocalFsStore::new(dir.path());
1513 let did = seed_env_with_deployment(&store);
1514 let staged = stage(&store, &OpFlags::default(), Some(stage_payload(&did))).unwrap();
1515 let rid_str = staged
1516 .result
1517 .get("revision_id")
1518 .and_then(|v| v.as_str())
1519 .unwrap()
1520 .to_string();
1521
1522 let env_id = EnvId::try_from("local").unwrap();
1526 let env_dir = store.env_dir(&env_id).unwrap();
1527 let events_path = env_dir.join("audit").join("events.jsonl");
1528 let _ = std::fs::remove_file(&events_path);
1529 std::fs::create_dir(&events_path).unwrap();
1530
1531 let err = warm_with_health_gate(
1532 &store,
1533 &OpFlags::default(),
1534 Some(RevisionTransitionPayload {
1535 environment_id: "local".to_string(),
1536 revision_id: rid_str.clone(),
1537 idempotency_key: None,
1538 }),
1539 |_env, _revision| {
1540 Err(crate::environment::HealthGateFailure {
1541 failed_checks: vec![crate::environment::HealthCheckId::RuntimeConfig],
1542 message: "runtime-config.json missing".to_string(),
1543 })
1544 },
1545 )
1546 .unwrap_err();
1547
1548 match &err {
1551 OpError::Audit(_) => {}
1552 other => panic!("expected OpError::Audit (fail-closed); got `{other:?}`"),
1553 }
1554
1555 let env = store.load(&env_id).unwrap();
1558 assert_eq!(env.revisions[0].lifecycle, RevisionLifecycle::Failed);
1559 }
1560
1561 #[test]
1567 fn warm_uncommitted_error_with_audit_failure_returns_original_error() {
1568 let dir = tempdir().unwrap();
1569 let store = LocalFsStore::new(dir.path());
1570 let _did = seed_env_with_deployment(&store);
1571
1572 let env_id = EnvId::try_from("local").unwrap();
1574 let env_dir = store.env_dir(&env_id).unwrap();
1575 std::fs::write(env_dir.join("audit"), b"audit-blocker").unwrap();
1576
1577 let phantom_rid = ulid::Ulid::new().to_string();
1579 let err = warm(
1580 &store,
1581 &OpFlags::default(),
1582 Some(RevisionTransitionPayload {
1583 environment_id: "local".to_string(),
1584 revision_id: phantom_rid,
1585 idempotency_key: None,
1586 }),
1587 )
1588 .unwrap_err();
1589
1590 match &err {
1592 OpError::NotFound(_) => {}
1593 other => panic!("expected OpError::NotFound (audit demoted); got `{other:?}`"),
1594 }
1595 }
1596
1597 #[test]
1611 fn warm_ok_with_refresh_failure_and_audit_failure_returns_audit_error() {
1612 let dir = tempdir().unwrap();
1613 let store = LocalFsStore::new(dir.path());
1614 let did = seed_env_with_deployment(&store);
1615 let staged = stage(&store, &OpFlags::default(), Some(stage_payload(&did))).unwrap();
1616 let rid_str = staged
1617 .result
1618 .get("revision_id")
1619 .and_then(|v| v.as_str())
1620 .unwrap()
1621 .to_string();
1622
1623 let env_id = EnvId::try_from("local").unwrap();
1624 let env_dir = store.env_dir(&env_id).unwrap();
1625
1626 std::fs::create_dir(env_dir.join("runtime-config.json")).unwrap();
1630
1631 let events_path = env_dir.join("audit").join("events.jsonl");
1634 let _ = std::fs::remove_file(&events_path);
1635 std::fs::create_dir(&events_path).unwrap();
1636
1637 let err = warm_with_health_gate(
1638 &store,
1639 &OpFlags::default(),
1640 Some(RevisionTransitionPayload {
1641 environment_id: "local".to_string(),
1642 revision_id: rid_str,
1643 idempotency_key: None,
1644 }),
1645 |_env, _revision| Ok(()),
1646 )
1647 .unwrap_err();
1648
1649 match &err {
1654 OpError::Audit(_) => {}
1655 other => panic!("expected OpError::Audit (fail-closed); got `{other:?}`"),
1656 }
1657
1658 let env = store.load(&env_id).unwrap();
1661 assert_eq!(env.revisions[0].lifecycle, RevisionLifecycle::Ready);
1662 }
1663
1664 #[test]
1673 fn drain_ok_with_refresh_failure_and_audit_failure_returns_audit_error() {
1674 let dir = tempdir().unwrap();
1675 let store = LocalFsStore::new(dir.path());
1676 let did = seed_env_with_deployment(&store);
1677 let staged = stage(&store, &OpFlags::default(), Some(stage_payload(&did))).unwrap();
1678 let rid_str = staged
1679 .result
1680 .get("revision_id")
1681 .and_then(|v| v.as_str())
1682 .unwrap()
1683 .to_string();
1684
1685 let env_id = EnvId::try_from("local").unwrap();
1689 let mut env = store.load(&env_id).unwrap();
1690 env.revisions[0].lifecycle = RevisionLifecycle::Ready;
1691 store.save(&env).unwrap();
1692
1693 let env_dir = store.env_dir(&env_id).unwrap();
1694
1695 let _ = std::fs::remove_file(env_dir.join("runtime-config.json"));
1698 std::fs::create_dir(env_dir.join("runtime-config.json")).unwrap();
1699 let events_path = env_dir.join("audit").join("events.jsonl");
1700 let _ = std::fs::remove_file(&events_path);
1701 std::fs::create_dir(&events_path).unwrap();
1702
1703 let err = drain(
1704 &store,
1705 &OpFlags::default(),
1706 Some(RevisionTransitionPayload {
1707 environment_id: "local".to_string(),
1708 revision_id: rid_str,
1709 idempotency_key: None,
1710 }),
1711 )
1712 .unwrap_err();
1713
1714 match &err {
1715 OpError::Audit(_) => {}
1716 other => panic!("expected OpError::Audit (fail-closed); got `{other:?}`"),
1717 }
1718
1719 let env = store.load(&env_id).unwrap();
1721 assert_eq!(env.revisions[0].lifecycle, RevisionLifecycle::Draining);
1722 }
1723
1724 use crate::rollout_telemetry::test_capture::capture_events;
1742 use std::collections::BTreeSet;
1743
1744 fn observed(events: &[String]) -> BTreeSet<String> {
1747 events.iter().cloned().collect()
1748 }
1749
1750 #[test]
1754 fn warm_emits_health_gate_passed_and_revision_warmed() {
1755 let dir = tempdir().unwrap();
1756 let store = LocalFsStore::new(dir.path());
1757 let did = seed_env_with_deployment(&store);
1758 let staged = stage(&store, &OpFlags::default(), Some(stage_payload(&did))).unwrap();
1759 let rid = staged
1760 .result
1761 .get("revision_id")
1762 .and_then(|v| v.as_str())
1763 .unwrap()
1764 .to_string();
1765
1766 let (result, events) = capture_events(|| {
1767 warm(
1768 &store,
1769 &OpFlags::default(),
1770 Some(RevisionTransitionPayload {
1771 environment_id: "local".to_string(),
1772 revision_id: rid,
1773 idempotency_key: None,
1774 }),
1775 )
1776 });
1777 result.unwrap();
1778 let observed = observed(&events);
1779 assert!(
1780 observed.contains("rollout.health_gate.passed"),
1781 "observed events: {observed:?}"
1782 );
1783 assert!(
1784 observed.contains("rollout.revision.warmed"),
1785 "observed events: {observed:?}"
1786 );
1787 assert!(!observed.contains("rollout.health_gate.failed"));
1789 }
1790
1791 #[test]
1795 fn warm_with_failing_gate_emits_health_gate_failed() {
1796 let dir = tempdir().unwrap();
1797 let store = LocalFsStore::new(dir.path());
1798 let did = seed_env_with_deployment(&store);
1799 let staged = stage(&store, &OpFlags::default(), Some(stage_payload(&did))).unwrap();
1800 let rid = staged
1801 .result
1802 .get("revision_id")
1803 .and_then(|v| v.as_str())
1804 .unwrap()
1805 .to_string();
1806
1807 let (result, events) = capture_events(|| {
1808 warm_with_health_gate(
1809 &store,
1810 &OpFlags::default(),
1811 Some(RevisionTransitionPayload {
1812 environment_id: "local".to_string(),
1813 revision_id: rid,
1814 idempotency_key: None,
1815 }),
1816 |_env, _revision| {
1817 Err(crate::environment::HealthGateFailure {
1818 failed_checks: vec![crate::environment::HealthCheckId::RuntimeConfig],
1819 message: "synthetic gate failure".to_string(),
1820 })
1821 },
1822 )
1823 });
1824 result.unwrap_err();
1825 let observed = observed(&events);
1826 assert!(
1827 observed.contains("rollout.health_gate.failed"),
1828 "observed events: {observed:?}"
1829 );
1830 assert!(!observed.contains("rollout.health_gate.passed"));
1832 assert!(!observed.contains("rollout.revision.warmed"));
1833 }
1834
1835 #[test]
1839 fn drain_emits_revision_draining() {
1840 let dir = tempdir().unwrap();
1841 let store = LocalFsStore::new(dir.path());
1842 let did = seed_env_with_deployment(&store);
1843 let staged = stage(&store, &OpFlags::default(), Some(stage_payload(&did))).unwrap();
1844 let rid = staged
1845 .result
1846 .get("revision_id")
1847 .and_then(|v| v.as_str())
1848 .unwrap()
1849 .to_string();
1850 warm(
1852 &store,
1853 &OpFlags::default(),
1854 Some(RevisionTransitionPayload {
1855 environment_id: "local".to_string(),
1856 revision_id: rid.clone(),
1857 idempotency_key: None,
1858 }),
1859 )
1860 .unwrap();
1861
1862 let (result, events) = capture_events(|| {
1863 drain(
1864 &store,
1865 &OpFlags::default(),
1866 Some(RevisionTransitionPayload {
1867 environment_id: "local".to_string(),
1868 revision_id: rid,
1869 idempotency_key: None,
1870 }),
1871 )
1872 });
1873 result.unwrap();
1874 let observed = observed(&events);
1875 assert!(
1876 observed.contains("rollout.revision.draining"),
1877 "observed events: {observed:?}"
1878 );
1879 }
1880
1881 #[test]
1886 fn archive_emits_revision_evicted_on_draining_to_inactive() {
1887 let dir = tempdir().unwrap();
1888 let store = LocalFsStore::new(dir.path());
1889 let did = seed_env_with_deployment(&store);
1890 let staged = stage(&store, &OpFlags::default(), Some(stage_payload(&did))).unwrap();
1891 let rid = staged
1892 .result
1893 .get("revision_id")
1894 .and_then(|v| v.as_str())
1895 .unwrap()
1896 .to_string();
1897 warm(
1900 &store,
1901 &OpFlags::default(),
1902 Some(RevisionTransitionPayload {
1903 environment_id: "local".to_string(),
1904 revision_id: rid.clone(),
1905 idempotency_key: None,
1906 }),
1907 )
1908 .unwrap();
1909 drain(
1910 &store,
1911 &OpFlags::default(),
1912 Some(RevisionTransitionPayload {
1913 environment_id: "local".to_string(),
1914 revision_id: rid.clone(),
1915 idempotency_key: None,
1916 }),
1917 )
1918 .unwrap();
1919
1920 let (result, events) = capture_events(|| {
1921 archive(
1922 &store,
1923 &OpFlags::default(),
1924 Some(RevisionTransitionPayload {
1925 environment_id: "local".to_string(),
1926 revision_id: rid,
1927 idempotency_key: None,
1928 }),
1929 )
1930 });
1931 result.unwrap();
1932 let observed = observed(&events);
1933 assert!(
1934 observed.contains("rollout.revision.evicted"),
1935 "observed events: {observed:?}"
1936 );
1937 }
1938}