1use std::collections::BTreeMap;
31use std::path::{Path, PathBuf};
32use std::sync::Mutex;
33use std::time::Duration;
34
35use serde::Serialize;
36
37use crate::client::GatewayApi;
38use crate::config::ModuleDeclaration;
39use crate::error::CoreError;
40use crate::module::ModuleSpec;
41use crate::poll::{self, PollConfig, PollState};
42use crate::rig::compose::{
43 ComposeRunner, check_output, compose_version, docker_ps_publish_args, down_args, logs_args,
44 parse_docker_ps_ldjson, parse_ps_ldjson, parse_volume_ls_ldjson, ps_args, reset_preview,
45 up_args, volume_ls_args,
46};
47use crate::rig::modules::{
48 ModuleProvisioning, OrphanedModule, ProvisionedModule, existing_override,
49};
50use crate::rig::{RigPlan, port_preflight};
51
52pub const DEFAULT_WAIT_TIMEOUT_S: u64 = 300;
56
57const RUNNING: &str = "RUNNING";
59
60const GATEWAY_HTTP_TARGET: u16 = 8088;
62const GATEWAY_HTTPS_TARGET: u16 = 443;
63
64#[derive(Debug, Serialize)]
66pub struct RigUpResult {
67 pub rig: String,
69 pub project: String,
72 pub state: String,
74 pub gateway_url: Option<String>,
77 pub warnings: Vec<String>,
80 pub provisioned_modules: Vec<ProvisionedModule>,
85 pub orphaned_modules: Vec<OrphanedModule>,
92}
93
94#[derive(Debug, Serialize)]
96pub struct RigDownResult {
97 pub rig: String,
99 pub project: String,
101 pub state: String,
103}
104
105#[derive(Debug, Serialize)]
107pub struct RigResetResult {
108 pub rig: String,
110 pub project: String,
112 pub removed_volumes: Vec<String>,
115 pub state: String,
118 pub warnings: Vec<String>,
120 pub provisioned_modules: Vec<ProvisionedModule>,
125 pub orphaned_modules: Vec<OrphanedModule>,
129}
130
131#[derive(Debug, Serialize)]
133pub struct StatusPublisher {
134 pub published_port: Option<u16>,
136 pub target_port: Option<u16>,
138 pub protocol: Option<String>,
140}
141
142#[derive(Debug, Serialize)]
144pub struct StatusService {
145 pub name: String,
147 pub state: String,
149 pub health: Option<String>,
151 pub exit_code: Option<i64>,
153 pub publishers: Vec<StatusPublisher>,
155}
156
157#[derive(Debug, Serialize)]
161pub struct RigStatusResult {
162 pub rig: String,
164 pub project: String,
166 pub compose_file: String,
168 pub services: Vec<StatusService>,
171 pub volumes: Vec<String>,
173 pub ports_free: bool,
176}
177
178pub fn gateway_url_from(plan: &RigPlan) -> Option<String> {
184 if let Some(mapping) = plan
185 .port_mappings
186 .iter()
187 .find(|mapping| mapping.target == GATEWAY_HTTP_TARGET)
188 {
189 return Some(format!("http://localhost:{}", mapping.published));
190 }
191 if let Some(mapping) = plan
192 .port_mappings
193 .iter()
194 .find(|mapping| mapping.target == GATEWAY_HTTPS_TARGET)
195 {
196 return Some(format!("https://localhost:{}", mapping.published));
197 }
198 None
199}
200
201pub async fn rig_up(
216 runner: &dyn ComposeRunner,
217 plan: &RigPlan,
218 wait_timeout_s: u64,
219 gateway: Option<&dyn GatewayApi>,
220 provisioning: &ModuleProvisioning,
221) -> Result<RigUpResult, CoreError> {
222 compose_version(runner).await?;
224
225 if let Some(conflict) = port_preflight(runner, plan).await?.first() {
229 return Err(CoreError::Rig(format!(
230 "port {} in use by {} — stop it or change the rig's published port",
231 conflict.port, conflict.attribution
232 )));
233 }
234
235 let output = runner
237 .run(&up_args(
238 plan,
239 wait_timeout_s,
240 provisioning.override_files(),
241 ))
242 .await;
243 check_output(&output, "docker compose up")?;
244
245 let gateway_url = gateway_url_from(plan);
246 let mut warnings = Vec::new();
247 let state = match (gateway, &gateway_url) {
248 (Some(api), Some(url)) => {
249 commissioned_wait(api, url, wait_timeout_s, &mut warnings).await?
250 }
251 _ => {
252 warnings.push(
253 "no gateway port mapping (target 8088/443) found — skipped the \
254 commissioned wait"
255 .to_string(),
256 );
257 "running".to_string()
258 }
259 };
260
261 Ok(RigUpResult {
262 rig: plan.name.clone(),
263 project: plan.name.clone(),
264 state,
265 gateway_url,
266 warnings,
267 provisioned_modules: provisioning.modules.clone(),
268 orphaned_modules: provisioning.orphaned_modules.clone(),
269 })
270}
271
272async fn commissioned_wait(
278 api: &dyn GatewayApi,
279 url: &str,
280 wait_timeout_s: u64,
281 warnings: &mut Vec<String>,
282) -> Result<String, CoreError> {
283 let cfg = PollConfig {
284 subject: format!("rig gateway RUNNING (GET {url}/StatusPing)"),
285 interval: Duration::from_secs(2),
286 deadline: Duration::from_secs(wait_timeout_s),
287 ..PollConfig::default()
288 };
289 let mut uncommissioned = Mutex::new(false);
293 let url_owned = url.to_string();
294 let outcome = poll::poll(cfg, &mut uncommissioned, |uncommissioned| {
295 Box::pin(async {
296 match api.status_ping().await {
297 Ok(ping) if ping.state == RUNNING => Ok(PollState::<()>::Done(())),
298 Ok(ping) => Ok(PollState::Pending(Some(ping.state))),
299 Err(CoreError::GatewayNotCommissioned { .. }) => {
302 *uncommissioned.get_mut().expect("commissioned flag") = true;
303 Ok(PollState::Pending(Some(format!(
304 "gateway uncommissioned — open {url_owned}/welcome"
305 ))))
306 }
307 Err(other) => Err(other),
309 }
310 })
311 })
312 .await;
313 match outcome {
314 Ok(()) => Ok("running".to_string()),
315 Err(CoreError::Network { source: None, .. })
319 if *uncommissioned.lock().expect("commissioned flag") =>
320 {
321 warnings.push(format!(
322 "gateway uncommissioned — open {url}/welcome in a browser and complete \
323 the commissioning wizard (no headless commissioning exists)"
324 ));
325 Ok("uncommissioned".to_string())
326 }
327 Err(other) => Err(CoreError::Rig(format!(
328 "gateway did not reach RUNNING within {wait_timeout_s}s — {other}"
329 ))),
330 }
331}
332
333pub async fn rig_down(
342 runner: &dyn ComposeRunner,
343 plan: &RigPlan,
344) -> Result<RigDownResult, CoreError> {
345 compose_version(runner).await?;
346 let override_files: Vec<PathBuf> = existing_override(plan).into_iter().collect();
347 let output = runner.run(&down_args(plan, false, &override_files)).await;
348 check_output(&output, "docker compose down")?;
349 Ok(RigDownResult {
350 rig: plan.name.clone(),
351 project: plan.name.clone(),
352 state: "down".to_string(),
353 })
354}
355
356pub async fn rig_reset(
382 runner: &dyn ComposeRunner,
383 plan: &RigPlan,
384 wait_timeout_s: u64,
385 gateway: Option<&dyn GatewayApi>,
386 provisioning: &ModuleProvisioning,
387) -> Result<RigResetResult, CoreError> {
388 let removed_volumes = reset_preview(runner, plan).await?;
390
391 compose_version(runner).await?;
393
394 let output = runner
398 .run(&down_args(plan, true, provisioning.override_files()))
399 .await;
400 check_output(&output, "docker compose down")?;
401
402 if let Some(conflict) = port_preflight(runner, plan).await?.first() {
404 return Err(CoreError::Rig(format!(
405 "port {} in use by {} — stop it or change the rig's published port \
406 (the rig is torn down; re-run `rig up` once the port frees)",
407 conflict.port, conflict.attribution
408 )));
409 }
410
411 let output = runner
413 .run(&up_args(
414 plan,
415 wait_timeout_s,
416 provisioning.override_files(),
417 ))
418 .await;
419 check_output(&output, "docker compose up")?;
420
421 let gateway_url = gateway_url_from(plan);
423 let mut warnings = Vec::new();
424 let state = match (gateway, &gateway_url) {
425 (Some(api), Some(url)) => {
426 commissioned_wait(api, url, wait_timeout_s, &mut warnings).await?
427 }
428 _ => {
429 warnings.push(
430 "no gateway port mapping (target 8088/443) found — skipped the \
431 commissioned wait"
432 .to_string(),
433 );
434 "running".to_string()
435 }
436 };
437
438 Ok(RigResetResult {
439 rig: plan.name.clone(),
440 project: plan.name.clone(),
441 removed_volumes,
442 state,
443 warnings,
444 provisioned_modules: provisioning.modules.clone(),
445 orphaned_modules: provisioning.orphaned_modules.clone(),
446 })
447}
448
449#[derive(Debug, Serialize)]
454pub struct RigLogsResult {
455 pub streamed: usize,
457}
458
459#[derive(Debug, Serialize)]
463pub struct TrialBanners {
464 pub severity: Option<String>,
467 pub expire_time_ms: Option<i64>,
470 pub active: bool,
474}
475
476#[derive(Debug, Serialize)]
480pub struct TrialStatusResult {
481 pub license_mode: String,
483 pub trial_state: String,
486 pub trial_remaining_s: i64,
489 pub expired: bool,
491 pub emergency: bool,
493 pub emergency_remaining_s: i64,
495 pub development: bool,
497 pub banners: TrialBanners,
499 pub warnings: Vec<String>,
502}
503
504fn epoch_ms_now() -> i64 {
506 std::time::SystemTime::now()
507 .duration_since(std::time::UNIX_EPOCH)
508 .expect("system clock is after the unix epoch")
509 .as_millis() as i64
510}
511
512pub async fn trial_status(gateway: &dyn GatewayApi) -> Result<TrialStatusResult, CoreError> {
522 let wire = gateway.trial_status_wire().await?;
523 let mut warnings = Vec::new();
524 let banners = match gateway.banners().await {
525 Ok(set) => {
526 let trial_banner = set.banners.iter().find(|banner| banner.r#type == "trial");
527 match trial_banner {
528 Some(banner) => {
529 let active = banner.data.severity == "info"
530 && banner
531 .data
532 .expire_time_ms
533 .is_some_and(|ms| ms > epoch_ms_now());
534 TrialBanners {
535 severity: Some(banner.data.severity.clone()),
536 expire_time_ms: banner.data.expire_time_ms,
537 active,
538 }
539 }
540 None => TrialBanners {
541 severity: None,
542 expire_time_ms: None,
543 active: false,
544 },
545 }
546 }
547 Err(err) => {
548 warnings.push(format!(
549 "banners cross-check unavailable ({}); the trial endpoint's \
550 expired flag is the truth",
551 err
552 ));
553 TrialBanners {
554 severity: None,
555 expire_time_ms: None,
556 active: false,
557 }
558 }
559 };
560 Ok(TrialStatusResult {
561 license_mode: wire.license_mode,
562 trial_state: wire.trial_state,
563 trial_remaining_s: wire.trial_seconds_left,
564 expired: wire.expired,
565 emergency: wire.emergency,
566 emergency_remaining_s: wire.emergency_seconds_left,
567 development: wire.development,
568 banners,
569 warnings,
570 })
571}
572
573#[derive(Debug, Serialize)]
577pub struct TrialResetResult {
578 pub rig_url: String,
580 pub mechanism: String,
584 pub expired_before: bool,
587 pub expired_after: bool,
590 pub trial_remaining_s: i64,
593}
594
595pub async fn trial_reset(
616 gateway: &dyn GatewayApi,
617 rig_url: &str,
618 token_available: bool,
619 basic: Option<(&str, &crate::config::Secret)>,
620) -> Result<TrialResetResult, CoreError> {
621 let before = gateway.trial_status_wire().await?;
624 if !before.expired {
625 return Err(CoreError::TrialNotExpired {
626 remaining_s: before.trial_seconds_left,
627 endpoint: Some(format!("{rig_url}/data/api/v1/trial")),
628 });
629 }
630
631 if token_available {
636 match gateway.trial_reset_wire().await {
637 Ok(_fresh) => {
638 let after = gateway.trial_status_wire().await?;
639 return finish(rig_url, "token", after);
640 }
641 Err(err) => {
642 tracing::warn!(
643 error = %err,
644 "trial-reset tier 0 (token-auth POST) failed — falling through to the login rung"
645 );
646 }
647 }
648 }
649
650 let Some((username, password)) = basic else {
653 return Err(CoreError::SecretUnavailable {
658 profile: rig_url.to_string(),
659 });
660 };
661 let flow = crate::client::idp::IdpLoginFlow::new(rig_url)?;
662 let (flow, session) = crate::client::idp::login(flow, username, password).await?;
663 crate::client::idp::trial_reset_via_session(&flow, &session).await?;
664 let after = gateway.trial_status_wire().await?;
666 finish(rig_url, "login", after)
667}
668
669fn finish(
671 rig_url: &str,
672 mechanism: &str,
673 after: crate::client::trial::TrialWire,
674) -> Result<TrialResetResult, CoreError> {
675 if after.expired {
676 return Err(CoreError::Internal(format!(
677 "trial reset was accepted but the read-back still reports expired \
678 ({}s left) — re-run `rig trial status` to see the gateway's answer",
679 after.trial_seconds_left
680 )));
681 }
682 Ok(TrialResetResult {
683 rig_url: rig_url.to_string(),
684 mechanism: mechanism.to_string(),
685 expired_before: true,
686 expired_after: after.expired,
687 trial_remaining_s: after.trial_seconds_left,
688 })
689}
690
691pub async fn rig_logs(
703 runner: &dyn ComposeRunner,
704 plan: &RigPlan,
705 tail: u32,
706 follow: bool,
707 service: Option<&str>,
708 sink: &mut (dyn FnMut(String) + Send),
709) -> Result<RigLogsResult, CoreError> {
710 let override_files: Vec<PathBuf> = existing_override(plan).into_iter().collect();
713 let args = logs_args(plan, tail, follow, service, &override_files);
714 let mut streamed = 0usize;
715 let output = if follow {
716 let mut forwarder = |line: &str| {
719 streamed += 1;
720 sink(line.to_string());
721 };
722 runner.run_streaming(&args, &mut forwarder).await
723 } else {
724 runner.run(&args).await
725 };
726 if !output.stderr.trim().is_empty() {
727 tracing::warn!(
728 source = "docker compose logs",
729 stderr = %output.stderr.trim(),
730 "compose diagnostics (stderr passthrough — never the data sink)"
731 );
732 }
733 let stdout = check_output(&output, "docker compose logs")?;
735 for line in stdout.lines() {
736 sink(line.to_string());
737 streamed += 1;
738 }
739 Ok(RigLogsResult { streamed })
740}
741
742pub const RESTORE_WAIT_FLOOR_S: u64 = 300;
752
753pub const RESTORE_TOKEN_WARNING: &str = "API tokens may have been reset by restore \
758— re-provision via gateway UI, then ign doctor";
759
760const MANIFEST_NOTES: [&str; 2] = [
765 "trial clock state is NOT captured by gwbk (unknown behavior — reset \
766 separately via rig trial reset)",
767 "tag-provider bulk export is Phase 5 scope (TAGS-09); gwbk captures tag \
768 config via gateway data",
769];
770
771#[derive(Debug, Serialize)]
773pub struct SnapshotResult {
774 pub dir: String,
777 pub gwbk_bytes: u64,
780 pub projects: Vec<String>,
782 pub manifest_path: String,
784}
785
786#[derive(Debug, Serialize)]
788pub struct RestoreResult {
789 pub restored_from: String,
791 pub state: String,
795 pub warnings: Vec<String>,
798}
799
800#[derive(Debug, Serialize)]
803pub struct ModuleUninstallResult {
804 pub rig: String,
806 pub module_id: String,
808 pub gateway_module_id: String,
811 pub uninstalled: bool,
814}
815
816pub fn module_still_declared_error(id: &str, rig: &str) -> CoreError {
831 CoreError::Rig(format!(
832 "module {id:?} is still declared in [rigs.{rig}.modules.{id}] — \
833 uninstalling it now would be undone by the very next `ign rig up` \
834 (the gateway refuses to forget a module whose `.modl` is still \
835 mounted); remove that table entry, run `ign rig up` so the mount \
836 is dropped, then retry `ign rig module uninstall {id} --yes`"
837 ))
838}
839
840pub async fn rig_module_uninstall(
854 api: &dyn GatewayApi,
855 rig: &str,
856 spec: &'static ModuleSpec,
857 declared: &BTreeMap<String, ModuleDeclaration>,
858) -> Result<ModuleUninstallResult, CoreError> {
859 if declared.contains_key(spec.id) {
860 return Err(module_still_declared_error(spec.id, rig));
861 }
862 api.uninstall_module(spec.gateway_module_id, spec.id)
863 .await?;
864 Ok(ModuleUninstallResult {
865 rig: rig.to_string(),
866 module_id: spec.id.to_string(),
867 gateway_module_id: spec.gateway_module_id.to_string(),
868 uninstalled: true,
869 })
870}
871
872fn civil_from_days(days: i64) -> (i64, u32, u32) {
877 let z = days + 719_468;
878 let era = z.div_euclid(146_097);
879 let doe = z.rem_euclid(146_097); let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; let y = yoe + era * 400;
882 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); let mp = (5 * doy + 2) / 153; let d = (doy - (153 * mp + 2) / 5 + 1) as u32; let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32; (if m <= 2 { y + 1 } else { y }, m, d)
887}
888
889fn stamp_from_secs(secs: i64) -> String {
891 let days = secs.div_euclid(86_400);
892 let time_of_day = secs.rem_euclid(86_400);
893 let (year, month, day) = civil_from_days(days);
894 let hour = time_of_day / 3600;
895 let minute = (time_of_day % 3600) / 60;
896 let second = time_of_day % 60;
897 format!("{year:04}{month:02}{day:02}-{hour:02}{minute:02}{second:02}")
898}
899
900pub async fn rig_snapshot(
925 gateway: &dyn GatewayApi,
926 rig_name: &str,
927 out_dir: Option<&Path>,
928) -> Result<SnapshotResult, CoreError> {
929 let epoch_s = std::time::SystemTime::now()
931 .duration_since(std::time::UNIX_EPOCH)
932 .expect("system clock is after the unix epoch")
933 .as_secs() as i64;
934 let dir: PathBuf = match out_dir {
935 Some(dir) => dir.to_path_buf(),
936 None => PathBuf::from("ign-rig-snapshots").join(format!(
937 "{}-{}",
938 rig_name,
939 stamp_from_secs(epoch_s)
940 )),
941 };
942 tokio::fs::create_dir_all(&dir)
943 .await
944 .map_err(|err| CoreError::Internal(format!("cannot create {}: {err}", dir.display())))?;
945
946 let gwbk_name = format!("{rig_name}.gwbk");
950 let meta = gateway
951 .backup_download(
952 &dir.join(&gwbk_name),
953 crate::client::backup::BackupType::Roaming,
954 )
955 .await?;
956
957 let page = gateway
959 .projects(&crate::client::query::ListQuery::default())
960 .await?;
961 let projects_dir = dir.join("projects");
962 let mut exported: Vec<(String, String)> = Vec::new();
963 for record in &page.items {
964 if exported.is_empty() {
965 tokio::fs::create_dir_all(&projects_dir)
966 .await
967 .map_err(|err| {
968 CoreError::Internal(format!("cannot create {}: {err}", projects_dir.display()))
969 })?;
970 }
971 let file = format!(
972 "projects/{}.zip",
973 crate::client::projects::encode_segment(&record.name)
974 );
975 gateway
976 .project_export_to_file(&record.name, &dir.join(&file))
977 .await?;
978 exported.push((record.name.clone(), file));
979 }
980
981 let version = gateway
983 .gateway_info()
984 .await
985 .ok()
986 .map(|info| info.ignition_version);
987 let manifest = serde_json::json!({
988 "rig": rig_name,
989 "taken_at": epoch_s,
990 "ignition": { "version": version },
991 "gwbk": gwbk_name,
992 "projects": exported
993 .iter()
994 .map(|(name, file)| serde_json::json!({ "name": name, "file": file }))
995 .collect::<Vec<_>>(),
996 "notes": MANIFEST_NOTES,
997 });
998 let manifest_path = dir.join("manifest.json");
999 tokio::fs::write(
1000 &manifest_path,
1001 serde_json::to_vec_pretty(&manifest)
1002 .map_err(|err| CoreError::Internal(format!("manifest serialization failed: {err}")))?,
1003 )
1004 .await
1005 .map_err(|err| {
1006 CoreError::Internal(format!("cannot write {}: {err}", manifest_path.display()))
1007 })?;
1008
1009 Ok(SnapshotResult {
1010 dir: dir.display().to_string(),
1011 gwbk_bytes: meta.bytes,
1012 projects: exported.into_iter().map(|(name, _)| name).collect(),
1013 manifest_path: manifest_path.display().to_string(),
1014 })
1015}
1016
1017fn restore_deadline(wait_timeout_s: u64) -> u64 {
1022 wait_timeout_s.max(RESTORE_WAIT_FLOOR_S)
1023}
1024
1025pub async fn rig_restore(
1044 gateway: &dyn GatewayApi,
1045 rig_url: &str,
1046 gwbk: &Path,
1047 wait_timeout_s: u64,
1048) -> Result<RestoreResult, CoreError> {
1049 let meta = std::fs::metadata(gwbk).map_err(|_| CoreError::InvalidInput {
1055 reason: format!("gwbk file {} not found", gwbk.display()),
1056 })?;
1057 if !meta.is_file() {
1058 return Err(CoreError::InvalidInput {
1059 reason: format!("gwbk file {} is not a regular file", gwbk.display()),
1060 });
1061 }
1062 if meta.len() == 0 {
1063 return Err(CoreError::InvalidInput {
1064 reason: format!("gwbk file {} is empty", gwbk.display()),
1065 });
1066 }
1067
1068 gateway.backup_restore(gwbk).await?;
1071
1072 let deadline_s = restore_deadline(wait_timeout_s);
1074 let mut warnings = Vec::new();
1075 let state = commissioned_wait(gateway, rig_url, deadline_s, &mut warnings).await?;
1076
1077 warnings.insert(0, RESTORE_TOKEN_WARNING.to_string());
1079
1080 Ok(RestoreResult {
1081 restored_from: gwbk.display().to_string(),
1082 state,
1083 warnings,
1084 })
1085}
1086
1087pub async fn rig_status(
1092 runner: &dyn ComposeRunner,
1093 plan: &RigPlan,
1094) -> Result<RigStatusResult, CoreError> {
1095 compose_version(runner).await?;
1096
1097 let override_files: Vec<PathBuf> = existing_override(plan).into_iter().collect();
1100 let ps = runner.run(&ps_args(plan, &override_files)).await;
1101 let rows = parse_ps_ldjson(check_output(&ps, "docker compose ps")?);
1102
1103 let volume_ls = runner.run_docker(&volume_ls_args(&plan.name)).await;
1104 let volumes = parse_volume_ls_ldjson(check_output(&volume_ls, "docker volume ls")?)
1105 .into_iter()
1106 .map(|entry| entry.name)
1107 .collect();
1108
1109 let mut ports_free = true;
1112 for port in &plan.host_ports {
1113 let output = runner.run_docker(&docker_ps_publish_args(*port)).await;
1114 let occupants = parse_docker_ps_ldjson(check_output(&output, "docker ps")?);
1115 if !occupants.is_empty() {
1116 ports_free = false;
1117 }
1118 }
1119
1120 let services = rows
1121 .into_iter()
1122 .map(|row| StatusService {
1123 name: if row.service.is_empty() {
1124 row.name
1125 } else {
1126 row.service
1127 },
1128 state: row.state,
1129 health: row.health,
1130 exit_code: row.exit_code,
1131 publishers: row
1132 .publishers
1133 .into_iter()
1134 .map(|publisher| StatusPublisher {
1135 published_port: publisher.published_port,
1136 target_port: publisher.target_port,
1137 protocol: publisher.protocol,
1138 })
1139 .collect(),
1140 })
1141 .collect();
1142
1143 Ok(RigStatusResult {
1144 rig: plan.name.clone(),
1145 project: plan.name.clone(),
1146 compose_file: plan.compose_file.display().to_string(),
1147 services,
1148 volumes,
1149 ports_free,
1150 })
1151}
1152
1153#[cfg(test)]
1154mod tests {
1155 use std::collections::VecDeque;
1156 use std::path::{Path, PathBuf};
1157 use std::sync::Mutex;
1158
1159 use super::{
1160 DEFAULT_WAIT_TIMEOUT_S, RigDownResult, RigResetResult, RigStatusResult, RigUpResult,
1161 gateway_url_from, rig_down, rig_logs, rig_reset, rig_restore, rig_snapshot, rig_status,
1162 rig_up, trial_reset, trial_status,
1163 };
1164 use crate::client::GatewayApi;
1165 use crate::config::ModuleDeclaration;
1166 use crate::error::CoreError;
1167 use crate::rig::RigPlan;
1168 use crate::rig::compose::{
1169 ComposeOutput, ComposeRunner, PortMapping, down_args, logs_args, up_args, volume_ls_args,
1170 };
1171 use crate::rig::modules::{ModuleProvisioning, provision_modules, undeclared_provisioning};
1172
1173 #[derive(Default)]
1180 struct FakeRunner {
1181 calls: Mutex<Vec<(&'static str, Vec<String>)>>,
1182 outputs: Mutex<VecDeque<ComposeOutput>>,
1183 }
1184
1185 impl FakeRunner {
1186 fn with(outputs: Vec<ComposeOutput>) -> Self {
1187 Self {
1188 outputs: Mutex::new(outputs.into()),
1189 ..Self::default()
1190 }
1191 }
1192
1193 fn calls(&self) -> Vec<(&'static str, Vec<String>)> {
1194 self.calls.lock().unwrap().clone()
1195 }
1196 }
1197
1198 #[async_trait::async_trait]
1199 impl ComposeRunner for FakeRunner {
1200 async fn run(&self, args: &[String]) -> ComposeOutput {
1201 self.calls
1202 .lock()
1203 .unwrap()
1204 .push(("docker compose", args.to_vec()));
1205 self.outputs
1206 .lock()
1207 .unwrap()
1208 .pop_front()
1209 .expect("outputs exhausted")
1210 }
1211
1212 async fn run_docker(&self, args: &[String]) -> ComposeOutput {
1213 self.calls.lock().unwrap().push(("docker", args.to_vec()));
1214 self.outputs
1215 .lock()
1216 .unwrap()
1217 .pop_front()
1218 .expect("outputs exhausted")
1219 }
1220
1221 async fn run_streaming(
1222 &self,
1223 args: &[String],
1224 line_sink: &mut (dyn for<'a> FnMut(&'a str) + Send),
1225 ) -> ComposeOutput {
1226 self.calls
1227 .lock()
1228 .unwrap()
1229 .push(("docker compose", args.to_vec()));
1230 let output = self
1231 .outputs
1232 .lock()
1233 .unwrap()
1234 .pop_front()
1235 .expect("outputs exhausted");
1236 for line in output.stdout.lines() {
1240 line_sink(line);
1241 }
1242 ComposeOutput {
1243 stdout: String::new(),
1244 stderr: output.stderr,
1245 code: output.code,
1246 }
1247 }
1248 }
1249
1250 fn ok(stdout: &str) -> ComposeOutput {
1251 ComposeOutput {
1252 stdout: stdout.to_string(),
1253 stderr: String::new(),
1254 code: 0,
1255 }
1256 }
1257
1258 fn version_ok() -> ComposeOutput {
1259 ok("Docker Compose version v5.1.2\n")
1260 }
1261
1262 const OWN_OCCUPANT: &str =
1269 r#"{"Names":"fixture-rig-ignition-1","Labels":"com.docker.compose.project=fixture-rig"}"#;
1270
1271 fn free_ports_for_own_project() -> Vec<ComposeOutput> {
1275 vec![ok(OWN_OCCUPANT), ok(OWN_OCCUPANT)]
1276 }
1277
1278 fn up_cycle_outputs() -> Vec<ComposeOutput> {
1281 let mut outputs = vec![version_ok()];
1282 outputs.extend(free_ports_for_own_project());
1283 outputs.push(ok(""));
1284 outputs
1285 }
1286
1287 fn gw_plan() -> RigPlan {
1290 RigPlan {
1291 name: "fixture-rig".into(),
1292 config_name: None,
1293 compose_file: "/rigs/docker/compose.yml".into(),
1294 project_dir: "/rigs/docker".into(),
1295 services: vec!["ignition".into()],
1296 host_ports: vec![9088, 9443],
1297 port_mappings: vec![
1298 PortMapping {
1299 target: 8088,
1300 published: 9088,
1301 },
1302 PortMapping {
1303 target: 443,
1304 published: 9443,
1305 },
1306 ],
1307 volumes: vec!["gw-data".into()],
1308 gateway_service: Some("ignition".into()),
1309 modules: std::collections::BTreeMap::new(),
1310 }
1311 }
1312
1313 async fn status_ping_server(state: &str) -> wiremock::MockServer {
1315 let server = wiremock::MockServer::start().await;
1316 wiremock::Mock::given(wiremock::matchers::method("GET"))
1317 .and(wiremock::matchers::path("/StatusPing"))
1318 .respond_with(
1319 wiremock::ResponseTemplate::new(200)
1320 .set_body_json(serde_json::json!({ "state": state })),
1321 )
1322 .expect(1..)
1323 .mount(&server)
1324 .await;
1325 server
1326 }
1327
1328 async fn uncommissioned_server() -> wiremock::MockServer {
1332 let server = wiremock::MockServer::start().await;
1333 wiremock::Mock::given(wiremock::matchers::method("GET"))
1334 .and(wiremock::matchers::path("/StatusPing"))
1335 .respond_with(
1336 wiremock::ResponseTemplate::new(302).insert_header("Location", "/welcome"),
1337 )
1338 .expect(1..)
1339 .mount(&server)
1340 .await;
1341 server
1342 }
1343
1344 #[test]
1349 fn gateway_url_prefers_http_8088_then_https_443() {
1350 assert_eq!(
1351 gateway_url_from(&gw_plan()),
1352 Some("http://localhost:9088".to_string()),
1353 "the 8088 mapping wins even though 443 is also present"
1354 );
1355 let https_only = RigPlan {
1356 port_mappings: vec![PortMapping {
1357 target: 443,
1358 published: 9443,
1359 }],
1360 host_ports: vec![9443],
1361 ..gw_plan()
1362 };
1363 assert_eq!(
1364 gateway_url_from(&https_only),
1365 Some("https://localhost:9443".to_string())
1366 );
1367 let no_gateway = RigPlan {
1368 port_mappings: vec![PortMapping {
1369 target: 22,
1370 published: 9022,
1371 }],
1372 host_ports: vec![9022],
1373 ..gw_plan()
1374 };
1375 assert_eq!(gateway_url_from(&no_gateway), None);
1376 }
1377
1378 #[test]
1379 fn default_wait_timeout_is_300() {
1380 assert_eq!(DEFAULT_WAIT_TIMEOUT_S, 300, "research Pitfall 3 headroom");
1381 }
1382
1383 #[tokio::test]
1390 async fn up_success_probes_to_running() {
1391 let server = status_ping_server("RUNNING").await;
1392 let api = crate::client::ReqwestGatewayApi::for_tests(&server.uri(), None);
1393
1394 let runner = FakeRunner::with(up_cycle_outputs());
1395 let result = rig_up(
1396 &runner,
1397 &gw_plan(),
1398 300,
1399 Some(&api),
1400 &ModuleProvisioning::default(),
1401 )
1402 .await
1403 .expect("up succeeds");
1404 assert_eq!(result.rig, "fixture-rig");
1405 assert_eq!(result.state, "running");
1406 assert!(result.warnings.is_empty());
1407 assert_eq!(result.gateway_url.as_deref(), Some("http://localhost:9088"));
1408
1409 let calls = runner.calls();
1410 assert_eq!(calls[0], ("docker compose", vec!["version".to_string()]));
1411 assert_eq!(calls[1].0, "docker");
1413 assert_eq!(calls[2].0, "docker");
1414 assert_eq!(
1415 calls[3],
1416 ("docker compose", up_args(&gw_plan(), 300, &[])),
1417 "up rides the LOCKED arg shape"
1418 );
1419 }
1420
1421 #[tokio::test]
1431 async fn up_wires_override_file_as_second_f_argument() {
1432 let override_path = PathBuf::from("/rigs/docker/compose.ign-modules.yml");
1433 let provisioning = ModuleProvisioning {
1434 override_file: Some(override_path.clone()),
1435 modules: Vec::new(),
1436 orphaned_modules: Vec::new(),
1437 };
1438
1439 let runner = FakeRunner::with(up_cycle_outputs());
1440 let result = rig_up(&runner, &gw_plan(), 300, None, &provisioning)
1441 .await
1442 .expect("up succeeds with a provisioned override");
1443 assert!(result.provisioned_modules.is_empty());
1444
1445 let calls = runner.calls();
1446 assert_eq!(
1447 calls[3],
1448 (
1449 "docker compose",
1450 vec![
1451 "-p".to_string(),
1452 "fixture-rig".to_string(),
1453 "-f".to_string(),
1454 "/rigs/docker/compose.yml".to_string(),
1455 "-f".to_string(),
1456 "/rigs/docker/compose.ign-modules.yml".to_string(),
1457 "up".to_string(),
1458 "-d".to_string(),
1459 "--wait".to_string(),
1460 "--wait-timeout".to_string(),
1461 "300".to_string(),
1462 "--remove-orphans".to_string(),
1463 ]
1464 ),
1465 "override is the SECOND -f, base file first, up tail unchanged"
1466 );
1467 }
1468
1469 #[tokio::test]
1473 async fn up_uncommissioned_is_data_not_failure() {
1474 let server = uncommissioned_server().await;
1475 let api = crate::client::ReqwestGatewayApi::for_tests(&server.uri(), None);
1476
1477 let runner = FakeRunner::with(up_cycle_outputs());
1478 let result = rig_up(
1479 &runner,
1480 &gw_plan(),
1481 1,
1482 Some(&api),
1483 &ModuleProvisioning::default(),
1484 )
1485 .await
1486 .expect("uncommissioned is exit-0 data");
1487 assert_eq!(result.state, "uncommissioned");
1488 assert_eq!(result.gateway_url.as_deref(), Some("http://localhost:9088"));
1489 assert!(
1490 result
1491 .warnings
1492 .iter()
1493 .any(|warning| warning.contains("http://localhost:9088/welcome")),
1494 "wizard URL in warnings: {:?}",
1495 result.warnings
1496 );
1497 }
1498
1499 #[tokio::test]
1501 async fn up_still_starting_at_deadline_is_rig_error() {
1502 let server = status_ping_server("STARTING").await;
1503 let api = crate::client::ReqwestGatewayApi::for_tests(&server.uri(), None);
1504
1505 let runner = FakeRunner::with(up_cycle_outputs());
1506 let err = rig_up(
1507 &runner,
1508 &gw_plan(),
1509 1,
1510 Some(&api),
1511 &ModuleProvisioning::default(),
1512 )
1513 .await
1514 .expect_err("still-STARTING deadline errors");
1515 assert!(matches!(err, CoreError::Rig(_)));
1516 assert_eq!(err.exit_code(), 7);
1517 let message = err.to_string();
1518 assert!(message.contains("did not reach RUNNING"), "{message}");
1519 assert!(
1520 message.contains("STARTING"),
1521 "last observation named: {message}"
1522 );
1523 }
1524
1525 #[tokio::test]
1527 async fn up_port_conflict_aborts_with_attribution() {
1528 let occupant = r#"{"Names":"other-gw-1","Labels":"com.docker.compose.project=other"}"#;
1529 let runner = FakeRunner::with(vec![version_ok(), ok(occupant), ok(occupant)]);
1530
1531 let err = rig_up(
1532 &runner,
1533 &gw_plan(),
1534 300,
1535 None,
1536 &ModuleProvisioning::default(),
1537 )
1538 .await
1539 .expect_err("cross-project occupant aborts");
1540 let message = err.to_string();
1541 assert!(
1542 message.contains("port 9088 in use by container other-gw-1 (rig other)"),
1543 "{message}"
1544 );
1545 let calls = runner.calls();
1547 assert_eq!(
1548 calls.len(),
1549 3,
1550 "version + two port checks only, no up: {calls:?}"
1551 );
1552 }
1553
1554 #[tokio::test]
1557 async fn up_without_probe_skips_wait_with_warning() {
1558 let runner = FakeRunner::with(up_cycle_outputs());
1559 let result = rig_up(
1560 &runner,
1561 &gw_plan(),
1562 300,
1563 None,
1564 &ModuleProvisioning::default(),
1565 )
1566 .await
1567 .expect("up succeeds without a probe");
1568 assert_eq!(result.state, "running");
1569 assert!(
1570 result
1571 .warnings
1572 .iter()
1573 .any(|warning| warning.contains("skipped the commissioned wait")),
1574 "{:?}",
1575 result.warnings
1576 );
1577 }
1578
1579 #[tokio::test]
1581 async fn up_missing_compose_fails_fast() {
1582 let missing = ComposeOutput {
1583 stdout: String::new(),
1584 stderr: "docker: command not found".into(),
1585 code: 127,
1586 };
1587 let runner = FakeRunner::with(vec![missing]);
1588 let err = rig_up(
1589 &runner,
1590 &gw_plan(),
1591 300,
1592 None,
1593 &ModuleProvisioning::default(),
1594 )
1595 .await
1596 .expect_err("no docker errors");
1597 let message = err.to_string();
1598 assert!(
1599 message.contains("docker compose is unavailable"),
1600 "{message}"
1601 );
1602 assert!(message.contains("not supported"), "{message}");
1603 }
1604
1605 #[tokio::test]
1610 async fn down_runs_exact_args_and_reports_down() {
1611 let runner = FakeRunner::with(vec![version_ok(), ok("")]);
1612 let result = rig_down(&runner, &gw_plan()).await.expect("down succeeds");
1613 assert_eq!(
1614 serde_json::to_value(&result).unwrap(),
1615 serde_json::json!({
1616 "rig": "fixture-rig",
1617 "project": "fixture-rig",
1618 "state": "down",
1619 }),
1620 "RigDownResult shape (all keys always)"
1621 );
1622 let calls = runner.calls();
1623 assert_eq!(
1624 calls[1],
1625 ("docker compose", down_args(&gw_plan(), false, &[]))
1626 );
1627 }
1628
1629 #[tokio::test]
1630 async fn down_failure_carries_stderr_tail() {
1631 let runner = FakeRunner::with(vec![
1632 version_ok(),
1633 ComposeOutput {
1634 stdout: String::new(),
1635 stderr: "error while removing network: active endpoints".into(),
1636 code: 1,
1637 },
1638 ]);
1639 let err = rig_down(&runner, &gw_plan())
1640 .await
1641 .expect_err("down failure errors");
1642 let message = err.to_string();
1643 assert!(
1644 message.contains("docker compose down failed (exit 1)"),
1645 "{message}"
1646 );
1647 assert!(message.contains("active endpoints"), "{message}");
1648 }
1649
1650 const RESET_VOLUME_STDOUT: &str = concat!(
1658 r#"{"Name":"fixture-rig_gw-data","Labels":{"com.docker.compose.project":"fixture-rig"}}"#,
1659 "\n",
1660 r#"{"Name":"other-rig_gw-data","Labels":{"com.docker.compose.project":"other-rig"}}"#,
1661 "\n",
1662 );
1663
1664 fn reset_cycle_outputs() -> Vec<ComposeOutput> {
1668 let mut outputs = vec![ok(RESET_VOLUME_STDOUT), version_ok(), ok("")];
1669 outputs.extend(free_ports_for_own_project());
1670 outputs.push(ok(""));
1671 outputs
1672 }
1673
1674 #[tokio::test]
1679 async fn reset_previews_tears_down_with_v_then_brings_up() {
1680 let server = status_ping_server("RUNNING").await;
1681 let api = crate::client::ReqwestGatewayApi::for_tests(&server.uri(), None);
1682
1683 let runner = FakeRunner::with(reset_cycle_outputs());
1684 let result = rig_reset(
1685 &runner,
1686 &gw_plan(),
1687 300,
1688 Some(&api),
1689 &ModuleProvisioning::default(),
1690 )
1691 .await
1692 .expect("reset succeeds");
1693 assert_eq!(result.rig, "fixture-rig");
1694 assert_eq!(result.removed_volumes, vec!["fixture-rig_gw-data"]);
1695 assert_eq!(result.state, "running");
1696 assert!(result.warnings.is_empty());
1697
1698 let calls = runner.calls();
1700 assert_eq!(calls.len(), 6, "exactly the six scripted calls: {calls:?}");
1701 assert_eq!(
1702 calls[0],
1703 ("docker", volume_ls_args("fixture-rig")),
1704 "preview rides the plain-docker volume ls shape"
1705 );
1706 assert_eq!(calls[1], ("docker compose", vec!["version".to_string()]));
1707 assert_eq!(
1710 calls[2],
1711 (
1712 "docker compose",
1713 vec![
1714 "-p".to_string(),
1715 "fixture-rig".to_string(),
1716 "-f".to_string(),
1717 "/rigs/docker/compose.yml".to_string(),
1718 "down".to_string(),
1719 "--remove-orphans".to_string(),
1720 "-v".to_string(),
1721 ],
1722 ),
1723 "down -v --remove-orphans via the runner seam"
1724 );
1725 assert_eq!(calls[3].0, "docker");
1727 assert_eq!(calls[4].0, "docker");
1728 assert_eq!(calls[5], ("docker compose", up_args(&gw_plan(), 300, &[])));
1729 }
1730
1731 #[tokio::test]
1734 async fn reset_uncommissioned_fresh_volume_is_data() {
1735 let server = uncommissioned_server().await;
1736 let api = crate::client::ReqwestGatewayApi::for_tests(&server.uri(), None);
1737
1738 let runner = FakeRunner::with(reset_cycle_outputs());
1739 let result = rig_reset(
1740 &runner,
1741 &gw_plan(),
1742 1,
1743 Some(&api),
1744 &ModuleProvisioning::default(),
1745 )
1746 .await
1747 .expect("uncommissioned reset is exit-0 data");
1748 assert_eq!(result.state, "uncommissioned");
1749 assert_eq!(result.removed_volumes, vec!["fixture-rig_gw-data"]);
1750 assert!(
1751 result
1752 .warnings
1753 .iter()
1754 .any(|warning| warning.contains("http://localhost:9088/welcome")),
1755 "wizard URL in warnings: {:?}",
1756 result.warnings
1757 );
1758 }
1759
1760 #[tokio::test]
1763 async fn reset_port_regrabbed_midcycle_errors_and_never_ups() {
1764 let occupant = r#"{"Names":"other-gw-1","Labels":"com.docker.compose.project=other"}"#;
1765 let runner = FakeRunner::with(vec![
1766 ok(""), version_ok(),
1768 ok(""), ok(occupant), ok(OWN_OCCUPANT), ]);
1772 let err = rig_reset(
1773 &runner,
1774 &gw_plan(),
1775 300,
1776 None,
1777 &ModuleProvisioning::default(),
1778 )
1779 .await
1780 .expect_err("mid-cycle port grab aborts before the up half");
1781 assert!(matches!(err, CoreError::Rig(_)));
1782 assert_eq!(err.exit_code(), 7);
1783 let message = err.to_string();
1784 assert!(
1785 message.contains("port 9088 in use by container other-gw-1 (rig other)"),
1786 "{message}"
1787 );
1788 assert!(
1789 message.contains("torn down"),
1790 "the hint names the torn-down state: {message}"
1791 );
1792 let calls = runner.calls();
1794 assert_eq!(calls.len(), 5, "no up call: {calls:?}");
1795 assert_eq!(calls.last().expect("calls exist").0, "docker");
1796 }
1797
1798 #[tokio::test]
1800 async fn reset_down_failure_carries_stderr_tail() {
1801 let runner = FakeRunner::with(vec![
1802 ok(""),
1803 version_ok(),
1804 ComposeOutput {
1805 stdout: String::new(),
1806 stderr: "cannot remove volume: in use".into(),
1807 code: 1,
1808 },
1809 ]);
1810 let err = rig_reset(
1811 &runner,
1812 &gw_plan(),
1813 300,
1814 None,
1815 &ModuleProvisioning::default(),
1816 )
1817 .await
1818 .expect_err("down -v failure errors");
1819 let message = err.to_string();
1820 assert!(
1821 message.contains("docker compose down failed (exit 1)"),
1822 "{message}"
1823 );
1824 assert!(message.contains("in use"), "{message}");
1825 }
1826
1827 const LOGS_STDOUT: &str = concat!(
1834 "ignition-1 | 22:01:01.001 INFO Gateway - starting\n",
1835 "ignition-1 | 22:01:02.002 INFO Gateway - RUNNING\n",
1836 );
1837
1838 #[tokio::test]
1841 async fn logs_one_shot_sinks_lines_verbatim() {
1842 let runner = FakeRunner::with(vec![ok(LOGS_STDOUT)]);
1843 let mut received: Vec<String> = Vec::new();
1844 let result = rig_logs(&runner, &gw_plan(), 200, false, None, &mut |line| {
1845 received.push(line)
1846 })
1847 .await
1848 .expect("logs succeeds");
1849 assert_eq!(result.streamed, 2);
1850 assert_eq!(
1851 received,
1852 vec![
1853 "ignition-1 | 22:01:01.001 INFO Gateway - starting",
1854 "ignition-1 | 22:01:02.002 INFO Gateway - RUNNING",
1855 ],
1856 "lines pass through verbatim — no envelope wrapping ever"
1857 );
1858 let calls = runner.calls();
1859 assert_eq!(
1860 calls,
1861 vec![(
1862 "docker compose",
1863 logs_args(&gw_plan(), 200, false, None, &[])
1864 )]
1865 );
1866 }
1867
1868 #[tokio::test]
1871 async fn logs_follow_streams_via_the_streaming_seam() {
1872 let runner = FakeRunner::with(vec![ok(LOGS_STDOUT)]);
1873 let mut received: Vec<String> = Vec::new();
1874 let result = rig_logs(
1875 &runner,
1876 &gw_plan(),
1877 50,
1878 true,
1879 Some("ignition"),
1880 &mut |line| received.push(line),
1881 )
1882 .await
1883 .expect("follow logs succeeds");
1884 assert_eq!(result.streamed, 2, "streamed lines counted in follow mode");
1885 assert_eq!(received.len(), 2);
1886 let calls = runner.calls();
1887 assert_eq!(
1888 calls,
1889 vec![(
1890 "docker compose",
1891 logs_args(&gw_plan(), 50, true, Some("ignition"), &[])
1892 )]
1893 );
1894 }
1895
1896 #[tokio::test]
1899 async fn logs_failure_carries_stderr_tail_never_sink() {
1900 let runner = FakeRunner::with(vec![ComposeOutput {
1901 stdout: String::new(),
1902 stderr: "no such service: nosvc".into(),
1903 code: 1,
1904 }]);
1905 let mut received: Vec<String> = Vec::new();
1906 let err = rig_logs(
1907 &runner,
1908 &gw_plan(),
1909 200,
1910 false,
1911 Some("nosvc"),
1912 &mut |line| received.push(line),
1913 )
1914 .await
1915 .expect_err("unknown service errors");
1916 let message = err.to_string();
1917 assert!(
1918 message.contains("docker compose logs failed (exit 1)"),
1919 "{message}"
1920 );
1921 assert!(message.contains("no such service"), "{message}");
1922 assert!(received.is_empty(), "diagnostics never ride the data sink");
1923 }
1924
1925 async fn expired_trial_server() -> wiremock::MockServer {
1932 let server = wiremock::MockServer::start().await;
1933 wiremock::Mock::given(wiremock::matchers::method("GET"))
1934 .and(wiremock::matchers::path("/data/api/v1/trial"))
1935 .respond_with(
1936 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
1937 "licenseMode": "Trial", "trialState": "AllInDemo",
1938 "trialSecondsLeft": 0, "expired": true,
1939 "emergency": false, "emergencySecondsLeft": 0,
1940 "development": false, "developmentSecondsLeft": 0
1941 })),
1942 )
1943 .mount(&server)
1944 .await;
1945 wiremock::Mock::given(wiremock::matchers::method("GET"))
1946 .and(wiremock::matchers::path("/data/api/v1/overview/banners"))
1947 .respond_with(
1948 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
1949 "banners": [{
1950 "order": 0, "type": "trial",
1951 "data": { "severity": "warning", "expireTime": null,
1952 "toolTips": [], "actions": [] }
1953 }]
1954 })),
1955 )
1956 .mount(&server)
1957 .await;
1958 server
1959 }
1960
1961 #[tokio::test]
1964 async fn trial_status_expired_shape_is_exact() {
1965 let server = expired_trial_server().await;
1966 let api = crate::client::ReqwestGatewayApi::for_tests(&server.uri(), None);
1967 let result = trial_status(&api).await.expect("expired status parses");
1968 assert_eq!(
1969 serde_json::to_value(&result).unwrap(),
1970 serde_json::json!({
1971 "license_mode": "Trial",
1972 "trial_state": "AllInDemo",
1973 "trial_remaining_s": 0,
1974 "expired": true,
1975 "emergency": false,
1976 "emergency_remaining_s": 0,
1977 "development": false,
1978 "banners": {
1979 "severity": "warning",
1980 "expire_time_ms": null,
1981 "active": false
1982 },
1983 "warnings": []
1984 }),
1985 "EXACT shape comparison — the unit-explicit keys + the \
1986 banners cross-check block, no unknown keys"
1987 );
1988 }
1989
1990 #[tokio::test]
1995 async fn trial_status_banner_active_requires_future_expire_time() {
1996 for (expire_time, active) in [(9_999_999_999_999_999i64, true), (1i64, false)] {
1997 let server = wiremock::MockServer::start().await;
1998 wiremock::Mock::given(wiremock::matchers::method("GET"))
1999 .and(wiremock::matchers::path("/data/api/v1/trial"))
2000 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(
2001 serde_json::json!({
2002 "licenseMode": "Trial", "trialState": "AllInDemo",
2003 "trialSecondsLeft": 6590, "expired": false,
2004 "emergency": false, "emergencySecondsLeft": 0,
2005 "development": false, "developmentSecondsLeft": 0
2006 }),
2007 ))
2008 .mount(&server)
2009 .await;
2010 wiremock::Mock::given(wiremock::matchers::method("GET"))
2011 .and(wiremock::matchers::path("/data/api/v1/overview/banners"))
2012 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(
2013 serde_json::json!({
2014 "banners": [{
2015 "order": 5, "type": "trial",
2016 "data": { "severity": "info",
2017 "expireTime": expire_time,
2018 "toolTips": [], "actions": [] }
2019 }]
2020 }),
2021 ))
2022 .mount(&server)
2023 .await;
2024 let api = crate::client::ReqwestGatewayApi::for_tests(&server.uri(), None);
2025 let result = trial_status(&api).await.expect("active status parses");
2026 assert!(!result.expired, "primary truth from the trial endpoint");
2027 assert_eq!(result.trial_remaining_s, 6590);
2028 assert_eq!(
2029 result.banners.severity.as_deref(),
2030 Some("info"),
2031 "the trial banner surfaced (8.3.3 serves order 5 — not an index)"
2032 );
2033 assert_eq!(
2034 result.banners.active, active,
2035 "info severity + expireTime {expire_time} → active {active} (Pitfall 7)"
2036 );
2037 }
2038 }
2039
2040 #[tokio::test]
2043 async fn trial_status_banners_failure_degrades_with_warning() {
2044 let server = wiremock::MockServer::start().await;
2045 wiremock::Mock::given(wiremock::matchers::method("GET"))
2046 .and(wiremock::matchers::path("/data/api/v1/trial"))
2047 .respond_with(
2048 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
2049 "licenseMode": "Trial", "trialState": "AllInDemo",
2050 "trialSecondsLeft": 0, "expired": true,
2051 "emergency": false, "emergencySecondsLeft": 0,
2052 "development": false, "developmentSecondsLeft": 0
2053 })),
2054 )
2055 .mount(&server)
2056 .await;
2057 wiremock::Mock::given(wiremock::matchers::method("GET"))
2058 .and(wiremock::matchers::path("/data/api/v1/overview/banners"))
2059 .respond_with(wiremock::ResponseTemplate::new(500))
2060 .mount(&server)
2061 .await;
2062 let api = crate::client::ReqwestGatewayApi::for_tests(&server.uri(), None);
2063 let result = trial_status(&api).await.expect("primary endpoint answered");
2064 assert!(
2065 result.expired,
2066 "primary truth survives the cross-check failure"
2067 );
2068 assert_eq!(result.banners.severity, None);
2069 assert_eq!(result.banners.expire_time_ms, None);
2070 assert!(!result.banners.active);
2071 assert!(
2072 result
2073 .warnings
2074 .iter()
2075 .any(|warning| warning.contains("banners cross-check unavailable")),
2076 "the degradation is visible data: {:?}",
2077 result.warnings
2078 );
2079 }
2080
2081 fn trial_body(expired: bool, seconds_left: i64) -> serde_json::Value {
2087 serde_json::json!({
2088 "licenseMode": "Trial", "trialState": "AllInDemo",
2089 "trialSecondsLeft": seconds_left, "expired": expired,
2090 "emergency": false, "emergencySecondsLeft": 0,
2091 "development": false, "developmentSecondsLeft": 0
2092 })
2093 }
2094
2095 #[derive(Clone)]
2101 struct TrialFlipScript {
2102 reset_done: std::sync::Arc<std::sync::atomic::AtomicBool>,
2103 post_status: u16,
2104 }
2105
2106 impl wiremock::Respond for TrialFlipScript {
2107 fn respond(&self, request: &wiremock::Request) -> wiremock::ResponseTemplate {
2108 if request.method.as_str() == "POST" {
2109 if self.post_status == 200 {
2110 self.reset_done
2111 .store(true, std::sync::atomic::Ordering::SeqCst);
2112 return wiremock::ResponseTemplate::new(200)
2113 .set_body_json(trial_body(false, 7199));
2114 }
2115 return wiremock::ResponseTemplate::new(self.post_status);
2116 }
2117 let expired = !self.reset_done.load(std::sync::atomic::Ordering::SeqCst);
2118 wiremock::ResponseTemplate::new(200)
2119 .set_body_json(trial_body(expired, if expired { 0 } else { 7199 }))
2120 }
2121 }
2122
2123 async fn trial_reset_server(post_status: u16) -> (wiremock::MockServer, TrialFlipScript) {
2126 let server = wiremock::MockServer::start().await;
2127 let script = TrialFlipScript {
2128 reset_done: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
2129 post_status,
2130 };
2131 wiremock::Mock::given(wiremock::matchers::method("GET"))
2132 .and(wiremock::matchers::path("/data/api/v1/trial"))
2133 .respond_with(script.clone())
2134 .mount(&server)
2135 .await;
2136 wiremock::Mock::given(wiremock::matchers::method("POST"))
2137 .and(wiremock::matchers::path("/data/api/v1/trial"))
2138 .respond_with(script.clone())
2139 .mount(&server)
2140 .await;
2141 (server, script)
2142 }
2143
2144 #[tokio::test]
2148 async fn trial_reset_refuses_active_trial_up_front() {
2149 let (server, script) = trial_reset_server(200).await;
2150 script
2153 .reset_done
2154 .store(true, std::sync::atomic::Ordering::SeqCst);
2155 let api = crate::client::ReqwestGatewayApi::for_tests(&server.uri(), None);
2156 let err = trial_reset(&api, &server.uri(), false, None)
2157 .await
2158 .expect_err("an active trial is refused before any POST");
2159 assert!(matches!(err, CoreError::TrialNotExpired { .. }), "{err}");
2160 assert_eq!(err.exit_code(), 6);
2161 assert_eq!(err.code(), "trial_not_expired");
2162 let message = err.to_string();
2163 assert!(
2164 message.contains("7199s left"),
2165 "the message names the countdown: {message}"
2166 );
2167 }
2168
2169 #[tokio::test]
2172 async fn trial_reset_tier0_lands_with_read_back_flip() {
2173 let (server, _script) = trial_reset_server(200).await;
2174 let credential = crate::config::Credential::Token(crate::config::Secret::new(
2175 "spike:tokengeneratedlive",
2176 ));
2177 let api = crate::client::ReqwestGatewayApi::for_tests(&server.uri(), Some(credential));
2178 let result = trial_reset(&api, &server.uri(), true, None)
2179 .await
2180 .expect("tier 0 resets the expired trial");
2181 assert_eq!(
2182 serde_json::to_value(&result).unwrap(),
2183 serde_json::json!({
2184 "rig_url": server.uri(),
2185 "mechanism": "token",
2186 "expired_before": true,
2187 "expired_after": false,
2188 "trial_remaining_s": 7199
2189 }),
2190 "EXACT shape — which rung landed + the before/after flip"
2191 );
2192 }
2193
2194 #[tokio::test]
2198 async fn trial_reset_token_refused_without_login_errors() {
2199 let (server, _script) = trial_reset_server(401).await;
2200 let credential =
2201 crate::config::Credential::Token(crate::config::Secret::new("spike:wrongtoken"));
2202 let api = crate::client::ReqwestGatewayApi::for_tests(&server.uri(), Some(credential));
2203 let err = trial_reset(&api, &server.uri(), true, None)
2204 .await
2205 .expect_err("the refused token rung has no fallback");
2206 assert!(matches!(err, CoreError::SecretUnavailable { .. }), "{err}");
2207 assert_eq!(err.exit_code(), 3);
2208 }
2209
2210 async fn login_dance_server() -> (wiremock::MockServer, TrialFlipScript) {
2218 let (server, script) = trial_reset_server(401).await;
2219 wiremock::Mock::given(wiremock::matchers::method("GET"))
2220 .and(wiremock::matchers::path("/data/app/login"))
2221 .respond_with(wiremock::ResponseTemplate::new(302).insert_header(
2222 "Location",
2223 "/idp/default/oidc/auth?app=gateway&state=st&nonce=nc",
2224 ))
2225 .mount(&server)
2226 .await;
2227 wiremock::Mock::given(wiremock::matchers::method("GET"))
2228 .and(wiremock::matchers::path("/idp/default/oidc/auth"))
2229 .and(wiremock::matchers::query_param_is_missing("token"))
2230 .respond_with(
2231 wiremock::ResponseTemplate::new(302)
2232 .insert_header("Location", "/idp/default/authn/login?app=gateway&token=TT0"),
2233 )
2234 .mount(&server)
2235 .await;
2236 for (body_token, answer) in [
2237 (
2238 "TT0",
2239 r#"{"complete":false,"nextChallenge":[{"type":"basic"}],"token":"TT1"}"#,
2240 ),
2241 ("TT2", r#"{"complete":true,"token":"TT3"}"#),
2242 ] {
2243 wiremock::Mock::given(wiremock::matchers::method("POST"))
2244 .and(wiremock::matchers::path(
2245 "/idp/default/authn/next-challenge",
2246 ))
2247 .and(wiremock::matchers::body_json(
2248 serde_json::json!({ "token": body_token }),
2249 ))
2250 .respond_with(
2251 wiremock::ResponseTemplate::new(200)
2252 .set_body_string(answer)
2253 .insert_header("Content-Type", "application/json"),
2254 )
2255 .mount(&server)
2256 .await;
2257 }
2258 wiremock::Mock::given(wiremock::matchers::method("POST"))
2259 .and(wiremock::matchers::path(
2260 "/idp/default/authn/submit-challenge/basic",
2261 ))
2262 .respond_with(
2263 wiremock::ResponseTemplate::new(200)
2264 .set_body_string(r#"{"success":true,"token":"TT2"}"#)
2265 .insert_header("Content-Type", "application/json"),
2266 )
2267 .mount(&server)
2268 .await;
2269 wiremock::Mock::given(wiremock::matchers::method("GET"))
2270 .and(wiremock::matchers::path("/idp/default/oidc/auth"))
2271 .and(wiremock::matchers::query_param("token", "TT3"))
2272 .respond_with(wiremock::ResponseTemplate::new(302).insert_header(
2273 "Location",
2274 "/data/federate/callback/internal?code=c&state=st",
2275 ))
2276 .mount(&server)
2277 .await;
2278 wiremock::Mock::given(wiremock::matchers::method("GET"))
2279 .and(wiremock::matchers::path("/data/federate/callback/internal"))
2280 .respond_with(
2281 wiremock::ResponseTemplate::new(302)
2282 .insert_header("Location", "/app")
2283 .append_header("Set-Cookie", "webui-sid-1=sess; Path=/; HttpOnly"),
2284 )
2285 .mount(&server)
2286 .await;
2287 wiremock::Mock::given(wiremock::matchers::method("GET"))
2288 .and(wiremock::matchers::path("/data/app/session"))
2289 .respond_with(
2290 wiremock::ResponseTemplate::new(200)
2291 .set_body_string(r#"{"userPayload":{},"csrfToken":"csrf1"}"#)
2292 .insert_header("Content-Type", "application/json"),
2293 )
2294 .mount(&server)
2295 .await;
2296 wiremock::Mock::given(wiremock::matchers::method("POST"))
2297 .and(wiremock::matchers::path("/data/api/v1/trial"))
2298 .and(wiremock::matchers::header("X-CSRF-Token", "csrf1"))
2299 .respond_with(SessionResetFlip {
2300 reset_done: script.reset_done.clone(),
2301 })
2302 .with_priority(1)
2303 .mount(&server)
2304 .await;
2305 (server, script)
2306 }
2307
2308 struct SessionResetFlip {
2311 reset_done: std::sync::Arc<std::sync::atomic::AtomicBool>,
2312 }
2313
2314 impl wiremock::Respond for SessionResetFlip {
2315 fn respond(&self, _request: &wiremock::Request) -> wiremock::ResponseTemplate {
2316 self.reset_done
2317 .store(true, std::sync::atomic::Ordering::SeqCst);
2318 wiremock::ResponseTemplate::new(200).set_body_json(trial_body(false, 7199))
2319 }
2320 }
2321
2322 #[tokio::test]
2326 async fn trial_reset_falls_through_to_the_login_rung() {
2327 let (server, _script) = login_dance_server().await;
2328 let credential =
2329 crate::config::Credential::Token(crate::config::Secret::new("spike:rejectedtoken"));
2330 let api = crate::client::ReqwestGatewayApi::for_tests(&server.uri(), Some(credential));
2331 let password = crate::config::Secret::new("rig-password");
2332 let result = trial_reset(&api, &server.uri(), true, Some(("admin", &password)))
2333 .await
2334 .expect("the login rung carries the reset");
2335 assert_eq!(result.mechanism, "login");
2336 assert!(result.expired_before);
2337 assert!(!result.expired_after);
2338 assert_eq!(result.trial_remaining_s, 7199);
2339 }
2340
2341 #[tokio::test]
2344 async fn trial_reset_login_rung_alone() {
2345 let (server, _script) = login_dance_server().await;
2346 let api = crate::client::ReqwestGatewayApi::for_tests(&server.uri(), None);
2347 let password = crate::config::Secret::new("rig-password");
2348 let result = trial_reset(&api, &server.uri(), false, Some(("admin", &password)))
2349 .await
2350 .expect("login-only reset works");
2351 assert_eq!(result.mechanism, "login");
2352 assert!(!result.expired_after);
2353 }
2354
2355 fn orphan_plan(dir: &Path) -> RigPlan {
2364 RigPlan {
2365 compose_file: dir.join("compose.yml"),
2366 project_dir: dir.to_path_buf(),
2367 ..gw_plan()
2368 }
2369 }
2370
2371 fn seed_two_mount_override(plan: &RigPlan) -> PathBuf {
2378 use crate::module::fetch::ArtifactSource;
2379 use crate::module::{GIT_MODULE, PROJECT_SCAN_ENDPOINT};
2380 use crate::rig::modules::{MountedModule, write_override};
2381
2382 let mounts = [
2383 MountedModule {
2384 spec: &GIT_MODULE,
2385 version: "2.3.4".to_string(),
2386 source: PathBuf::from("/cache/modules/git/2.3.4-deadbeef.modl"),
2387 source_kind: ArtifactSource::Cache,
2388 },
2389 MountedModule {
2390 spec: &PROJECT_SCAN_ENDPOINT,
2391 version: "1.0.0".to_string(),
2392 source: PathBuf::from("/cache/modules/project-scan-endpoint/1.0.0-cafebabe.modl"),
2393 source_kind: ArtifactSource::Cache,
2394 },
2395 ];
2396 write_override(plan, "ignition", &mounts)
2397 .expect("seed a real two-mount override")
2398 .expect("mounts were non-empty")
2399 }
2400
2401 fn seed_cache_entry(root: &Path, id: &str, version: &str, contents: &[u8]) {
2403 use sha2::{Digest, Sha256};
2404 let dir = root.join("modules").join(id);
2405 std::fs::create_dir_all(&dir).expect("cache dir");
2406 let digest = format!("{:x}", Sha256::digest(contents));
2407 std::fs::write(dir.join(format!("{version}-{digest}.modl")), contents)
2408 .expect("seed cached artifact");
2409 }
2410
2411 fn declared(entries: &[(&str, &str)]) -> std::collections::BTreeMap<String, ModuleDeclaration> {
2412 entries
2413 .iter()
2414 .map(|(id, version)| {
2415 (
2416 id.to_string(),
2417 ModuleDeclaration {
2418 version: version.to_string(),
2419 },
2420 )
2421 })
2422 .collect()
2423 }
2424
2425 fn orphan_ids(provisioning: &ModuleProvisioning) -> Vec<&str> {
2426 provisioning
2427 .orphaned_modules
2428 .iter()
2429 .map(|o| o.id.as_str())
2430 .collect()
2431 }
2432
2433 #[tokio::test]
2436 #[should_panic(expected = "rig up must never uninstall a module")]
2437 async fn the_uninstall_tripwire_actually_panics() {
2438 let rig = SnapshotRig::default();
2439 let _ = rig
2440 .uninstall_module("com.axone_io.ignition.git", "git")
2441 .await;
2442 }
2443
2444 #[tokio::test]
2448 async fn up_after_undeclaring_reports_orphans_and_issues_no_uninstall() {
2449 let dir = tempfile::tempdir().expect("tempdir");
2450 let plan = orphan_plan(dir.path());
2451 let override_path = seed_two_mount_override(&plan);
2452
2453 let provisioning = undeclared_provisioning(&plan).expect("undeclared provisioning");
2454 assert_eq!(
2455 orphan_ids(&provisioning),
2456 vec!["git", "project-scan-endpoint"],
2457 "both undeclared modules are reported"
2458 );
2459 assert!(!override_path.exists(), "the stale override is deleted");
2460
2461 let rig = SnapshotRig::default();
2462 let runner = FakeRunner::with(up_cycle_outputs());
2463 let result = rig_up(&runner, &plan, 300, Some(&rig), &provisioning)
2464 .await
2465 .expect("rig up completes without touching the gateway's module list");
2466 assert_eq!(result.state, "running");
2467 assert_eq!(
2471 result
2472 .orphaned_modules
2473 .iter()
2474 .map(|o| o.id.as_str())
2475 .collect::<Vec<_>>(),
2476 vec!["git", "project-scan-endpoint"],
2477 "rig_up copies the orphan report into RigUpResult"
2478 );
2479 let json = serde_json::to_value(&result).expect("serialise");
2480 assert_eq!(json["orphaned_modules"][0]["id"], "git");
2481 assert_eq!(
2482 json["orphaned_modules"][0]["remove_with"],
2483 "ign rig module uninstall git --yes"
2484 );
2485 assert!(
2486 !rig.calls()
2487 .iter()
2488 .any(|c| c.starts_with("uninstall_module")),
2489 "no uninstall was attempted: {:?}",
2490 rig.calls()
2491 );
2492 }
2493
2494 #[tokio::test]
2498 async fn up_after_dropping_one_module_reports_only_that_one_and_issues_no_uninstall() {
2499 let dir = tempfile::tempdir().expect("tempdir");
2500 let plan = orphan_plan(dir.path());
2501 seed_two_mount_override(&plan);
2502
2503 let cache = tempfile::tempdir().expect("cache");
2504 seed_cache_entry(cache.path(), "git", "2.3.4", b"git payload");
2505 let feed = crate::module::fetch::ModuleFeed::for_base(
2506 url::Url::parse("http://127.0.0.1:1").expect("literal URL"),
2507 )
2508 .expect("feed");
2509
2510 let provisioning = provision_modules(
2511 &feed,
2512 &plan,
2513 &declared(&[("git", "2.3.4")]),
2514 cache.path(),
2515 crate::module::fetch::FetchPolicy::CacheFirst,
2516 )
2517 .await
2518 .expect("provisioning with git still declared");
2519 assert_eq!(orphan_ids(&provisioning), vec!["project-scan-endpoint"]);
2520
2521 let rig = SnapshotRig::default();
2522 let runner = FakeRunner::with(up_cycle_outputs());
2523 let result = rig_up(&runner, &plan, 300, Some(&rig), &provisioning)
2524 .await
2525 .expect("rig up completes");
2526 assert_eq!(
2527 result
2528 .orphaned_modules
2529 .iter()
2530 .map(|o| o.id.as_str())
2531 .collect::<Vec<_>>(),
2532 vec!["project-scan-endpoint"],
2533 "RigUpResult carries exactly the dropped module"
2534 );
2535 assert!(
2536 !rig.calls()
2537 .iter()
2538 .any(|c| c.starts_with("uninstall_module"))
2539 );
2540 }
2541
2542 #[tokio::test]
2546 async fn up_with_no_module_changes_reports_no_orphans_and_issues_no_uninstall() {
2547 let dir = tempfile::tempdir().expect("tempdir");
2548 let plan = orphan_plan(dir.path());
2549 let override_path = seed_two_mount_override(&plan);
2550
2551 let cache = tempfile::tempdir().expect("cache");
2552 seed_cache_entry(cache.path(), "git", "2.3.4", b"git payload");
2553 seed_cache_entry(
2554 cache.path(),
2555 "project-scan-endpoint",
2556 "1.0.0",
2557 b"scan payload",
2558 );
2559 let feed = crate::module::fetch::ModuleFeed::for_base(
2560 url::Url::parse("http://127.0.0.1:1").expect("literal URL"),
2561 )
2562 .expect("feed");
2563
2564 let provisioning = provision_modules(
2565 &feed,
2566 &plan,
2567 &declared(&[("git", "2.3.4"), ("project-scan-endpoint", "1.0.0")]),
2568 cache.path(),
2569 crate::module::fetch::FetchPolicy::CacheFirst,
2570 )
2571 .await
2572 .expect("provisioning with both modules still declared");
2573 assert!(
2574 provisioning.orphaned_modules.is_empty(),
2575 "nothing changed, nothing is orphaned: {:?}",
2576 provisioning.orphaned_modules
2577 );
2578 assert!(override_path.exists(), "the override was regenerated");
2579
2580 let rig = SnapshotRig::default();
2581 let runner = FakeRunner::with(up_cycle_outputs());
2582 let result = rig_up(&runner, &plan, 300, Some(&rig), &provisioning)
2583 .await
2584 .expect("rig up completes");
2585 assert!(
2586 result.orphaned_modules.is_empty(),
2587 "an unchanged rig reports nothing on the result either"
2588 );
2589 assert_eq!(
2590 serde_json::to_value(&result).expect("serialise")["orphaned_modules"],
2591 serde_json::json!([]),
2592 "the key is present and empty, never absent"
2593 );
2594 assert!(
2595 !rig.calls()
2596 .iter()
2597 .any(|c| c.starts_with("uninstall_module"))
2598 );
2599 }
2600
2601 #[tokio::test]
2606 async fn reset_after_undeclaring_reports_orphans_and_issues_no_uninstall() {
2607 let dir = tempfile::tempdir().expect("tempdir");
2608 let plan = orphan_plan(dir.path());
2609 let override_path = seed_two_mount_override(&plan);
2610
2611 let provisioning = undeclared_provisioning(&plan).expect("undeclared provisioning");
2612 assert!(!override_path.exists(), "the stale override is deleted");
2613
2614 let rig = SnapshotRig::default();
2615 let runner = FakeRunner::with(reset_cycle_outputs());
2616 let result = rig_reset(&runner, &plan, 300, Some(&rig), &provisioning)
2617 .await
2618 .expect("reset completes");
2619 assert_eq!(
2620 result
2621 .orphaned_modules
2622 .iter()
2623 .map(|o| o.id.as_str())
2624 .collect::<Vec<_>>(),
2625 vec!["git", "project-scan-endpoint"],
2626 "rig_reset copies the orphan report into RigResetResult"
2627 );
2628 assert!(
2629 !rig.calls()
2630 .iter()
2631 .any(|c| c.starts_with("uninstall_module")),
2632 "no uninstall was attempted: {:?}",
2633 rig.calls()
2634 );
2635 }
2636
2637 struct SnapshotRig {
2646 calls: Mutex<Vec<String>>,
2647 project_names: Vec<String>,
2649 version: String,
2651 ping_state: &'static str,
2656 ping_fail: bool,
2660 }
2661
2662 impl Default for SnapshotRig {
2663 fn default() -> Self {
2664 Self {
2665 calls: Mutex::new(Vec::new()),
2666 project_names: Vec::new(),
2667 version: String::new(),
2668 ping_state: "RUNNING",
2669 ping_fail: false,
2670 }
2671 }
2672 }
2673
2674 impl SnapshotRig {
2675 fn calls(&self) -> Vec<String> {
2676 self.calls.lock().unwrap().clone()
2677 }
2678
2679 fn fixture_bytes() -> Vec<u8> {
2681 let mut bytes: Vec<u8> = vec![0x50, 0x4B, 0x03, 0x04];
2682 bytes.extend_from_slice(b"snapshot-fixture");
2683 bytes
2684 }
2685
2686 fn record(&self, call: String) {
2687 self.calls.lock().unwrap().push(call);
2688 }
2689
2690 fn serve_download(out: &Path, fixture_len: u64) -> crate::client::projects::ExportMeta {
2691 std::fs::write(out, Self::fixture_bytes()).expect("write fixture file");
2692 crate::client::projects::ExportMeta {
2693 filename: None,
2694 bytes: fixture_len,
2695 content_type: Some("application/octet-stream".into()),
2696 }
2697 }
2698 }
2699
2700 #[async_trait::async_trait]
2701 impl GatewayApi for SnapshotRig {
2702 async fn uninstall_module(
2710 &self,
2711 gateway_module_id: &str,
2712 _module_id: &str,
2713 ) -> Result<(), CoreError> {
2714 self.record(format!("uninstall_module({gateway_module_id})"));
2715 panic!(
2716 "rig up must never uninstall a module (D-19-01): \
2717 uninstall_module({gateway_module_id}) was called"
2718 );
2719 }
2720 async fn bundle_generate(
2721 &self,
2722 ) -> Result<crate::client::diagnostics::BundleStatusWire, CoreError> {
2723 unreachable!("not part of this action")
2724 }
2725 async fn bundle_status(
2726 &self,
2727 ) -> Result<crate::client::diagnostics::BundleStatusWire, CoreError> {
2728 unreachable!("not part of this action")
2729 }
2730 async fn bundle_download(
2731 &self,
2732 _out: &std::path::Path,
2733 ) -> Result<crate::client::projects::ExportMeta, CoreError> {
2734 unreachable!("not part of this action")
2735 }
2736 async fn tag_provider_list(
2737 &self,
2738 _query: &crate::client::query::ListQuery,
2739 ) -> Result<
2740 crate::client::query::ListEnvelope<crate::client::tags::TagProviderRecord>,
2741 CoreError,
2742 > {
2743 unreachable!("not part of this action")
2744 }
2745 async fn tag_provider_find(
2746 &self,
2747 _name: &str,
2748 ) -> Result<crate::client::tags::TagProviderRecord, CoreError> {
2749 unreachable!("not part of this action")
2750 }
2751 async fn tag_provider_create(
2752 &self,
2753 _body: &[crate::client::tags::TagProviderCreate],
2754 ) -> Result<(), CoreError> {
2755 unreachable!("not part of this action")
2756 }
2757 async fn tag_provider_delete(
2758 &self,
2759 _name: &str,
2760 _signature: &str,
2761 ) -> Result<(), CoreError> {
2762 unreachable!("not part of this action")
2763 }
2764 async fn backup_download(
2765 &self,
2766 out: &Path,
2767 _backup_type: crate::client::backup::BackupType,
2768 ) -> Result<crate::client::projects::ExportMeta, CoreError> {
2769 self.record("backup_download".into());
2770 Ok(Self::serve_download(
2771 out,
2772 Self::fixture_bytes().len() as u64,
2773 ))
2774 }
2775 async fn backup_restore(&self, _gwbk: &Path) -> Result<(), CoreError> {
2776 self.record("backup_restore".into());
2777 Ok(())
2778 }
2779 async fn eam_task_history(
2780 &self,
2781 _limit: Option<u32>,
2782 _search: Option<&str>,
2783 ) -> Result<crate::client::query::ListEnvelope<crate::client::eam::EamHistoryItem>, CoreError>
2784 {
2785 unreachable!("not part of this action")
2786 }
2787 async fn eam_task_definitions(
2788 &self,
2789 ) -> Result<crate::client::query::ListEnvelope<crate::client::eam::EamTaskRecord>, CoreError>
2790 {
2791 unreachable!("not part of this action")
2792 }
2793 async fn eam_task_find(
2794 &self,
2795 _name: &str,
2796 ) -> Result<crate::client::eam::EamTaskRecord, CoreError> {
2797 unreachable!("not part of this action")
2798 }
2799 async fn eam_task_create(&self, _definition: &serde_json::Value) -> Result<(), CoreError> {
2800 unreachable!("not part of this action")
2801 }
2802 async fn eam_task_force(&self, _owner: &str, _name: &str) -> Result<(), CoreError> {
2803 unreachable!("not part of this action")
2804 }
2805 async fn eam_task_suspend(&self, _name: &str) -> Result<(), CoreError> {
2806 unreachable!("not part of this action")
2807 }
2808 async fn eam_task_resume(&self, _name: &str) -> Result<(), CoreError> {
2809 unreachable!("not part of this action")
2810 }
2811 async fn eam_task_cancel(&self, _name: &str) -> Result<(), CoreError> {
2812 unreachable!("not part of this action")
2813 }
2814 async fn eam_tasks_scheduled(
2815 &self,
2816 _running: bool,
2817 ) -> Result<Vec<crate::client::eam::EamScheduledTask>, CoreError> {
2818 unreachable!("not part of this action")
2819 }
2820 async fn eam_task_modify(
2821 &self,
2822 _definition: &serde_json::Value,
2823 ) -> Result<Option<crate::client::eam::ModifyOutcome>, CoreError> {
2824 unreachable!("not part of this action")
2825 }
2826 async fn eam_task_delete(
2827 &self,
2828 _name: &str,
2829 _signature: &str,
2830 _confirm: bool,
2831 ) -> Result<crate::client::eam::DeleteOutcome, CoreError> {
2832 unreachable!("not part of this action")
2833 }
2834 async fn api_call(
2835 &self,
2836 _call: &crate::client::apicall::ApiCallRequest,
2837 ) -> Result<crate::client::apicall::ApiCallData, CoreError> {
2838 unreachable!("not part of this action")
2839 }
2840 async fn license_status(
2841 &self,
2842 ) -> Result<crate::client::license::LicenseStatusWire, CoreError> {
2843 unreachable!("not part of this action")
2844 }
2845 async fn redundancy_status(
2846 &self,
2847 ) -> Result<crate::client::redundancy::RedundancyStatusWire, CoreError> {
2848 unreachable!("not part of this action")
2849 }
2850 async fn gan_status(&self) -> Result<crate::client::gan::GanStatusWire, CoreError> {
2851 unreachable!("not part of this action")
2852 }
2853 async fn projects(
2854 &self,
2855 _query: &crate::client::query::ListQuery,
2856 ) -> Result<
2857 crate::client::query::ListEnvelope<crate::client::projects::ProjectRecord>,
2858 CoreError,
2859 > {
2860 self.record("projects".into());
2861 let items: Vec<crate::client::projects::ProjectRecord> = self
2862 .project_names
2863 .iter()
2864 .map(|name| crate::client::projects::ProjectRecord {
2865 name: name.clone(),
2866 title: None,
2867 description: None,
2868 enabled: true,
2869 parent: None,
2870 inheritable: None,
2871 default_db: None,
2872 tag_provider: None,
2873 user_source: None,
2874 extra: Default::default(),
2875 })
2876 .collect();
2877 let total = items.len() as i64;
2878 Ok(crate::client::query::ListEnvelope {
2879 items,
2880 metadata: crate::client::query::ListMetadata {
2881 total,
2882 matching: total,
2883 limit: -1,
2884 offset: 0,
2885 },
2886 })
2887 }
2888 async fn project_export_to_file(
2889 &self,
2890 name: &str,
2891 out: &Path,
2892 ) -> Result<crate::client::projects::ExportMeta, CoreError> {
2893 self.record(format!("export:{name}"));
2894 Ok(Self::serve_download(
2895 out,
2896 Self::fixture_bytes().len() as u64,
2897 ))
2898 }
2899 async fn gateway_info(&self) -> Result<crate::client::version::GatewayInfo, CoreError> {
2900 self.record("gateway_info".into());
2901 Ok(crate::client::version::GatewayInfo {
2902 name: None,
2903 redundancy_role: None,
2904 edition: None,
2905 ignition_version: self.version.clone(),
2906 jvm_version: None,
2907 license: None,
2908 endpoint: None,
2909 })
2910 }
2911 async fn status_ping(&self) -> Result<crate::client::status::StatusPing, CoreError> {
2912 if self.ping_fail {
2913 return Err(CoreError::Internal("probe fixture failure".into()));
2918 }
2919 Ok(crate::client::status::StatusPing {
2920 state: self.ping_state.to_string(),
2921 })
2922 }
2923
2924 async fn trial_status_wire(&self) -> Result<crate::client::trial::TrialWire, CoreError> {
2926 unreachable!("not part of this action")
2927 }
2928 async fn banners(&self) -> Result<crate::client::trial::BannerSet, CoreError> {
2929 unreachable!("not part of this action")
2930 }
2931 async fn trial_reset_wire(&self) -> Result<crate::client::trial::TrialWire, CoreError> {
2932 unreachable!("not part of this action")
2933 }
2934 async fn overview(&self) -> Result<crate::client::status::Overview, CoreError> {
2935 unreachable!("not part of this action")
2936 }
2937 async fn modules(
2938 &self,
2939 _quarantined: bool,
2940 _query: &crate::client::query::ListQuery,
2941 ) -> Result<crate::client::query::ListEnvelope<crate::client::status::ModuleInfo>, CoreError>
2942 {
2943 unreachable!("not part of this action")
2944 }
2945 async fn metrics_current(
2946 &self,
2947 ) -> Result<crate::client::metrics::CurrentGauges, CoreError> {
2948 unreachable!("not part of this action")
2949 }
2950 async fn metrics_historic(
2951 &self,
2952 ) -> Result<crate::client::metrics::PerformanceCharts, CoreError> {
2953 unreachable!("not part of this action")
2954 }
2955 async fn metrics_threads(&self) -> Result<crate::client::metrics::ThreadCounts, CoreError> {
2956 unreachable!("not part of this action")
2957 }
2958 async fn designers(
2959 &self,
2960 _query: &crate::client::query::ListQuery,
2961 ) -> Result<
2962 crate::client::query::ListEnvelope<crate::client::sessions::DesignerInfo>,
2963 CoreError,
2964 > {
2965 unreachable!("not part of this action")
2966 }
2967 async fn perspective_sessions(
2968 &self,
2969 _query: &crate::client::query::ListQuery,
2970 ) -> Result<
2971 crate::client::query::ListEnvelope<crate::client::sessions::PerspectiveSession>,
2972 CoreError,
2973 > {
2974 unreachable!("not part of this action")
2975 }
2976 async fn vision_clients(
2977 &self,
2978 _query: &crate::client::query::ListQuery,
2979 ) -> Result<
2980 crate::client::query::ListEnvelope<crate::client::sessions::VisionClient>,
2981 CoreError,
2982 > {
2983 unreachable!("not part of this action")
2984 }
2985 async fn terminate_perspective_session(
2986 &self,
2987 _id: &str,
2988 _message: Option<&str>,
2989 ) -> Result<(), CoreError> {
2990 unreachable!("not part of this action")
2991 }
2992 async fn terminate_vision_client(&self, _id: &str) -> Result<(), CoreError> {
2993 unreachable!("not part of this action")
2994 }
2995 async fn prune_designer(&self, _id: &str) -> Result<(), CoreError> {
2996 unreachable!("not part of this action")
2997 }
2998 async fn database_connections(
2999 &self,
3000 ) -> Result<
3001 crate::client::query::ListEnvelope<crate::client::connections::GatewayConnection>,
3002 CoreError,
3003 > {
3004 unreachable!("not part of this action")
3005 }
3006 async fn opc_connections(
3007 &self,
3008 ) -> Result<
3009 crate::client::query::ListEnvelope<crate::client::connections::GatewayConnection>,
3010 CoreError,
3011 > {
3012 unreachable!("not part of this action")
3013 }
3014 async fn logs(
3015 &self,
3016 _filter: &crate::client::logs::LogQuery,
3017 ) -> Result<crate::client::query::ListEnvelope<crate::client::logs::LogEntry>, CoreError>
3018 {
3019 unreachable!("not part of this action")
3020 }
3021 async fn logs_download(&self) -> Result<crate::client::logs::LogDownload, CoreError> {
3022 unreachable!("not part of this action")
3023 }
3024 async fn loggers(
3025 &self,
3026 _query: &crate::client::query::ListQuery,
3027 ) -> Result<crate::client::query::ListEnvelope<crate::client::logs::LoggerInfo>, CoreError>
3028 {
3029 unreachable!("not part of this action")
3030 }
3031 async fn set_logger_level(&self, _logger: &str, _level: &str) -> Result<(), CoreError> {
3032 unreachable!("not part of this action")
3033 }
3034 async fn reset_logger_levels(&self) -> Result<(), CoreError> {
3035 unreachable!("not part of this action")
3036 }
3037 async fn restart(&self) -> Result<(), CoreError> {
3038 unreachable!("not part of this action")
3039 }
3040 async fn scan_projects(&self) -> Result<(), CoreError> {
3041 unreachable!("not part of this action")
3042 }
3043 async fn security_properties(
3044 &self,
3045 ) -> Result<crate::client::restart::SecurityProperties, CoreError> {
3046 unreachable!("not part of this action")
3047 }
3048 async fn webdev_route_status(&self, _route: &str) -> Result<u16, CoreError> {
3049 unreachable!("not part of this action")
3050 }
3051 async fn webdev_route_call(
3052 &self,
3053 _project: &str,
3054 _route: &str,
3055 _body: &serde_json::Value,
3056 _extra_headers: &[(&str, &str)],
3057 ) -> Result<serde_json::Value, CoreError> {
3058 unreachable!("not part of this action")
3059 }
3060 async fn webdev_route_probe(
3061 &self,
3062 _project: &str,
3063 _route: &str,
3064 _extra_headers: &[(&str, &str)],
3065 ) -> Result<crate::client::webdev::RouteProbe, CoreError> {
3066 unreachable!("not part of this action")
3067 }
3068 async fn project_find(
3069 &self,
3070 _name: &str,
3071 ) -> Result<crate::client::projects::ProjectRecord, CoreError> {
3072 unreachable!("not part of this action")
3073 }
3074 async fn project_create(
3075 &self,
3076 _body: &crate::client::projects::ProjectCreate,
3077 ) -> Result<(), CoreError> {
3078 unreachable!("not part of this action")
3079 }
3080 async fn project_copy(&self, _from: &str, _to: &str) -> Result<(), CoreError> {
3081 unreachable!("not part of this action")
3082 }
3083 async fn project_rename(&self, _name: &str, _new_name: &str) -> Result<(), CoreError> {
3084 unreachable!("not part of this action")
3085 }
3086 async fn project_modify(
3087 &self,
3088 _name: &str,
3089 _body: &crate::client::projects::ProjectModify,
3090 ) -> Result<(), CoreError> {
3091 unreachable!("not part of this action")
3092 }
3093 async fn project_delete(&self, _name: &str) -> Result<(), CoreError> {
3094 unreachable!("not part of this action")
3095 }
3096 async fn project_import(
3097 &self,
3098 _name: &str,
3099 _zip: Vec<u8>,
3100 _overwrite: bool,
3101 ) -> Result<crate::client::projects::ImportOutcome, CoreError> {
3102 unreachable!("not part of this action")
3103 }
3104 }
3105
3106 #[tokio::test]
3111 async fn snapshot_composes_gwbk_exports_and_exact_manifest() {
3112 let out_dir = tempfile::tempdir().expect("tempdir");
3113 let rig = SnapshotRig {
3114 project_names: vec!["alpha".into(), "My Project".into()],
3115 version: "8.3.3 (b1)".into(),
3116 ping_state: "RUNNING",
3117 ..SnapshotRig::default()
3118 };
3119
3120 let result = rig_snapshot(&rig, "fixture-rig", Some(out_dir.path()))
3121 .await
3122 .expect("snapshot composes");
3123 let fixture_len = SnapshotRig::fixture_bytes().len() as u64;
3124 assert_eq!(result.gwbk_bytes, fixture_len);
3125 assert_eq!(
3126 result.projects,
3127 vec!["alpha".to_string(), "My Project".to_string()]
3128 );
3129 assert_eq!(result.dir, out_dir.path().display().to_string());
3130
3131 let on_disk = std::fs::read(out_dir.path().join("fixture-rig.gwbk")).expect("gwbk exists");
3133 assert_eq!(on_disk, SnapshotRig::fixture_bytes());
3134 assert!(out_dir.path().join("projects/alpha.zip").exists());
3136 assert!(out_dir.path().join("projects/My%20Project.zip").exists());
3137
3138 let manifest_path = out_dir.path().join("manifest.json");
3141 assert_eq!(result.manifest_path, manifest_path.display().to_string());
3142 let manifest: serde_json::Value =
3143 serde_json::from_str(&std::fs::read_to_string(&manifest_path).expect("manifest read"))
3144 .expect("manifest parses");
3145 let taken_at = manifest["taken_at"].as_i64().expect("taken_at epoch s");
3146 let now = std::time::SystemTime::now()
3147 .duration_since(std::time::UNIX_EPOCH)
3148 .unwrap()
3149 .as_secs() as i64;
3150 assert!(
3151 (now - 5..=now + 5).contains(&taken_at),
3152 "taken_at is epoch seconds near now: {taken_at}"
3153 );
3154 assert_eq!(
3155 manifest,
3156 serde_json::json!({
3157 "rig": "fixture-rig",
3158 "taken_at": taken_at,
3159 "ignition": { "version": "8.3.3 (b1)" },
3160 "gwbk": "fixture-rig.gwbk",
3161 "projects": [
3162 { "name": "alpha", "file": "projects/alpha.zip" },
3163 { "name": "My Project", "file": "projects/My%20Project.zip" }
3164 ],
3165 "notes": [
3166 "trial clock state is NOT captured by gwbk (unknown behavior — reset \
3167 separately via rig trial reset)",
3168 "tag-provider bulk export is Phase 5 scope (TAGS-09); gwbk captures tag \
3169 config via gateway data"
3170 ]
3171 }),
3172 "EXACT manifest shape — the honest composition contract"
3173 );
3174
3175 assert_eq!(
3178 rig.calls(),
3179 vec![
3180 "backup_download".to_string(),
3181 "projects".to_string(),
3182 "export:alpha".to_string(),
3183 "export:My Project".to_string(),
3184 "gateway_info".to_string(),
3185 ]
3186 );
3187 }
3188
3189 #[tokio::test]
3192 async fn snapshot_of_empty_gateway_carries_empty_projects_key() {
3193 let out_dir = tempfile::tempdir().expect("tempdir");
3194 let rig = SnapshotRig {
3195 version: "8.3.6".into(),
3196 ping_state: "RUNNING",
3197 ..SnapshotRig::default()
3198 };
3199 let result = rig_snapshot(&rig, "fixture-rig", Some(out_dir.path()))
3200 .await
3201 .expect("empty snapshot composes");
3202 assert_eq!(result.projects, Vec::<String>::new());
3203 assert!(!out_dir.path().join("projects").exists(), "no empty dir");
3204 let manifest: serde_json::Value = serde_json::from_str(
3205 &std::fs::read_to_string(out_dir.path().join("manifest.json")).expect("read"),
3206 )
3207 .expect("parses");
3208 assert_eq!(
3209 manifest["projects"],
3210 serde_json::json!([]),
3211 "the key is present and empty — agents never key-hunt"
3212 );
3213 }
3214
3215 #[tokio::test]
3220 async fn snapshot_survives_gateway_info_failure_with_null_version() {
3221 let server = wiremock::MockServer::start().await;
3227 wiremock::Mock::given(wiremock::matchers::method("GET"))
3228 .and(wiremock::matchers::path("/data/api/v1/backup"))
3229 .respond_with(
3230 wiremock::ResponseTemplate::new(200)
3231 .set_body_raw(vec![0x50, 0x4B, 0x03, 0x04], "application/octet-stream"),
3232 )
3233 .mount(&server)
3234 .await;
3235 wiremock::Mock::given(wiremock::matchers::method("GET"))
3236 .and(wiremock::matchers::path("/data/api/v1/projects/list"))
3237 .respond_with(
3238 wiremock::ResponseTemplate::new(200)
3239 .set_body_json(serde_json::json!({ "items": [], "metadata": {
3240 "total": 0, "matching": 0, "limit": -1, "offset": 0 } })),
3241 )
3242 .mount(&server)
3243 .await;
3244 wiremock::Mock::given(wiremock::matchers::method("GET"))
3245 .and(wiremock::matchers::path("/data/api/v1/gateway-info"))
3246 .respond_with(wiremock::ResponseTemplate::new(500))
3247 .mount(&server)
3248 .await;
3249 let api = crate::client::ReqwestGatewayApi::for_tests(&server.uri(), None);
3250
3251 let out_dir = tempfile::tempdir().expect("tempdir");
3252 let result = rig_snapshot(&api, "fixture-rig", Some(out_dir.path()))
3253 .await
3254 .expect("the snapshot survives the metadata failure");
3255 assert_eq!(result.gwbk_bytes, 4);
3256 let manifest: serde_json::Value = serde_json::from_str(
3257 &std::fs::read_to_string(out_dir.path().join("manifest.json")).expect("read"),
3258 )
3259 .expect("parses");
3260 assert_eq!(manifest["ignition"]["version"], serde_json::Value::Null);
3261 }
3262
3263 #[tokio::test]
3266 async fn restore_posts_waits_and_warns() {
3267 let work = tempfile::tempdir().expect("tempdir");
3268 let gwbk = work.path().join("snapshot.gwbk");
3269 std::fs::write(&gwbk, b"PK\x03\x04restore-fixture").expect("write gwbk");
3270 let rig = SnapshotRig {
3271 ping_state: "RUNNING",
3272 ..SnapshotRig::default()
3273 };
3274
3275 let result = rig_restore(&rig, "http://localhost:9088", &gwbk, 300)
3276 .await
3277 .expect("restore completes");
3278 assert_eq!(
3279 serde_json::to_value(&result).unwrap(),
3280 serde_json::json!({
3281 "restored_from": gwbk.display().to_string(),
3282 "state": "running",
3283 "warnings": [
3284 "API tokens may have been reset by restore — re-provision via \
3285 gateway UI, then ign doctor"
3286 ],
3287 }),
3288 "EXACT shape — state witnessed (never a bare 2xx), token warning first"
3289 );
3290 assert_eq!(
3291 rig.calls(),
3292 vec!["backup_restore".to_string()],
3293 "the POST fired once (the wait's status_ping probes aren't \
3294 recorded — the fake serves the state directly)"
3295 );
3296 }
3297
3298 #[tokio::test]
3302 async fn restore_prechecks_fail_before_any_network() {
3303 let rig = SnapshotRig::default();
3304
3305 let missing = PathBuf::from("/nonexistent/snap.gwbk");
3307 let err = rig_restore(&rig, "http://localhost:9088", &missing, 300)
3308 .await
3309 .expect_err("missing file refuses");
3310 assert!(matches!(err, CoreError::InvalidInput { .. }), "{err}");
3311 assert_eq!(err.exit_code(), 2);
3312 assert_eq!(err.code(), "invalid_input");
3313 let message = err.to_string();
3314 assert!(message.contains("not found"), "{message}");
3315
3316 let work = tempfile::tempdir().expect("tempdir");
3318 let empty = work.path().join("empty.gwbk");
3319 std::fs::write(&empty, b"").expect("write empty");
3320 let err = rig_restore(&rig, "http://localhost:9088", &empty, 300)
3321 .await
3322 .expect_err("empty file refuses");
3323 assert!(matches!(err, CoreError::InvalidInput { .. }), "{err}");
3324 assert!(err.to_string().contains("empty"), "{}", err);
3325
3326 let unreadable = work.path(); let err = rig_restore(&rig, "http://localhost:9088", unreadable, 300)
3332 .await
3333 .expect_err("directory is not a restorable file");
3334 assert!(matches!(err, CoreError::InvalidInput { .. }), "{err}");
3335 assert!(
3336 rig.calls().is_empty(),
3337 "pre-check refusals never touch the gateway: {:?}",
3338 rig.calls()
3339 );
3340 }
3341
3342 #[tokio::test]
3349 async fn restore_wait_failure_is_a_rig_error() {
3350 let work = tempfile::tempdir().expect("tempdir");
3351 let gwbk = work.path().join("snapshot.gwbk");
3352 std::fs::write(&gwbk, b"PK\x03\x04fixture").expect("write gwbk");
3353 let rig = SnapshotRig {
3354 ping_fail: true,
3355 ..SnapshotRig::default()
3356 };
3357 let err = rig_restore(&rig, "http://localhost:9088", &gwbk, 300)
3358 .await
3359 .expect_err("a failed wait errors the restore");
3360 assert!(matches!(err, CoreError::Rig(_)), "{err}");
3361 assert_eq!(err.exit_code(), 7);
3362 let message = err.to_string();
3363 assert!(message.contains("did not reach RUNNING"), "{message}");
3364 assert!(
3365 rig.calls().contains(&"backup_restore".to_string()),
3366 "the POST fired before the wait: {:?}",
3367 rig.calls()
3368 );
3369 }
3370
3371 #[test]
3376 fn restore_wait_floor_is_300s_and_clamps() {
3377 assert_eq!(super::RESTORE_WAIT_FLOOR_S, 300);
3378 assert_eq!(super::restore_deadline(1), 300, "short budgets floor up");
3379 assert_eq!(super::restore_deadline(300), 300);
3380 assert_eq!(
3381 super::restore_deadline(600),
3382 600,
3383 "longer budgets pass through"
3384 );
3385 }
3386
3387 #[test]
3391 fn stamp_renders_utc_compact() {
3392 assert_eq!(super::stamp_from_secs(0), "19700101-000000");
3393 assert_eq!(super::stamp_from_secs(1_787_346_747), "20260821-211227");
3394 assert_eq!(super::civil_from_days(0), (1970, 1, 1));
3395 assert_eq!(super::civil_from_days(19_723), (2024, 1, 1));
3396 }
3397
3398 const PS_STDOUT: &str = concat!(
3404 r#"{"Name":"fixture-rig-ignition-1","Service":"ignition","State":"running","Health":"healthy","ExitCode":0,"Publishers":[{"URL":"0.0.0.0","TargetPort":8088,"PublishedPort":9088,"Protocol":"tcp"},{"URL":"0.0.0.0","TargetPort":443,"PublishedPort":9443,"Protocol":"tcp"}]}"#,
3405 "\n",
3406 r#"{"Name":"fixture-rig-db-1","Service":"db","State":"exited","ExitCode":137,"Publishers":[]}"#,
3407 "\n",
3408 );
3409
3410 const VOLUME_STDOUT: &str = concat!(
3411 r#"{"Name":"fixture-rig_gw-data","Labels":{"com.docker.compose.project":"fixture-rig"}}"#,
3412 "\n",
3413 );
3414
3415 #[tokio::test]
3416 async fn status_serializes_the_allowlist_exactly() {
3417 let occupant = r#"{"Names":"fixture-rig-ignition-1","Labels":"com.docker.compose.project=fixture-rig"}"#;
3421 let runner = FakeRunner::with(vec![
3422 version_ok(),
3423 ok(PS_STDOUT),
3424 ok(VOLUME_STDOUT),
3425 ok(occupant),
3426 ok(occupant),
3427 ]);
3428
3429 let result = rig_status(&runner, &gw_plan())
3430 .await
3431 .expect("status succeeds");
3432 let json = serde_json::to_value(&result).unwrap();
3433 assert_eq!(
3434 json,
3435 serde_json::json!({
3436 "rig": "fixture-rig",
3437 "project": "fixture-rig",
3438 "compose_file": "/rigs/docker/compose.yml",
3439 "services": [
3440 {
3441 "name": "ignition",
3442 "state": "running",
3443 "health": "healthy",
3444 "exit_code": 0,
3445 "publishers": [
3446 {"published_port": 9088, "target_port": 8088, "protocol": "tcp"},
3447 {"published_port": 9443, "target_port": 443, "protocol": "tcp"}
3448 ]
3449 },
3450 {
3451 "name": "db",
3452 "state": "exited",
3453 "health": null,
3454 "exit_code": 137,
3455 "publishers": []
3456 }
3457 ],
3458 "volumes": ["fixture-rig_gw-data"],
3459 "ports_free": false
3460 }),
3461 "EXACT shape comparison: no compose-config passthrough, no \
3462 unknown keys — the allowlist IS the contract"
3463 );
3464 }
3465
3466 #[tokio::test]
3469 async fn status_down_rig_is_data() {
3470 let runner = FakeRunner::with(vec![version_ok(), ok(""), ok(""), ok(""), ok("")]);
3471 let result = rig_status(&runner, &gw_plan())
3472 .await
3473 .expect("status of a down rig exits 0");
3474 assert!(result.services.is_empty());
3475 assert!(result.volumes.is_empty());
3476 assert!(result.ports_free);
3477 }
3478
3479 #[test]
3482 fn up_and_down_results_carry_all_keys() {
3483 let up = RigUpResult {
3484 rig: "r".into(),
3485 project: "r".into(),
3486 state: "uncommissioned".into(),
3487 gateway_url: None,
3488 warnings: vec![],
3489 provisioned_modules: vec![],
3490 orphaned_modules: vec![],
3491 };
3492 let json = serde_json::to_value(&up).unwrap();
3493 for key in [
3494 "rig",
3495 "project",
3496 "state",
3497 "gateway_url",
3498 "warnings",
3499 "provisioned_modules",
3500 "orphaned_modules",
3501 ] {
3502 assert!(json.get(key).is_some(), "missing key {key}");
3503 }
3504 let down = RigDownResult {
3505 rig: "r".into(),
3506 project: "r".into(),
3507 state: "down".into(),
3508 };
3509 let json = serde_json::to_value(&down).unwrap();
3510 for key in ["rig", "project", "state"] {
3511 assert!(json.get(key).is_some(), "missing key {key}");
3512 }
3513 let reset = RigResetResult {
3514 rig: "r".into(),
3515 project: "r".into(),
3516 removed_volumes: vec![],
3517 state: "running".into(),
3518 warnings: vec![],
3519 provisioned_modules: vec![],
3520 orphaned_modules: vec![],
3521 };
3522 let json = serde_json::to_value(&reset).unwrap();
3523 for key in [
3524 "rig",
3525 "project",
3526 "removed_volumes",
3527 "state",
3528 "warnings",
3529 "provisioned_modules",
3530 "orphaned_modules",
3531 ] {
3532 assert!(json.get(key).is_some(), "missing key {key}");
3533 }
3534 let status_keys = [
3535 "rig",
3536 "project",
3537 "compose_file",
3538 "services",
3539 "volumes",
3540 "ports_free",
3541 ];
3542 let _ = RigStatusResult {
3543 rig: "r".into(),
3544 project: "r".into(),
3545 compose_file: "/c.yml".into(),
3546 services: vec![],
3547 volumes: vec![],
3548 ports_free: true,
3549 };
3550 assert_eq!(status_keys.len(), 6);
3553 }
3554}