1use serde::Serialize;
22
23pub const TUI_TTY_REFUSAL_REASON: &str = "ign tui requires a terminal (stdout is not a TTY)";
31
32pub const TOP_TTY_REFUSAL_REASON: &str = "ign top requires a terminal (stdout is not a TTY)";
39
40pub const LOSS_GATE_REFUSAL_REASON_PREFIX: &str = "loss report (";
51
52pub const TESTING_ROUTE_PREFIX: &str = "testing/";
62
63pub const GATEWAY_CLIENT_BODY_CAP_BYTES: usize = 4096;
70
71pub const GATEWAY_CLIENT_BODY_TRUNCATION_MARKER: &str = "... [truncated]";
76
77pub fn truncate_api_body(body: &str) -> String {
83 if body.len() <= GATEWAY_CLIENT_BODY_CAP_BYTES {
84 return body.to_string();
85 }
86 let mut end = GATEWAY_CLIENT_BODY_CAP_BYTES;
87 while !body.is_char_boundary(end) {
88 end -= 1;
89 }
90 let mut truncated = body[..end].to_string();
91 truncated.push_str(GATEWAY_CLIENT_BODY_TRUNCATION_MARKER);
92 truncated
93}
94
95#[derive(Debug, thiserror::Error)]
98pub enum CoreError {
99 #[error("internal error: {0}")]
101 Internal(String),
102
103 #[error("{operation} is destructive; rerun with --yes to confirm")]
107 ConfirmationRequired { operation: String },
108
109 #[error(
117 "{operation} actively instruments the gateway JVM; rerun with --i-accept-the-risk to acknowledge the production risk"
118 )]
119 ProfilingRiskNotAccepted { operation: String },
120
121 #[error("invalid import file: {reason}")]
125 InvalidImportFile { reason: String },
126
127 #[error("gateway rejected the project import for {project:?}: {problem}")]
135 ImportDenied {
136 project: String,
138 problem: String,
140 endpoint: Option<String>,
142 },
143
144 #[error("invalid input: {reason}")]
149 InvalidInput { reason: String },
150
151 #[error("profile {name:?} not found (known profiles: {known:?})")]
153 ProfileNotFound { name: String, known: Vec<String> },
154
155 #[error("no active profile configured")]
159 NoActiveProfile,
160
161 #[error("secret unavailable for profile {profile:?}")]
164 SecretUnavailable { profile: String },
165
166 #[error("login password unavailable for profile {profile:?}")]
172 PasswordUnavailable { profile: String },
173
174 #[error("invalid configuration: {reason}")]
176 ConfigInvalid { reason: String },
177
178 #[error("profile {profile:?}: poll_interval_secs must be >= 1 (sub-second polling refused)")]
185 PollIntervalTooSmall {
186 profile: String,
188 },
189
190 #[error(
202 "{lead} at {url}{source_note}{observation_note}",
203 lead = if observation.is_some() {
204 "no terminal state"
205 } else {
206 "gateway unreachable"
207 },
208 source_note = source.as_ref().map(|source| format!(": {source}")).unwrap_or_default(),
209 observation_note = observation
210 .as_deref()
211 .map(|obs| format!("; last observation: {obs}"))
212 .unwrap_or_default()
213 )]
214 Network {
215 url: String,
216 #[source]
217 source: Option<reqwest::Error>,
218 observation: Option<String>,
223 },
224
225 #[error("gateway rejected credentials (HTTP {status})")]
227 Auth {
228 status: u16,
229 endpoint: Option<String>,
231 },
232
233 #[error("gateway version {found} is below minimum {minimum}")]
237 GatewayTooOld {
238 found: String,
239 minimum: String,
240 endpoint: Option<String>,
242 },
243
244 #[error("gateway at {} is not commissioned", endpoint.as_deref().unwrap_or("unknown address"))]
248 GatewayNotCommissioned {
249 endpoint: Option<String>,
251 },
252
253 #[error("gateway is restarting (webserver up, services down)")]
257 GatewayRestarting {
258 endpoint: Option<String>,
260 },
261
262 #[error("resource not found on the gateway")]
266 NotFound {
267 endpoint: Option<String>,
269 },
270
271 #[error("project {name} already exists on the gateway")]
278 ProjectExists {
279 name: String,
281 endpoint: Option<String>,
283 },
284
285 #[error("resource {path:?} has binary content — not editable via the resource loop")]
291 ResourceBinary {
292 path: String,
294 endpoint: Option<String>,
296 },
297
298 #[error(
307 "trial is not expired ({remaining_s}s left) — the gateway only honors resets once the trial expires"
308 )]
309 TrialNotExpired {
310 remaining_s: i64,
312 endpoint: Option<String>,
314 },
315
316 #[error(
324 "webdev routes are not deployed (probe of {route:?} in project {project:?} answered 405)"
325 )]
326 RoutesNotDeployed {
327 project: String,
329 route: String,
331 endpoint: Option<String>,
333 },
334
335 #[error(
340 "the WebDev module is unlicensed on this gateway (HTTP 402 — trial-expired rigs cannot serve /system/webdev routes)"
341 )]
342 WebdevUnlicensed {
343 endpoint: Option<String>,
345 },
346
347 #[error("route {route:?} version mismatch: deployed {deployed}, this CLI expects {expected}")]
352 RouteVersionMismatch {
353 route: String,
355 deployed: String,
357 expected: String,
360 endpoint: Option<String>,
362 },
363
364 #[error("tag provider {name:?} not found on the gateway")]
371 ProviderNotFound {
372 name: String,
374 endpoint: Option<String>,
376 },
377
378 #[error("webdev route denied the call ({code}): {message}")]
384 WebdevRouteError {
385 code: String,
387 message: String,
389 endpoint: Option<String>,
391 },
392
393 #[error(
400 "tag collision importing into provider {provider:?}: {} already exist(s)",
401 names.join(", ")
402 )]
403 TagCollision {
404 provider: String,
406 names: Vec<String>,
408 endpoint: Option<String>,
410 },
411
412 #[error(
423 "no alarm journal profile is configured on this gateway — alarm history has nothing to read"
424 )]
425 AlarmJournalMissing {
426 endpoint: Option<String>,
428 },
429
430 #[error("designer session {id} is live — the gateway refused the prune")]
439 SessionNotPrunable {
440 id: String,
442 endpoint: Option<String>,
444 },
445
446 #[error("rig error: {0}")]
449 Rig(String),
450
451 #[error(
461 "EAM is not configured as a controller on this gateway — every EAM runtime \
462 operation refuses until the module's installMode is flipped"
463 )]
464 EamNotController {
465 endpoint: Option<String>,
467 },
468
469 #[error(
480 "provider-root tag paths are not supported by the deployed route (the gateway needs an \
481 RPC context WebDev threads don't carry) — target a subtree like [provider]folder"
482 )]
483 ProviderRootUnsupported {
484 endpoint: Option<String>,
486 },
487
488 #[error(
496 "EAM task type {task_type} is fleet-destructive — refused (restore/install/upgrade \
497 are EXT-03 (v2) scope; run them from the EAM console)"
498 )]
499 EamTaskTypeRefused {
500 task_type: String,
502 },
503
504 #[error("EAM task {task} has a run in flight — the gateway refused the force: {detail}")]
513 EamTaskInFlight {
514 task: String,
516 detail: String,
519 endpoint: Option<String>,
521 },
522
523 #[error(
533 "scriptExec is not configured for profile {profile:?} — the secret-gated route deploys \
534 only via the explicit opt-in"
535 )]
536 ScriptExecNotConfigured {
537 profile: String,
539 },
540
541 #[error("ignition-lint is not installed (no executable found on PATH)")]
547 LintToolAbsent,
548
549 #[error("{tool} is not installed (no executable found on PATH)")]
559 NodeToolAbsent {
560 tool: String,
562 },
563
564 #[error("gateway rejected the api call (HTTP {status} from {endpoint}): {body}")]
576 GatewayClientError {
577 status: u16,
579 endpoint: String,
581 body: String,
584 },
585
586 #[error(
596 "no diagnostics bundle available (gateway reports state {state:?}) — run \
597 `ign diagnostics bundle generate` first; polling cannot change this state"
598 )]
599 BundleNotAvailable {
600 state: String,
603 },
604
605 #[error(
614 "module release feed at {url} is unreachable{source_note}",
615 source_note = source.as_ref().map(|source| format!(": {source}")).unwrap_or_default()
616 )]
617 ModuleFeedUnreachable {
618 url: String,
620 #[source]
621 source: Option<reqwest::Error>,
622 },
623
624 #[error("module {module:?} version {version:?} not found at {url}: {detail}")]
631 ModuleReleaseNotFound {
632 module: String,
634 version: String,
636 url: String,
638 detail: String,
641 },
642
643 #[error("{0}")]
656 ModuleDigestMismatch(Box<ModuleDigestMismatchDetails>),
657
658 #[error("module release feed at {url} is unusable: {detail}")]
666 ModuleFeedUnusable {
667 url: String,
669 detail: String,
671 },
672
673 #[error("{0}")]
687 ModuleDigestChanged(Box<ModuleDigestChangedDetails>),
688
689 #[error("module {id:?} is not registered (known modules: {known:?})")]
699 ModuleNotRegistered {
700 id: String,
702 known: Vec<String>,
704 },
705
706 #[error(
719 "gateway refused to uninstall module {module_id:?} ({gateway_module_id}): {}",
720 failed.join(", ")
721 )]
722 ModuleUninstallDenied {
723 module_id: String,
725 gateway_module_id: String,
728 failed: Vec<String>,
731 endpoint: Option<String>,
733 },
734
735 #[error(
742 "the {kind} scan-lock is held by {}",
743 actor.as_deref().unwrap_or("another actor")
744 )]
745 ScanLockHeld {
746 actor: Option<String>,
748 kind: String,
750 endpoint: Option<String>,
752 },
753
754 #[error("gateway refused the write to resource {name:?}: {reason} signature")]
761 ResourceSignatureRejected {
762 name: String,
764 reason: SignatureReason,
766 endpoint: Option<String>,
768 },
769
770 #[error("resource {name:?} already exists")]
776 ResourceExists {
777 name: String,
779 endpoint: Option<String>,
781 },
782
783 #[error("deployment mode {name:?} is already defined")]
787 ModeExists {
788 name: String,
790 endpoint: Option<String>,
792 },
793
794 #[error("deploy tree invalid: {0}")]
801 DeployTreeInvalid(Box<DeployTreeInvalidDetails>),
802
803 #[error("refusing to render an embedded or plaintext secret: {0}")]
807 EmbeddedSecret(Box<EmbeddedSecretDetails>),
808
809 #[error("transport failed ({operation}) on {node}: {detail}")]
817 TransportFailed {
818 operation: String,
820 node: String,
822 detail: String,
824 },
825
826 #[error("declared-names ledger {location} is unusable: {problem}")]
834 DeployLedgerInvalid {
835 location: String,
837 problem: String,
839 },
840
841 #[error("plan changed since review: expected plan_sha256 {expected}, now {actual} ({detail})")]
846 PlanStale {
847 expected: String,
849 actual: String,
851 detail: String,
853 },
854
855 #[error("{}", destructive_change_refused_text(.gate, .keys, *.count))]
860 DestructiveChangeRefused {
861 gate: String,
863 keys: Vec<String>,
865 count: usize,
867 },
868
869 #[error("{}", deploy_blocked_text(.keys))]
873 DeployBlocked {
874 keys: Vec<String>,
876 },
877
878 #[error("{}", deploy_preflight_failed_text(.failures))]
882 DeployPreflightFailed {
883 failures: Box<[PreflightFailure]>,
885 },
886
887 #[error(
890 "an interrupted deploy left the journal {location} for run {run_id}; \
891 recover it with: ign deploy rollback --run {run_id} --env {env}"
892 )]
893 DeployJournalPresent {
894 run_id: String,
896 env: String,
898 location: String,
900 },
901
902 #[error("{}", deploy_live_changed_text(.run_id, .paths, *.count))]
906 DeployLiveChanged {
907 run_id: String,
909 paths: Vec<String>,
911 count: usize,
913 },
914
915 #[error("deploy run {run_id} is unusable: {problem}")]
918 DeployRunInvalid {
919 run_id: String,
921 problem: String,
923 },
924
925 #[error("{0}")]
928 RollbackFailed(Box<RollbackFailure>),
929
930 #[error(
938 "project {project} is still managed by the git module: {location} is present ({kind}); an administrator removes it before this project is deployed"
939 )]
940 ProjectGitManaged {
941 project: String,
943 location: String,
945 kind: String,
947 },
948
949 #[error("project {project} cannot be deployed under parent {parent}: {reason}")]
956 ProjectParentInvalid {
957 project: String,
959 parent: String,
961 reason: String,
963 },
964
965 #[error(
973 "artifact hash mismatch for project {project}: expected {expected}, the checkout builds {actual}; nothing was uploaded"
974 )]
975 ProjectHashMismatch {
976 project: String,
978 expected: String,
980 actual: String,
982 },
983}
984
985pub const APPLY_LIST_SHOWN: usize = 10;
988
989#[derive(Debug, Clone, PartialEq, Eq)]
992pub struct PreflightFailure {
993 pub check: String,
995 pub detail: String,
997}
998
999#[derive(Debug, Clone, PartialEq, Eq)]
1001pub struct RollbackFailure {
1002 pub run_id: String,
1004 pub env: String,
1006 pub run_dir: Option<String>,
1008 pub journal: String,
1010 pub cause: String,
1012}
1013
1014impl RollbackFailure {
1015 pub fn snapshot_dir(&self) -> String {
1018 match self.journal.rsplit_once('/') {
1019 Some((dir, _)) => format!("{dir}/snapshots/{}/", self.run_id),
1020 None => format!("snapshots/{}/", self.run_id),
1021 }
1022 }
1023}
1024
1025impl std::fmt::Display for RollbackFailure {
1026 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1027 write!(
1032 f,
1033 "rollback of run {run} failed ({cause}); the gateway files may be half restored. \
1034 Retry with: ign deploy rollback --run {run} --env {env} (the journal at {journal} \
1035 and the snapshot are still on the host). If the retry reports a lost or damaged \
1036 snapshot, restore the files from the .gwbk backup (when this run took one) or from \
1037 the on-box snapshot {snapshot}, then delete the journal by hand. \
1038 run directory: {run_dir}",
1039 run = self.run_id,
1040 cause = self.cause,
1041 env = self.env,
1042 journal = self.journal,
1043 snapshot = self.snapshot_dir(),
1044 run_dir = self.run_dir.as_deref().unwrap_or("none was created"),
1045 )
1046 }
1047}
1048
1049fn listed(shown: &[String], total: usize) -> String {
1052 let take = shown.len().min(APPLY_LIST_SHOWN);
1053 let mut out = shown[..take].join(", ");
1054 if total > take {
1055 out.push_str(&format!(" and {} more", total - take));
1056 }
1057 out
1058}
1059
1060fn destructive_change_refused_text(gate: &str, keys: &[String], count: usize) -> String {
1061 let (flag, verb) = match gate {
1062 "drift" => ("--allow-drift <reason>", "apply"),
1063 "downgrade" => ("--allow-downgrade", "deploy projects"),
1064 "first_deploy" => ("--allow-first-change <reason>", "deploy projects"),
1065 _ => ("--allow-destructive", "apply"),
1066 };
1067 format!(
1068 "{gate} change refused for {count} entr{}: {}; {verb} needs {flag}",
1069 if count == 1 { "y" } else { "ies" },
1070 listed(keys, count)
1071 )
1072}
1073
1074fn deploy_blocked_text(keys: &[String]) -> String {
1075 format!(
1076 "deploy blocked: {} unit(s) are removed from the declared tree but a core copy \
1077 shadows them, so deleting them from the mode collection cannot take effect: {}",
1078 keys.len(),
1079 listed(keys, keys.len())
1080 )
1081}
1082
1083fn deploy_preflight_failed_text(failures: &[PreflightFailure]) -> String {
1084 let mut out = format!("deploy preflight failed ({} check(s)):", failures.len());
1085 for failure in failures {
1086 out.push_str(&format!("\n {}: {}", failure.check, failure.detail));
1087 }
1088 out
1089}
1090
1091fn deploy_live_changed_text(run_id: &str, paths: &[String], count: usize) -> String {
1092 format!(
1093 "live files changed since run {run_id} wrote them ({count}): {}; restoring the \
1094 snapshot would overwrite them",
1095 listed(paths, count)
1096 )
1097}
1098
1099pub const DEPLOY_FINDINGS_SHOWN: usize = 20;
1102
1103#[derive(Debug, Clone, PartialEq, Eq)]
1105pub struct DeployFinding {
1106 pub file: String,
1108 pub line: Option<usize>,
1110 pub column: Option<usize>,
1112 pub message: String,
1114}
1115
1116impl std::fmt::Display for DeployFinding {
1117 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1118 match (self.line, self.column) {
1119 (Some(line), Some(column)) => {
1120 write!(f, "{}:{line}:{column}: {}", self.file, self.message)
1121 }
1122 _ => write!(f, "{}: {}", self.file, self.message),
1123 }
1124 }
1125}
1126
1127#[derive(Debug, Clone, PartialEq, Eq)]
1130pub struct DeployTreeInvalidDetails {
1131 pub findings: Vec<DeployFinding>,
1133}
1134
1135impl std::fmt::Display for DeployTreeInvalidDetails {
1136 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1137 if let [only] = self.findings.as_slice() {
1138 return write!(f, "{only}");
1139 }
1140 write!(f, "{} findings: ", self.findings.len())?;
1141 write_findings(f, &self.findings, DEPLOY_FINDINGS_SHOWN)
1142 }
1143}
1144
1145fn write_findings(
1148 f: &mut std::fmt::Formatter<'_>,
1149 findings: &[DeployFinding],
1150 shown: usize,
1151) -> std::fmt::Result {
1152 for (i, finding) in findings.iter().take(shown).enumerate() {
1153 if i > 0 {
1154 f.write_str("; ")?;
1155 }
1156 write!(f, "{finding}")?;
1157 }
1158 if findings.len() > shown {
1159 write!(f, "; and {} more", findings.len() - shown)?;
1160 }
1161 Ok(())
1162}
1163
1164#[derive(Debug, Clone, PartialEq, Eq)]
1168pub struct EmbeddedSecretDetails {
1169 pub paths: Vec<String>,
1171 pub other_findings: Vec<DeployFinding>,
1173}
1174
1175impl std::fmt::Display for EmbeddedSecretDetails {
1176 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1177 write!(f, "embedded secret at {} path(s): ", self.paths.len())?;
1178 let shown_paths = self.paths.len().min(DEPLOY_FINDINGS_SHOWN);
1179 f.write_str(&self.paths[..shown_paths].join("; "))?;
1180 if self.paths.len() > shown_paths {
1181 write!(f, "; and {} more", self.paths.len() - shown_paths)?;
1182 }
1183 if !self.other_findings.is_empty() {
1184 write!(
1185 f,
1186 "; also {} other tree finding(s): ",
1187 self.other_findings.len()
1188 )?;
1189 let room = DEPLOY_FINDINGS_SHOWN.saturating_sub(shown_paths);
1191 write_findings(f, &self.other_findings, room)?;
1192 }
1193 Ok(())
1194 }
1195}
1196
1197#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1200pub enum SignatureReason {
1201 Stale,
1204 Missing,
1208}
1209
1210impl std::fmt::Display for SignatureReason {
1211 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1212 f.write_str(match self {
1213 Self::Stale => "stale",
1214 Self::Missing => "missing",
1215 })
1216 }
1217}
1218
1219#[derive(Debug, Clone, PartialEq, Eq)]
1222pub struct ModuleDigestMismatchDetails {
1223 pub module: String,
1225 pub version: String,
1227 pub url: String,
1229 pub expected: String,
1231 pub actual: String,
1233}
1234
1235#[derive(Debug, Clone, PartialEq, Eq)]
1238pub struct ModuleDigestChangedDetails {
1239 pub module: String,
1241 pub version: String,
1243 pub cached_digest: String,
1245 pub upstream_digest: String,
1247 pub cached_path: String,
1249}
1250
1251impl std::fmt::Display for ModuleDigestMismatchDetails {
1252 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1253 write!(
1254 f,
1255 "module {:?} version {:?} digest mismatch at {}: expected {}, got {}",
1256 self.module, self.version, self.url, self.expected, self.actual
1257 )
1258 }
1259}
1260
1261impl std::fmt::Display for ModuleDigestChangedDetails {
1262 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1263 write!(
1264 f,
1265 "module {:?} version {:?} digest changed upstream: cached {}, \
1266 upstream now reports {} (cached artifact kept at {})",
1267 self.module, self.version, self.cached_digest, self.upstream_digest, self.cached_path
1268 )
1269 }
1270}
1271
1272impl CoreError {
1273 pub fn code(&self) -> &'static str {
1275 match self {
1276 Self::Internal(_) => "internal",
1277 Self::ConfirmationRequired { .. } => "confirmation_required",
1278 Self::ProfilingRiskNotAccepted { .. } => "profiling_risk_not_accepted",
1279 Self::InvalidImportFile { .. } => "invalid_import_file",
1280 Self::ImportDenied { .. } => "import_denied",
1281 Self::InvalidInput { .. } => "invalid_input",
1282 Self::ProfileNotFound { .. } => "profile_not_found",
1283 Self::NoActiveProfile => "no_active_profile",
1284 Self::SecretUnavailable { .. } => "secret_unavailable",
1285 Self::PasswordUnavailable { .. } => "password_unavailable",
1286 Self::ConfigInvalid { .. } => "config_invalid",
1287 Self::PollIntervalTooSmall { .. } => "poll_interval_too_small",
1288 Self::Network { .. } => "network_error",
1289 Self::Auth { .. } => "auth_rejected",
1290 Self::GatewayTooOld { .. } => "gateway_too_old",
1291 Self::GatewayNotCommissioned { .. } => "gateway_not_commissioned",
1292 Self::GatewayRestarting { .. } => "gateway_restarting",
1293 Self::NotFound { .. } => "not_found",
1294 Self::ProjectExists { .. } => "project_exists",
1295 Self::ResourceBinary { .. } => "resource_binary",
1296 Self::TrialNotExpired { .. } => "trial_not_expired",
1297 Self::ProviderNotFound { .. } => "provider_not_found",
1298 Self::RoutesNotDeployed { .. } => "routes_not_deployed",
1299 Self::WebdevUnlicensed { .. } => "webdev_unlicensed",
1300 Self::RouteVersionMismatch { .. } => "route_version_mismatch",
1301 Self::WebdevRouteError { .. } => "webdev_route_error",
1302 Self::TagCollision { .. } => "tag_collision",
1303 Self::AlarmJournalMissing { .. } => "alarm_journal_missing",
1304 Self::SessionNotPrunable { .. } => "session_not_prunable",
1305 Self::Rig(_) => "rig_error",
1306 Self::EamNotController { .. } => "eam_not_controller",
1307 Self::ProviderRootUnsupported { .. } => "provider_root_unsupported",
1308 Self::EamTaskTypeRefused { .. } => "eam_task_type_refused",
1309 Self::EamTaskInFlight { .. } => "eam_task_in_flight",
1310 Self::ScriptExecNotConfigured { .. } => "script_exec_not_configured",
1311 Self::LintToolAbsent => "lint_tool_absent",
1312 Self::NodeToolAbsent { .. } => "node_tool_absent",
1313 Self::GatewayClientError { .. } => "gateway_client_error",
1314 Self::BundleNotAvailable { .. } => "bundle_not_available",
1315 Self::ModuleFeedUnreachable { .. } => "module_feed_unreachable",
1316 Self::ModuleReleaseNotFound { .. } => "module_release_not_found",
1317 Self::ModuleDigestMismatch(..) => "module_digest_mismatch",
1318 Self::ModuleFeedUnusable { .. } => "module_feed_unusable",
1319 Self::ModuleDigestChanged(..) => "module_digest_changed",
1320 Self::ModuleNotRegistered { .. } => "module_not_registered",
1321 Self::ModuleUninstallDenied { .. } => "module_uninstall_denied",
1322 Self::ScanLockHeld { .. } => "scan_lock_held",
1323 Self::ResourceSignatureRejected { .. } => "resource_signature_rejected",
1324 Self::ResourceExists { .. } => "resource_exists",
1325 Self::ModeExists { .. } => "mode_exists",
1326 Self::DeployTreeInvalid(..) => "deploy_tree_invalid",
1327 Self::EmbeddedSecret(..) => "embedded_secret",
1328 Self::TransportFailed { .. } => "transport_failed",
1329 Self::DeployLedgerInvalid { .. } => "deploy_ledger_invalid",
1330 Self::PlanStale { .. } => "plan_stale",
1331 Self::DestructiveChangeRefused { .. } => "destructive_change_refused",
1332 Self::DeployBlocked { .. } => "deploy_blocked",
1333 Self::DeployPreflightFailed { .. } => "deploy_preflight_failed",
1334 Self::DeployJournalPresent { .. } => "deploy_journal_present",
1335 Self::DeployLiveChanged { .. } => "deploy_live_changed",
1336 Self::DeployRunInvalid { .. } => "deploy_run_invalid",
1337 Self::RollbackFailed(..) => "rollback_failed",
1338 Self::ProjectGitManaged { .. } => "project_git_managed",
1339 Self::ProjectParentInvalid { .. } => "project_parent_invalid",
1340 Self::ProjectHashMismatch { .. } => "project_hash_mismatch",
1341 }
1342 }
1343
1344 pub fn exit_code(&self) -> u8 {
1346 match self {
1347 Self::Internal(_) => 1,
1348 Self::ConfirmationRequired { .. }
1349 | Self::ProfilingRiskNotAccepted { .. }
1350 | Self::InvalidImportFile { .. }
1351 | Self::InvalidInput { .. }
1352 | Self::GatewayClientError { .. }
1353 | Self::DeployTreeInvalid(..)
1354 | Self::EmbeddedSecret(..)
1355 | Self::DestructiveChangeRefused { .. } => 2,
1356 Self::ProfileNotFound { .. }
1357 | Self::NoActiveProfile
1358 | Self::SecretUnavailable { .. }
1359 | Self::PasswordUnavailable { .. }
1360 | Self::ConfigInvalid { .. }
1361 | Self::PollIntervalTooSmall { .. }
1362 | Self::ModuleNotRegistered { .. } => 3,
1363 Self::Network { .. }
1364 | Self::ModuleFeedUnreachable { .. }
1365 | Self::TransportFailed { .. } => 4,
1366 Self::Auth { .. } => 5,
1367 Self::GatewayTooOld { .. }
1368 | Self::GatewayNotCommissioned { .. }
1369 | Self::GatewayRestarting { .. }
1370 | Self::NotFound { .. }
1371 | Self::ProjectExists { .. }
1372 | Self::ResourceBinary { .. }
1373 | Self::TrialNotExpired { .. }
1374 | Self::ProviderNotFound { .. }
1375 | Self::RoutesNotDeployed { .. }
1376 | Self::WebdevUnlicensed { .. }
1377 | Self::RouteVersionMismatch { .. }
1378 | Self::WebdevRouteError { .. }
1379 | Self::TagCollision { .. }
1380 | Self::AlarmJournalMissing { .. }
1381 | Self::SessionNotPrunable { .. }
1382 | Self::ImportDenied { .. }
1383 | Self::EamNotController { .. }
1384 | Self::ProviderRootUnsupported { .. }
1385 | Self::EamTaskTypeRefused { .. }
1386 | Self::EamTaskInFlight { .. }
1387 | Self::ScriptExecNotConfigured { .. }
1388 | Self::LintToolAbsent
1389 | Self::NodeToolAbsent { .. }
1390 | Self::BundleNotAvailable { .. }
1391 | Self::ModuleReleaseNotFound { .. }
1392 | Self::ModuleDigestMismatch(..)
1393 | Self::ModuleFeedUnusable { .. }
1394 | Self::ModuleDigestChanged(..)
1395 | Self::ModuleUninstallDenied { .. }
1396 | Self::ScanLockHeld { .. }
1397 | Self::ResourceSignatureRejected { .. }
1398 | Self::ResourceExists { .. }
1399 | Self::ModeExists { .. }
1400 | Self::DeployLedgerInvalid { .. }
1401 | Self::PlanStale { .. }
1402 | Self::DeployBlocked { .. }
1403 | Self::DeployPreflightFailed { .. }
1404 | Self::DeployJournalPresent { .. }
1405 | Self::DeployLiveChanged { .. }
1406 | Self::DeployRunInvalid { .. }
1407 | Self::RollbackFailed(..)
1408 | Self::ProjectGitManaged { .. }
1409 | Self::ProjectParentInvalid { .. }
1410 | Self::ProjectHashMismatch { .. } => 6,
1411 Self::Rig(_) => 7,
1412 }
1413 }
1414
1415 pub fn hint(&self) -> Option<String> {
1417 match self {
1418 Self::Internal(_) => Some(
1419 "internal errors are bugs; re-run with -vv and report the \
1420 diagnostics output"
1421 .to_string(),
1422 ),
1423 Self::ConfirmationRequired { .. } => Some(
1424 "this operation is destructive; re-run with --yes or set \
1425 IGNITION_YES=1"
1426 .to_string(),
1427 ),
1428 Self::ProfilingRiskNotAccepted { .. } => Some(
1429 "JVM profiling ACTIVELY instruments the gateway: stack sampling \
1430 adds CPU overhead proportional to thread count x frequency, \
1431 and flight recordings buffer in JVM memory and write to \
1432 gateway disk. On a PRODUCTION server this belongs inside an \
1433 approved change window with a rollback plan — never as a \
1434 casual probe. --yes deliberately does NOT satisfy this gate; \
1435 re-run with --i-accept-the-risk after confirming that"
1436 .to_string(),
1437 ),
1438 Self::InvalidImportFile { .. } => Some(
1439 "import expects a project-export ZIP (PK\\x03\\x04 magic) of at \
1440 most 512 MB — pass a file produced by `ign project export` \
1441 via --file (or `-` to pipe one on stdin)"
1442 .to_string(),
1443 ),
1444 Self::ImportDenied { problem, .. } => Some(format!(
1445 "the gateway refused the import over a 200 answer — the problem \
1446 text above is the gateway's own; `ign project export` of the \
1447 current state is the honest baseline for hand-editing ({problem})"
1448 )),
1449 Self::InvalidInput { reason } => Some(
1450 if reason == TUI_TTY_REFUSAL_REASON {
1451 "run `ign tui` in an interactive terminal (the cockpit \
1455 needs a TTY on stdout — not a pipe or redirect)"
1456 } else if reason == TOP_TTY_REFUSAL_REASON {
1457 "run `ign top` in an interactive terminal (the monitor \
1461 needs a TTY on stdout — not a pipe or redirect)"
1462 } else if reason.starts_with(LOSS_GATE_REFUSAL_REASON_PREFIX) {
1463 "the loss report above names what this import would drop or \
1468 coerce — re-run with --yes to import anyway"
1469 } else if reason.starts_with("--plan-sha256 ")
1470 || reason.starts_with("--allow-drift ")
1471 || reason.starts_with("--artifact-sha256 ")
1472 || reason.starts_with("--release ")
1473 || reason.starts_with("--allow-first-change ")
1474 || reason.starts_with("deploy projects needs --artifact-sha256")
1475 {
1476 "correct the flag value named above and rerun; no host or gateway \
1480 was contacted"
1481 } else {
1482 "fix the input source — a readable file path via --file, or `-` \
1483 to pipe the content on stdin"
1484 }
1485 .to_string(),
1486 ),
1487 Self::ProfileNotFound { known, .. } => Some(if known.is_empty() {
1488 "no profiles configured yet; run `ign profile add` to create \
1489 one"
1490 .to_string()
1491 } else {
1492 format!(
1493 "known profiles: {}; run `ign profile add` to add another",
1494 known.join(", ")
1495 )
1496 }),
1497 Self::NoActiveProfile => Some(
1498 "pass --profile NAME, set IGNITION_PROFILE, or mark a profile \
1499 active with `ign profile use`"
1500 .to_string(),
1501 ),
1502 Self::SecretUnavailable { profile } => Some(format!(
1503 "set IGNITION_TOKEN (or token_env in the profile), or store a \
1504 keyring entry: service 'ignition-cli', user 'profile:{profile}'"
1505 )),
1506 Self::PasswordUnavailable { .. } => Some(
1507 "ign adopt logs in with the gateway admin password — export \
1508 IGNITION_PASSWORD (env-only, never a flag)"
1509 .to_string(),
1510 ),
1511 Self::ConfigInvalid { .. } => Some(
1512 "verify the config file is valid TOML with [profiles.NAME] \
1513 tables; `ign profile add` writes a known-good one"
1514 .to_string(),
1515 ),
1516 Self::PollIntervalTooSmall { profile } => Some(format!(
1517 "set poll_interval_secs to 1 or higher in [profiles.{profile}], \
1518 or remove the key to use the default cadence"
1519 )),
1520 Self::Network {
1521 url,
1522 observation,
1523 ..
1524 } => Some(match observation {
1525 Some(observation) => format!(
1526 "the gateway answered but no terminal state arrived before the deadline — \
1527 the last observation was: {observation}; address that state, not the \
1528 connection ({url})"
1529 ),
1530 None => format!("check the gateway is reachable at {url} (host, port, VPN, TLS)"),
1531 }),
1532 Self::Auth { status, .. } => Some(match status {
1533 401 => {
1534 "token not recognized — the X-Ignition-API-Token header must be the FULL `name:key` string from the gateway UI (Platform→Security→API Keys); Basic auth does not work on 8.3 /data routes — create an API token"
1538 }
1539 403 => {
1540 "token recognized but under-permitted — Ignition token setup is three parts: (1) token holds an adequate security level, (2) gateway read/write permissions include that level, (3) 'Require secure connections' is unchecked for http gateways; run `ign doctor` for a diagnosis"
1543 }
1544 _ => "check the credential; Ignition token setup is three parts: security level, write permissions, token assignment",
1545 }
1546 .to_string()),
1547 Self::GatewayTooOld { minimum, .. } => {
1548 Some(format!("upgrade the gateway to at least {minimum}"))
1549 }
1550 Self::GatewayNotCommissioned { .. } => Some(
1551 "open http://<host>:<port>/welcome in a browser and complete the \
1552 commissioning wizard"
1553 .to_string(),
1554 ),
1555 Self::GatewayRestarting { .. } => Some(
1556 "wait for readiness with `ign wait restart` or retry in ~1 minute".to_string(),
1557 ),
1558 Self::NotFound { .. } => Some(
1559 "check the id/path; a 404 JSON 'No route match' body can also mean \
1560 a pre-8.3 gateway"
1561 .to_string(),
1562 ),
1563 Self::ProjectExists { .. } => Some(
1564 "the default collision policy refuses to overwrite; re-run with \
1565 --collision-policy overwrite to replace it — overwrite \
1566 REPLACES the ENTIRE project (resources absent from the ZIP \
1567 are deleted; merge is Designer-only)"
1568 .to_string(),
1569 ),
1570 Self::ResourceBinary { .. } => Some(
1571 "resource content is binary — use `ign project export`/`import` \
1572 for data.bin-class resources"
1573 .to_string(),
1574 ),
1575 Self::TrialNotExpired { .. } => Some(
1576 "wait for the trial to expire (watch `ign rig trial status`), or \
1577 run `ign rig reset --yes` for a completely fresh trial volume"
1578 .to_string(),
1579 ),
1580 Self::RoutesNotDeployed { route, .. } => Some(
1581 if route.starts_with(TESTING_ROUTE_PREFIX) {
1589 "run `ign webdev deploy --with-testing` to install the gateway \
1590 testing bundle into the project (or bootstrap it in one shot \
1591 with `ign adopt --project NAME --testing`), then retry"
1592 } else {
1593 "run `ign webdev deploy` to install the CLI's WebDev routes into \
1594 the gateway, then retry"
1595 }
1596 .to_string(),
1597 ),
1598 Self::WebdevUnlicensed { .. } => Some(
1599 "license the gateway — the WebDev module answers 402 while \
1600 unlicensed (on a rig, `ign rig trial reset --yes` restarts an \
1601 expired trial)"
1602 .to_string(),
1603 ),
1604 Self::RouteVersionMismatch { deployed, expected, .. } => {
1605 let newer = semver::Version::parse(deployed)
1610 .ok()
1611 .zip(semver::Version::parse(expected).ok())
1612 .is_some_and(|(deployed, expected)| deployed > expected);
1613 Some(if newer {
1614 "the deployed routes are NEWER than this CLI — update ign \
1615 (the route bundle travels with the binary)"
1616 .to_string()
1617 } else {
1618 "run `ign webdev deploy` to redeploy the route version \
1619 this CLI expects"
1620 .to_string()
1621 })
1622 }
1623 Self::ProviderNotFound { .. } => Some(
1624 "check the provider name; `ign tags provider list` shows the \
1625 gateway's tag providers"
1626 .to_string(),
1627 ),
1628 Self::TagCollision { .. } => Some(
1629 "re-run with --collision-policy overwrite to replace the \
1630 existing tags (destructive: requires --yes)"
1631 .to_string(),
1632 ),
1633 Self::AlarmJournalMissing { .. } => Some(
1634 "alarm history needs a journal profile — provision a database \
1635 connection + alarm-journal profile on the gateway (and point \
1636 the general-alarm-settings singleton at it), then retry; see \
1637 the README 'Alarm history' section"
1638 .to_string(),
1639 ),
1640 Self::SessionNotPrunable { .. } => Some(
1641 "close the Designer first — prune removes stale entries only".to_string(),
1642 ),
1643 Self::WebdevRouteError { code, .. } => Some(if code == "secret_required" || code == "secret_mismatch" {
1644 "the scriptExec route is secret-gated — deploy it with `ign \
1645 webdev deploy --with-script-exec` (the secret is generated \
1646 and stored in the profile config at 0600); a mismatch means \
1647 the route was deployed with a different secret: redeploy or \
1648 pass --rotate-secret"
1649 .to_string()
1650 } else {
1651 "the deployed route refused the action — the code and message \
1652 are the route's stable contract; `ign webdev status` \
1653 diagnoses the deployment"
1654 .to_string()
1655 }),
1656 Self::EamNotController { .. } => Some(
1657 "flip the gateway's EAM role: config-resource PUT on \
1658 com.inductiveautomation.eam/module-settings with \
1659 installMode \"Controller\" (array body carrying the current \
1660 signature) — a manual gateway-role decision this CLI \
1661 deliberately does not automate; see the README 'EAM tasks' \
1662 section"
1663 .to_string(),
1664 ),
1665 Self::ProviderRootUnsupported { .. } => Some(
1666 "target a subtree path like [provider]folder — provider-ROOT \
1667 forms ([default] alone, or a bare provider name) need an \
1668 RPC context WebDev threads don't carry (8.3.3); subtree \
1669 paths are the supported form"
1670 .to_string(),
1671 ),
1672 Self::EamTaskTypeRefused { .. } => Some(
1673 "restore/install/upgrade tasks dispatch fleet-wide (every \
1674 agent target); run them from the Ignition EAM console — \
1675 EXT-03 (v2) will scope them into the CLI"
1676 .to_string(),
1677 ),
1678 Self::EamTaskInFlight { .. } => Some(
1679 "complete or delete the leftover '(forced)' run from the EAM \
1680 console — no ign verb deletes runs; the slot frees once the \
1681 run is resolved"
1682 .to_string(),
1683 ),
1684 Self::ScriptExecNotConfigured { .. } => Some(
1685 "run `ign webdev deploy --with-script-exec` to deploy the route and \
1686 generate + persist its secret (the deploy flag IS the opt-in — \
1687 `ign script run` has no --yes by design)"
1688 .to_string(),
1689 ),
1690 Self::LintToolAbsent => Some(
1691 "install the linter: `uv tool install ignition-lint-toolkit` \
1692 (or `pip install ignition-lint-toolkit`) — \
1693 github.com/TheThoughtagen/ignition-lint; then re-run with \
1694 ignition-lint on PATH"
1695 .to_string(),
1696 ),
1697 Self::NodeToolAbsent { .. } => {
1701 Some(crate::actions::e2e::NODE_INSTALL_HINT.to_string())
1702 }
1703 Self::GatewayClientError { .. } => Some(
1704 "the gateway rejected this request — the body above is the \
1705 gateway's own answer; fix the path/method/body, or use a \
1706 curated `ign` command when one exists"
1707 .to_string(),
1708 ),
1709 Self::BundleNotAvailable { .. } => Some(
1710 "generate a fresh bundle with `ign diagnostics bundle generate`, \
1711 then wait again — the gateway reports no current bundle and \
1712 polling cannot produce one"
1713 .to_string(),
1714 ),
1715 Self::ModuleFeedUnreachable { .. } => Some(
1716 "check network reachability to the module release feed — an \
1717 already-cached version works fully offline, so this only \
1718 blocks a first-time fetch of a new version"
1719 .to_string(),
1720 ),
1721 Self::ModuleReleaseNotFound {
1722 module, version, ..
1723 } => Some(format!(
1724 "check that {module} version {version} exists as a release upstream \
1725 (the tag and asset name must match exactly) — this CLI never falls \
1726 back to \"latest\""
1727 )),
1728 Self::ModuleDigestMismatch(d) => Some(format!(
1729 "the downloaded bytes for {} version {} do not match \
1730 the feed's published digest — do not trust this artifact; nothing \
1731 was cached, so re-running the fetch tries again from scratch",
1732 d.module,
1733 d.version
1734 )),
1735 Self::ModuleFeedUnusable { .. } => Some(
1736 "the module release feed answered but was not usable — if the \
1737 detail names a rate limit, wait and retry; otherwise the feed's \
1738 response itself needs investigation"
1739 .to_string(),
1740 ),
1741 Self::ModuleDigestChanged(d) => Some(format!(
1742 "upstream re-released this version with different bytes — the cached \
1743 artifact at {} is still there and usable offline; re-run with \
1744 FetchPolicy::AcceptUpstreamChange to deliberately accept and verify the \
1745 new bytes",
1746 d.cached_path
1747 )),
1748 Self::Rig(_) => Some(
1749 "check Docker is running and inspect the rig containers \
1750 (docker ps)"
1751 .to_string(),
1752 ),
1753 Self::ModuleNotRegistered { known, .. } => Some(if known.is_empty() {
1754 "no modules are registered in this build of ign".to_string()
1755 } else {
1756 format!(
1757 "known module ids: {} — check the id passed to --with-module \
1758 or declared under [rigs.NAME.modules.*]",
1759 known.join(", ")
1760 )
1761 }),
1762 Self::ModuleUninstallDenied { .. } => Some(
1763 "the gateway refuses to uninstall a module whose .modl is still \
1764 mounted — remove it from [rigs.NAME.modules], run `ign rig up` \
1765 so the mount goes, then retry; a successful uninstall is NOT \
1766 undone by re-declaring the module — recovery today means \
1767 `ign rig reset --yes`, which destroys the rig's data volume"
1768 .to_string(),
1769 ),
1770 Self::ScanLockHeld { .. } => Some(
1771 "the scan-lock is released when the holder's scan runs or its \
1772 holdTimeout expires — wait for that, then retry"
1773 .to_string(),
1774 ),
1775 Self::ResourceSignatureRejected { .. } => Some(
1776 "re-read the resource for its current signature and retry — \
1777 signatures change on every write"
1778 .to_string(),
1779 ),
1780 Self::ResourceExists { .. } => Some(
1781 "a resource of that name is already present — read it for its \
1782 current signature and update it, or choose another name"
1783 .to_string(),
1784 ),
1785 Self::ModeExists { .. } => Some(
1786 "that deployment mode is already defined — list the modes and \
1787 skip the create"
1788 .to_string(),
1789 ),
1790 Self::DeployTreeInvalid(..) => Some(
1791 "fix each file named above in the declared gateway tree; render \
1792 refuses until every finding is resolved"
1793 .to_string(),
1794 ),
1795 Self::EmbeddedSecret(..) => Some(
1796 "store the value in the gateway's secret provider and reference it \
1797 as {\"type\":\"Referenced\",\"data\":{\"providerName\":\"…\",\
1798 \"secretName\":\"…\"}}; never commit an Embedded or plaintext secret"
1799 .to_string(),
1800 ),
1801 Self::TransportFailed { .. } => Some(
1802 "check the node's host access in targets/<env>.yaml: for `local`, that \
1803 data_dir exists and is readable; for `ssh`, the pinned host key, the \
1804 key and reachability. Nothing was read as absent, so no plan was made"
1805 .to_string(),
1806 ),
1807 Self::DeployLedgerInvalid { .. } => Some(
1808 "the delete set is never assumed empty, so no plan was made; inspect or \
1809 restore the ledger file named above (only an apply writes it) and rerun"
1810 .to_string(),
1811 ),
1812 Self::PlanStale { detail, .. } if detail.starts_with("live project") => Some(
1813 "the live project changed after the plan was drawn; run the check again, \
1814 review what it reports and rerun the deploy, nothing was imported"
1815 .to_string(),
1816 ),
1817 Self::PlanStale { .. } => Some(
1818 "the plan changed after it was reviewed; review the new plan and rerun \
1819 with its plan_sha256, nothing was written"
1820 .to_string(),
1821 ),
1822 Self::DestructiveChangeRefused { gate, .. } => Some(match gate.as_str() {
1823 "drift" => "live files differ from both the base render and the declared tree; \
1824 review the drift, then rerun with --allow-drift and a reason of at most \
1825 500 characters"
1826 .to_string(),
1827 "downgrade" => "the gateway last received a newer release of this project; \
1828 deploy the newer release, or rerun with --allow-downgrade when the older \
1829 release must replace it"
1830 .to_string(),
1831 "first_deploy" => "this is the first deploy of the project by this tool and it \
1832 would change what the gateway runs; release what the gateway runs first, \
1833 or rerun with --allow-first-change and a reason"
1834 .to_string(),
1835 _ => "review the destructive entries named above, then rerun with \
1836 --allow-destructive and the plan_sha256 you reviewed"
1837 .to_string(),
1838 }),
1839 Self::DeployBlocked { .. } => Some(
1840 "a core copy still shadows each unit named above, so no flag permits the \
1841 removal; keep the unit in the declared tree, or remove the core copy on \
1842 the gateway first"
1843 .to_string(),
1844 ),
1845 Self::DeployPreflightFailed { .. } => Some(
1846 "fix each failed check named above and rerun; no gateway file was written"
1847 .to_string(),
1848 ),
1849 Self::DeployJournalPresent { .. } => Some(
1850 "an earlier deploy was interrupted and its journal is still on the box; run \
1851 the recovery command in the message, then rerun; plan and apply refuse \
1852 until the journal is cleared"
1853 .to_string(),
1854 ),
1855 Self::DeployLiveChanged { .. } => Some(
1856 "someone changed the files named above after the run wrote them; review \
1857 those changes, then rerun the rollback with --discard-live-changes to \
1858 restore the snapshot over them"
1859 .to_string(),
1860 ),
1861 Self::DeployRunInvalid { .. } => Some(
1862 "the run directory or the on-box record is missing or damaged; restore the \
1863 files from the .gwbk backup or the run-directory snapshot instead"
1864 .to_string(),
1865 ),
1866 Self::RollbackFailed(..) => Some(
1867 "do not apply again until the files are restored: run the retry command in \
1868 the message first, because the journal and the snapshot are usually intact; \
1869 only if it reports a lost or damaged snapshot restore from the .gwbk backup \
1870 or the on-box snapshot named there, and then delete the journal at the path \
1871 in the message by hand"
1872 .to_string(),
1873 ),
1874 Self::ProjectGitManaged { .. } => Some(
1875 "follow the README cutover runbook: remove this project's .git on the \
1876 gateway (the deploy tool never deletes it), then run the check again"
1877 .to_string(),
1878 ),
1879 Self::ProjectParentInvalid { reason, .. } => Some(
1880 match reason.as_str() {
1881 "missing" => "deploy the parent project from its own repo first, then run \
1882 the check again",
1883 "cycle" => "fix the parent in project.json so the inheritance has no cycle",
1884 _ => "the parent is not inheritable; fix the parent in project.json or \
1885 make the parent inheritable",
1886 }
1887 .to_string(),
1888 ),
1889 Self::ProjectHashMismatch { .. } => Some(
1890 "the checkout is not the reviewed one; rerun the check on this commit and \
1891 pass its artifact_sha256"
1892 .to_string(),
1893 ),
1894 }
1895 }
1896
1897 pub fn endpoint(&self) -> Option<String> {
1900 match self {
1901 Self::Network { url, .. } => Some(url.clone()),
1902 Self::Auth { endpoint, .. } => endpoint.clone(),
1903 Self::GatewayTooOld { endpoint, .. }
1904 | Self::GatewayNotCommissioned { endpoint }
1905 | Self::GatewayRestarting { endpoint }
1906 | Self::NotFound { endpoint }
1907 | Self::ProjectExists { endpoint, .. }
1908 | Self::ResourceBinary { endpoint, .. }
1909 | Self::TrialNotExpired { endpoint, .. }
1910 | Self::ProviderNotFound { endpoint, .. }
1911 | Self::RoutesNotDeployed { endpoint, .. }
1912 | Self::WebdevUnlicensed { endpoint }
1913 | Self::RouteVersionMismatch { endpoint, .. }
1914 | Self::WebdevRouteError { endpoint, .. }
1915 | Self::TagCollision { endpoint, .. }
1916 | Self::AlarmJournalMissing { endpoint }
1917 | Self::SessionNotPrunable { endpoint, .. }
1918 | Self::ImportDenied { endpoint, .. }
1919 | Self::EamNotController { endpoint }
1920 | Self::ProviderRootUnsupported { endpoint }
1921 | Self::EamTaskInFlight { endpoint, .. }
1922 | Self::ModuleUninstallDenied { endpoint, .. }
1923 | Self::ScanLockHeld { endpoint, .. }
1924 | Self::ResourceSignatureRejected { endpoint, .. }
1925 | Self::ResourceExists { endpoint, .. }
1926 | Self::ModeExists { endpoint, .. } => endpoint.clone(),
1927 Self::ModuleFeedUnreachable { url, .. }
1928 | Self::ModuleReleaseNotFound { url, .. }
1929 | Self::ModuleFeedUnusable { url, .. } => Some(url.clone()),
1930 Self::ModuleDigestMismatch(d) => Some(d.url.clone()),
1932 _ => None,
1933 }
1934 }
1935
1936 pub const DEPLOY_SLUGS: &'static [&'static str] = &[
1938 "deploy_tree_invalid",
1939 "embedded_secret",
1940 "transport_failed",
1941 "deploy_ledger_invalid",
1942 "plan_stale",
1943 "destructive_change_refused",
1944 "deploy_blocked",
1945 "deploy_preflight_failed",
1946 "deploy_journal_present",
1947 "deploy_live_changed",
1948 "deploy_run_invalid",
1949 "rollback_failed",
1950 "project_git_managed",
1951 "project_parent_invalid",
1952 "project_hash_mismatch",
1953 ];
1954
1955 pub fn deploy_tree_invalid(
1958 file: impl Into<String>,
1959 line: Option<usize>,
1960 column: Option<usize>,
1961 message: impl Into<String>,
1962 ) -> Self {
1963 Self::deploy_findings(vec![DeployFinding {
1964 file: file.into(),
1965 line,
1966 column,
1967 message: message.into(),
1968 }])
1969 }
1970
1971 pub fn deploy_findings(findings: Vec<DeployFinding>) -> Self {
1975 debug_assert!(!findings.is_empty(), "deploy_findings needs a finding");
1976 let findings = if findings.is_empty() {
1977 vec![DeployFinding {
1978 file: String::new(),
1979 line: None,
1980 column: None,
1981 message: "the declared tree is invalid".to_string(),
1982 }]
1983 } else {
1984 findings
1985 };
1986 Self::DeployTreeInvalid(Box::new(DeployTreeInvalidDetails { findings }))
1987 }
1988
1989 pub fn embedded_secret(paths: Vec<String>, other_findings: Vec<DeployFinding>) -> Self {
1993 Self::EmbeddedSecret(Box::new(EmbeddedSecretDetails {
1994 paths,
1995 other_findings,
1996 }))
1997 }
1998
1999 pub fn tui_tty_refusal() -> Self {
2004 Self::InvalidInput {
2005 reason: TUI_TTY_REFUSAL_REASON.to_string(),
2006 }
2007 }
2008
2009 pub fn top_tty_refusal() -> Self {
2013 Self::InvalidInput {
2014 reason: TOP_TTY_REFUSAL_REASON.to_string(),
2015 }
2016 }
2017
2018 pub fn envelope<'a>(&self, profile: Option<&'a str>) -> ErrorEnvelope<'a> {
2022 ErrorEnvelope {
2023 ok: false,
2024 profile,
2025 error: ErrorBody {
2026 code: self.code(),
2027 message: self.to_string(),
2028 endpoint: self.endpoint(),
2029 hint: self.hint(),
2030 },
2031 }
2032 }
2033}
2034
2035#[derive(Debug, Serialize)]
2038pub struct ErrorEnvelope<'a> {
2039 pub ok: bool,
2041 pub profile: Option<&'a str>,
2044 pub error: ErrorBody,
2046}
2047
2048#[derive(Debug, Serialize)]
2051pub struct ErrorBody {
2052 pub code: &'static str,
2054 pub message: String,
2056 pub endpoint: Option<String>,
2058 pub hint: Option<String>,
2060}
2061
2062#[cfg(test)]
2063mod tests {
2064 use super::{
2065 CoreError, DeployFinding, ErrorBody, ErrorEnvelope, GATEWAY_CLIENT_BODY_CAP_BYTES,
2066 GATEWAY_CLIENT_BODY_TRUNCATION_MARKER, LOSS_GATE_REFUSAL_REASON_PREFIX,
2067 ModuleDigestChangedDetails, ModuleDigestMismatchDetails, PreflightFailure, RollbackFailure,
2068 SignatureReason, TESTING_ROUTE_PREFIX, truncate_api_body,
2069 };
2070
2071 fn network_error() -> CoreError {
2075 crate::client::install_crypto_provider();
2082 let rt = tokio::runtime::Builder::new_current_thread()
2083 .enable_all()
2084 .build()
2085 .expect("test runtime");
2086 let url = "http://127.0.0.1:1";
2087 let source = rt
2088 .block_on(reqwest::get(url))
2089 .expect_err("request to an unroutable port must fail");
2090 CoreError::Network {
2091 url: url.to_string(),
2092 source: Some(source),
2093 observation: None,
2094 }
2095 }
2096
2097 fn module_feed_unreachable_error() -> CoreError {
2101 crate::client::install_crypto_provider();
2102 let rt = tokio::runtime::Builder::new_current_thread()
2103 .enable_all()
2104 .build()
2105 .expect("test runtime");
2106 let url = "http://127.0.0.1:1";
2107 let source = rt
2108 .block_on(reqwest::get(url))
2109 .expect_err("request to an unroutable port must fail");
2110 CoreError::ModuleFeedUnreachable {
2111 url: url.to_string(),
2112 source: Some(source),
2113 }
2114 }
2115
2116 #[test]
2120 fn exit_code_mapping_enumerated() {
2121 let cases: Vec<(CoreError, u8, &'static str)> = vec![
2122 (CoreError::Internal("boom".into()), 1, "internal"),
2123 (
2124 CoreError::ConfirmationRequired {
2125 operation: "project download".into(),
2126 },
2127 2,
2128 "confirmation_required",
2129 ),
2130 (
2131 CoreError::ProfilingRiskNotAccepted {
2132 operation: "jvm sample".into(),
2133 },
2134 2,
2135 "profiling_risk_not_accepted",
2136 ),
2137 (
2138 CoreError::InvalidImportFile {
2139 reason: "missing ZIP magic".into(),
2140 },
2141 2,
2142 "invalid_import_file",
2143 ),
2144 (
2145 CoreError::InvalidInput {
2146 reason: "cannot read put.json".into(),
2147 },
2148 2,
2149 "invalid_input",
2150 ),
2151 (
2152 CoreError::ProfileNotFound {
2153 name: "nope".into(),
2154 known: vec!["dev".into()],
2155 },
2156 3,
2157 "profile_not_found",
2158 ),
2159 (CoreError::NoActiveProfile, 3, "no_active_profile"),
2160 (
2161 CoreError::SecretUnavailable {
2162 profile: "dev".into(),
2163 },
2164 3,
2165 "secret_unavailable",
2166 ),
2167 (
2168 CoreError::PasswordUnavailable {
2169 profile: "dev".into(),
2170 },
2171 3,
2172 "password_unavailable",
2173 ),
2174 (
2175 CoreError::ConfigInvalid {
2176 reason: "bad toml".into(),
2177 },
2178 3,
2179 "config_invalid",
2180 ),
2181 (
2182 CoreError::PollIntervalTooSmall {
2183 profile: "dev".into(),
2184 },
2185 3,
2186 "poll_interval_too_small",
2187 ),
2188 (network_error(), 4, "network_error"),
2189 (
2190 CoreError::Auth {
2191 status: 401,
2192 endpoint: None,
2193 },
2194 5,
2195 "auth_rejected",
2196 ),
2197 (
2198 CoreError::GatewayTooOld {
2199 found: "8.1.0".into(),
2200 minimum: "8.3.1".into(),
2201 endpoint: None,
2202 },
2203 6,
2204 "gateway_too_old",
2205 ),
2206 (
2207 CoreError::GatewayNotCommissioned {
2208 endpoint: Some("http://gw:8088/data/api/v1/gateway-info".into()),
2209 },
2210 6,
2211 "gateway_not_commissioned",
2212 ),
2213 (
2214 CoreError::GatewayRestarting {
2215 endpoint: Some("http://gw:8088/data/api/v1/gateway-info".into()),
2216 },
2217 6,
2218 "gateway_restarting",
2219 ),
2220 (
2221 CoreError::NotFound {
2222 endpoint: Some("http://gw:8088/data/api/v1/designer/42".into()),
2223 },
2224 6,
2225 "not_found",
2226 ),
2227 (
2228 CoreError::ProjectExists {
2229 name: "PlantFloor".into(),
2230 endpoint: None,
2231 },
2232 6,
2233 "project_exists",
2234 ),
2235 (
2236 CoreError::ResourceBinary {
2237 path: "com.x/perspective/session-permissions".into(),
2238 endpoint: None,
2239 },
2240 6,
2241 "resource_binary",
2242 ),
2243 (
2244 CoreError::TrialNotExpired {
2245 remaining_s: 6590,
2246 endpoint: Some("http://localhost:9088/data/api/v1/trial".into()),
2247 },
2248 6,
2249 "trial_not_expired",
2250 ),
2251 (
2252 CoreError::ProviderNotFound {
2253 name: "nope".into(),
2254 endpoint: Some("/data/api/v1/resources/find/ignition/tag-provider/nope".into()),
2255 },
2256 6,
2257 "provider_not_found",
2258 ),
2259 (
2260 CoreError::RoutesNotDeployed {
2261 project: "ign-cli".into(),
2262 route: "tags".into(),
2263 endpoint: Some("/system/webdev/ign-cli/cli/tags".into()),
2264 },
2265 6,
2266 "routes_not_deployed",
2267 ),
2268 (
2269 CoreError::WebdevUnlicensed {
2270 endpoint: Some("/system/webdev/ign-cli/cli/tags".into()),
2271 },
2272 6,
2273 "webdev_unlicensed",
2274 ),
2275 (
2276 CoreError::RouteVersionMismatch {
2277 route: "tags".into(),
2278 deployed: "0.9.0".into(),
2279 expected: "1.0.0".into(),
2280 endpoint: Some("/system/webdev/ign-cli/cli/tags".into()),
2281 },
2282 6,
2283 "route_version_mismatch",
2284 ),
2285 (
2286 CoreError::WebdevRouteError {
2287 code: "route_error".into(),
2288 message: "boom".into(),
2289 endpoint: Some("/system/webdev/ign-cli/cli/tags".into()),
2290 },
2291 6,
2292 "webdev_route_error",
2293 ),
2294 (
2295 CoreError::TagCollision {
2296 provider: "p5import".into(),
2297 names: vec!["T1".into(), "P5".into()],
2298 endpoint: Some("/system/webdev/ign-cli/cli/tags".into()),
2299 },
2300 6,
2301 "tag_collision",
2302 ),
2303 (
2304 CoreError::AlarmJournalMissing {
2305 endpoint: Some("/system/webdev/ign-cli/cli/alarms".into()),
2306 },
2307 6,
2308 "alarm_journal_missing",
2309 ),
2310 (
2311 CoreError::ImportDenied {
2312 project: "PlantFloor".into(),
2313 problem: "resource already exists: ResourceId{resourcePath=com.example, collectionName=views}".into(),
2314 endpoint: Some("http://gw:8088/data/api/v1/projects/import/PlantFloor?overwrite=true".into()),
2315 },
2316 6,
2317 "import_denied",
2318 ),
2319 (
2320 CoreError::SessionNotPrunable {
2321 id: "d-live-1".into(),
2322 endpoint: Some("http://gw:8088/data/api/v1/designer/d-live-1".into()),
2323 },
2324 6,
2325 "session_not_prunable",
2326 ),
2327 (
2328 CoreError::EamNotController {
2329 endpoint: Some("http://gw:8088/data/eam/api/v1/eam-tasks/history".into()),
2330 },
2331 6,
2332 "eam_not_controller",
2333 ),
2334 (
2335 CoreError::ProviderRootUnsupported {
2336 endpoint: Some("/system/webdev/ign-cli/cli/tagConfig".into()),
2337 },
2338 6,
2339 "provider_root_unsupported",
2340 ),
2341 (
2342 CoreError::EamTaskTypeRefused {
2343 task_type: "eam_remoteUpgrade".into(),
2344 },
2345 6,
2346 "eam_task_type_refused",
2347 ),
2348 (
2349 CoreError::EamTaskInFlight {
2350 task: "cli-research-backup".into(),
2351 detail: "Task 'cli-research-backup (forced)' already exists! It must be completed or deleted before another task of this type can be force executed.".into(),
2352 endpoint: Some(
2353 "http://gw:8088/data/eam/api/v1/eam-tasks/force/eam/cli-research-backup"
2354 .into(),
2355 ),
2356 },
2357 6,
2358 "eam_task_in_flight",
2359 ),
2360 (
2361 CoreError::ScriptExecNotConfigured {
2362 profile: "dev".into(),
2363 },
2364 6,
2365 "script_exec_not_configured",
2366 ),
2367 (CoreError::LintToolAbsent, 6, "lint_tool_absent"),
2368 (
2369 CoreError::NodeToolAbsent {
2370 tool: "node".into(),
2371 },
2372 6,
2373 "node_tool_absent",
2374 ),
2375 (
2376 CoreError::BundleNotAvailable {
2377 state: "Invalid".into(),
2378 },
2379 6,
2380 "bundle_not_available",
2381 ),
2382 (
2383 CoreError::GatewayClientError {
2384 status: 400,
2385 endpoint: "http://gw:8088/data/api/v1/nonexistent".into(),
2386 body: r#"{"error":{"code":"NOT_FOUND"}}"#.into(),
2387 },
2388 2,
2389 "gateway_client_error",
2390 ),
2391 (CoreError::Rig("compose up failed".into()), 7, "rig_error"),
2392 (
2393 module_feed_unreachable_error(),
2394 4,
2395 "module_feed_unreachable",
2396 ),
2397 (
2398 CoreError::ModuleReleaseNotFound {
2399 module: "git".into(),
2400 version: "99.99.99".into(),
2401 url: "https://api.github.com/repos/WhiskeyHouse/ignition-git-module/releases/tags/v99.99.99".into(),
2402 detail: "no release exists for tag \"v99.99.99\"".into(),
2403 },
2404 6,
2405 "module_release_not_found",
2406 ),
2407 (
2408 CoreError::ModuleDigestMismatch(Box::new(ModuleDigestMismatchDetails {
2409 module: "git".into(),
2410 version: "2.3.4".into(),
2411 url: "https://release-assets.githubusercontent.com/Git-2.3.4-signed.modl".into(),
2412 expected: "b".repeat(64),
2413 actual: "c".repeat(64),
2414 })),
2415 6,
2416 "module_digest_mismatch",
2417 ),
2418 (
2419 CoreError::ModuleFeedUnusable {
2420 url: "https://api.github.com/repos/WhiskeyHouse/ignition-git-module/releases/tags/v2.3.4".into(),
2421 detail: "rate limit exceeded".into(),
2422 },
2423 6,
2424 "module_feed_unusable",
2425 ),
2426 (
2427 CoreError::ModuleDigestChanged(Box::new(ModuleDigestChangedDetails {
2428 module: "git".into(),
2429 version: "2.3.4".into(),
2430 cached_digest: "a".repeat(64),
2431 upstream_digest: "b".repeat(64),
2432 cached_path: "/cache/modules/git/2.3.4-aaaa.modl".into(),
2433 })),
2434 6,
2435 "module_digest_changed",
2436 ),
2437 (
2438 CoreError::ModuleNotRegistered {
2439 id: "bogus".into(),
2440 known: vec!["git".into()],
2441 },
2442 3,
2443 "module_not_registered",
2444 ),
2445 (
2446 CoreError::ModuleUninstallDenied {
2447 module_id: "git".into(),
2448 gateway_module_id: "com.axone_io.ignition.git".into(),
2449 failed: vec!["com.axone_io.ignition.git".into()],
2450 endpoint: Some(
2451 "http://gw:8088/data/api/v1/modules/uninstall".into(),
2452 ),
2453 },
2454 6,
2455 "module_uninstall_denied",
2456 ),
2457 (
2458 CoreError::ScanLockHeld {
2459 actor: Some("api-token:cap-a:172.17.0.1".into()),
2460 kind: "config".into(),
2461 endpoint: Some("http://gw:8088/data/api/v1/scan-lock/config".into()),
2462 },
2463 6,
2464 "scan_lock_held",
2465 ),
2466 (
2467 CoreError::ResourceSignatureRejected {
2468 name: "DB_A".into(),
2469 reason: SignatureReason::Stale,
2470 endpoint: Some(
2471 "http://gw:8088/data/api/v1/resources/find/ignition/database-connection/DB_A"
2472 .into(),
2473 ),
2474 },
2475 6,
2476 "resource_signature_rejected",
2477 ),
2478 (
2479 CoreError::ResourceExists {
2480 name: "DB_A".into(),
2481 endpoint: Some("http://gw:8088/data/api/v1/resources/ignition/database-connection".into()),
2482 },
2483 6,
2484 "resource_exists",
2485 ),
2486 (
2487 CoreError::ModeExists {
2488 name: "production".into(),
2489 endpoint: Some("http://gw:8088/data/api/v1/mode".into()),
2490 },
2491 6,
2492 "mode_exists",
2493 ),
2494 (
2495 CoreError::deploy_tree_invalid("manifest.yaml", Some(2), Some(1), "unknown field"),
2496 2,
2497 "deploy_tree_invalid",
2498 ),
2499 (
2500 CoreError::embedded_secret(vec!["a.json: settings.password".into()], vec![]),
2501 2,
2502 "embedded_secret",
2503 ),
2504 (
2505 CoreError::TransportFailed {
2506 operation: "read".into(),
2507 node: "nodes.master".into(),
2508 detail: "cannot read directory config/resources (NotFound)".into(),
2509 },
2510 4,
2511 "transport_failed",
2512 ),
2513 (
2514 CoreError::DeployLedgerInvalid {
2515 location: "ign-deploy/gw/dev/ledger.json".into(),
2516 problem: "files is not byte-sorted".into(),
2517 },
2518 6,
2519 "deploy_ledger_invalid",
2520 ),
2521 (
2522 CoreError::PlanStale {
2523 expected: "a".repeat(64),
2524 actual: "b".repeat(64),
2525 detail: "review hash".into(),
2526 },
2527 6,
2528 "plan_stale",
2529 ),
2530 (
2531 CoreError::DestructiveChangeRefused {
2532 gate: "destructive".into(),
2533 keys: vec!["ignition/a/x".into()],
2534 count: 1,
2535 },
2536 2,
2537 "destructive_change_refused",
2538 ),
2539 (
2540 CoreError::DeployBlocked {
2541 keys: vec!["ignition/a/x".into()],
2542 },
2543 6,
2544 "deploy_blocked",
2545 ),
2546 (
2547 CoreError::DeployPreflightFailed {
2548 failures: vec![PreflightFailure {
2549 check: "mode_master".into(),
2550 detail: "the gateway is not in the declared mode".into(),
2551 }]
2552 .into(),
2553 },
2554 6,
2555 "deploy_preflight_failed",
2556 ),
2557 (
2558 CoreError::DeployJournalPresent {
2559 run_id: "r1".into(),
2560 env: "prod".into(),
2561 location: "ign-deploy/gw/m/journal.json".into(),
2562 },
2563 6,
2564 "deploy_journal_present",
2565 ),
2566 (
2567 CoreError::DeployLiveChanged {
2568 run_id: "r1".into(),
2569 paths: vec!["a/b.json".into()],
2570 count: 1,
2571 },
2572 6,
2573 "deploy_live_changed",
2574 ),
2575 (
2576 CoreError::DeployRunInvalid {
2577 run_id: "r1".into(),
2578 problem: "record.json is missing".into(),
2579 },
2580 6,
2581 "deploy_run_invalid",
2582 ),
2583 (
2584 CoreError::RollbackFailed(Box::new(RollbackFailure {
2585 run_id: "r1".into(),
2586 env: "dev".into(),
2587 run_dir: Some(".ign/deploy/r1".into()),
2588 journal: "ign-deploy/gw/m/journal.json".into(),
2589 cause: "write_failed".into(),
2590 })),
2591 6,
2592 "rollback_failed",
2593 ),
2594 (
2595 CoreError::ProjectGitManaged {
2596 project: "CapApp".into(),
2597 location: "projects/CapApp/.git".into(),
2598 kind: "directory".into(),
2599 },
2600 6,
2601 "project_git_managed",
2602 ),
2603 (
2604 CoreError::ProjectParentInvalid {
2605 project: "CapApp".into(),
2606 parent: "WHK-Global".into(),
2607 reason: "missing".into(),
2608 },
2609 6,
2610 "project_parent_invalid",
2611 ),
2612 (
2613 CoreError::ProjectHashMismatch {
2614 project: "CapApp".into(),
2615 expected: "0".repeat(64),
2616 actual: "1".repeat(64),
2617 },
2618 6,
2619 "project_hash_mismatch",
2620 ),
2621 ];
2622 for (err, code, slug) in cases {
2623 assert_eq!(err.exit_code(), code, "wrong exit code for: {err}");
2624 assert_eq!(err.code(), slug, "unstable slug for: {err}");
2625 }
2626 }
2627
2628 const EXIT_SLUG_LITERALS: &[(u8, &str)] = &[
2637 (1, "internal"),
2638 (2, "confirmation_required"),
2639 (2, "profiling_risk_not_accepted"),
2640 (2, "invalid_import_file"),
2641 (2, "invalid_input"),
2642 (2, "gateway_client_error"),
2643 (2, "deploy_tree_invalid"),
2644 (2, "embedded_secret"),
2645 (2, "destructive_change_refused"),
2646 (3, "profile_not_found"),
2647 (3, "no_active_profile"),
2648 (3, "secret_unavailable"),
2649 (3, "password_unavailable"),
2650 (3, "config_invalid"),
2651 (3, "poll_interval_too_small"),
2652 (3, "module_not_registered"),
2653 (4, "network_error"),
2654 (5, "auth_rejected"),
2655 (6, "gateway_too_old"),
2656 (6, "gateway_not_commissioned"),
2657 (6, "gateway_restarting"),
2658 (6, "not_found"),
2659 (6, "project_exists"),
2660 (6, "resource_binary"),
2661 (6, "trial_not_expired"),
2662 (6, "provider_not_found"),
2663 (6, "routes_not_deployed"),
2664 (6, "webdev_unlicensed"),
2665 (6, "route_version_mismatch"),
2666 (6, "webdev_route_error"),
2667 (6, "tag_collision"),
2668 (6, "alarm_journal_missing"),
2669 (6, "import_denied"),
2670 (6, "session_not_prunable"),
2671 (6, "eam_not_controller"),
2672 (6, "eam_task_type_refused"),
2673 (6, "eam_task_in_flight"),
2674 (6, "script_exec_not_configured"),
2675 (6, "lint_tool_absent"),
2676 (6, "node_tool_absent"),
2677 (6, "provider_root_unsupported"),
2678 (6, "bundle_not_available"),
2679 (4, "module_feed_unreachable"),
2680 (4, "transport_failed"),
2681 (6, "module_release_not_found"),
2682 (6, "module_digest_mismatch"),
2683 (6, "module_feed_unusable"),
2684 (6, "module_digest_changed"),
2685 (6, "module_uninstall_denied"),
2686 (6, "scan_lock_held"),
2687 (6, "resource_signature_rejected"),
2688 (6, "resource_exists"),
2689 (6, "mode_exists"),
2690 (6, "deploy_ledger_invalid"),
2691 (6, "plan_stale"),
2692 (6, "deploy_blocked"),
2693 (6, "deploy_preflight_failed"),
2694 (6, "deploy_journal_present"),
2695 (6, "deploy_live_changed"),
2696 (6, "deploy_run_invalid"),
2697 (6, "rollback_failed"),
2698 (6, "project_git_managed"),
2699 (6, "project_parent_invalid"),
2700 (6, "project_hash_mismatch"),
2701 (7, "rig_error"),
2702 ];
2703
2704 #[test]
2707 fn deploy_slugs_never_carry_signature_or_problem_text() {
2708 const SAMPLE_SIG: &str = "d80c81d167362252bf389893ea0f3a81479521ba0bb68ee3bfc46e94bc2540b7";
2709
2710 let held = CoreError::ScanLockHeld {
2711 actor: Some("api-token:cap-a:172.17.0.1".into()),
2712 kind: "config".into(),
2713 endpoint: Some("http://gw:8088/data/api/v1/scan-lock/config".into()),
2714 };
2715 assert_eq!(held.code(), "scan_lock_held");
2716 assert_eq!(held.exit_code(), 6);
2717 let shown = held.to_string();
2718 assert!(shown.contains("api-token:cap-a:172.17.0.1"), "{shown}");
2719 assert!(shown.contains("config"), "{shown}");
2720 assert_eq!(
2721 held.endpoint().as_deref(),
2722 Some("http://gw:8088/data/api/v1/scan-lock/config")
2723 );
2724
2725 let anonymous = CoreError::ScanLockHeld {
2726 actor: None,
2727 kind: "config".into(),
2728 endpoint: None,
2729 };
2730 let shown = anonymous.to_string();
2731 assert!(shown.contains("another actor"), "{shown}");
2732 assert!(!shown.contains("None"), "{shown}");
2733
2734 for reason in [SignatureReason::Stale, SignatureReason::Missing] {
2735 let err = CoreError::ResourceSignatureRejected {
2736 name: "DB_A".into(),
2737 reason,
2738 endpoint: Some("http://gw:8088/data/api/v1/resources/x".into()),
2739 };
2740 assert_eq!(err.code(), "resource_signature_rejected");
2741 assert_eq!(err.exit_code(), 6);
2742 let shown = err.to_string();
2743 assert!(shown.contains("DB_A"), "{shown}");
2744 assert!(shown.contains(&reason.to_string()), "{shown}");
2745 assert!(!shown.contains(SAMPLE_SIG), "{shown}");
2746 let hint = err.hint().expect("hint required");
2747 assert!(!hint.contains(SAMPLE_SIG), "{hint}");
2748 let endpoint = err.endpoint().expect("endpoint");
2749 assert!(!endpoint.contains(SAMPLE_SIG), "{endpoint}");
2750 }
2751 assert_eq!(SignatureReason::Stale.to_string(), "stale");
2752 assert_eq!(SignatureReason::Missing.to_string(), "missing");
2753 }
2754
2755 fn parse_readme_exit_table(readme: &str) -> Vec<(u8, Vec<String>)> {
2764 let section = readme.split("## Exit codes").nth(1).unwrap_or_default();
2765 let mut rows = Vec::new();
2766 let table_lines = section
2767 .lines()
2768 .skip_while(|line| !line.trim_start().starts_with('|'));
2769 for line in table_lines {
2770 if !line.trim_start().starts_with('|') {
2771 break; }
2773 let cells: Vec<&str> = line.split('|').map(str::trim).collect();
2774 let Some(exit) = cells.get(1).and_then(|cell| cell.parse::<u8>().ok()) else {
2775 continue;
2776 };
2777 if !(1..=7).contains(&exit) {
2778 continue;
2779 }
2780 let slug_cell = cells
2781 .iter()
2782 .rev()
2783 .find(|cell| !cell.is_empty())
2784 .copied()
2785 .unwrap_or_default();
2786 let slugs: Vec<String> = slug_cell
2787 .split('`')
2788 .enumerate()
2789 .filter(|(idx, _)| idx % 2 == 1)
2790 .map(|(_, token)| token.trim().to_string())
2791 .filter(|token| {
2792 token.starts_with(|c: char| c.is_ascii_alphabetic()) && !token.contains(' ')
2793 })
2794 .collect();
2795 rows.push((exit, slugs));
2796 }
2797 rows
2798 }
2799
2800 #[test]
2811 fn readme_exit_table_agreement() {
2812 let readme = include_str!("../../../README.md");
2813 let rows = parse_readme_exit_table(readme);
2814 assert!(
2815 rows.len() >= 7,
2816 "README exit-code table not found — the parser must see all 7 \
2817 failure-class rows (found {})",
2818 rows.len()
2819 );
2820
2821 for (exit, slug) in EXIT_SLUG_LITERALS {
2824 let row = rows
2825 .iter()
2826 .find(|(readme_exit, _)| readme_exit == exit)
2827 .unwrap_or_else(|| panic!("README table has no row for exit {exit}"));
2828 assert!(
2829 row.1.iter().any(|s| s == slug),
2830 "README exit-{exit} row is missing slug {slug:?} (row: {:?})",
2831 row.1
2832 );
2833 }
2834
2835 for (exit, slugs) in &rows {
2838 for slug in slugs {
2839 assert!(
2840 EXIT_SLUG_LITERALS
2841 .iter()
2842 .any(|(lit_exit, lit_slug)| lit_exit == exit && lit_slug == slug),
2843 "README exit-{exit} row carries slug {slug:?} that no \
2844 CoreError variant emits — stale table row or slug/exit \
2845 drift between README and error.rs"
2846 );
2847 }
2848 }
2849 }
2850
2851 #[test]
2854 fn hints_are_actionable_for_config_auth_target_state() {
2855 let profile_not_found = CoreError::ProfileNotFound {
2856 name: "x".into(),
2857 known: vec!["dev".into(), "prod".into()],
2858 };
2859 let hint = profile_not_found.hint().expect("hint required");
2860 assert!(
2861 hint.contains("dev"),
2862 "hint must list known profiles: {hint}"
2863 );
2864 assert!(
2865 hint.contains("ign profile add"),
2866 "hint must name the fix: {hint}"
2867 );
2868
2869 let auth = CoreError::Auth {
2870 status: 403,
2871 endpoint: None,
2872 };
2873 let hint = auth.hint().expect("hint required");
2874 assert!(
2875 hint.contains("three parts"),
2876 "auth hint must name the three-part token setup: {hint}"
2877 );
2878
2879 let unauthorized = CoreError::Auth {
2882 status: 401,
2883 endpoint: None,
2884 };
2885 let hint = unauthorized.hint().expect("hint required");
2886 assert!(
2887 hint.contains("name:key"),
2888 "401 hint must name the full name:key token format: {hint}"
2889 );
2890 assert!(
2891 hint.contains("API token"),
2892 "401 hint must say Basic cannot work: {hint}"
2893 );
2894 let hint403 = auth.hint().expect("hint required");
2895 assert!(
2896 hint403.contains("secure connections"),
2897 "403 hint must name the secure-channel part: {hint403}"
2898 );
2899
2900 let too_old = CoreError::GatewayTooOld {
2901 found: "8.1.0".into(),
2902 minimum: "8.3.1".into(),
2903 endpoint: None,
2904 };
2905 let hint = too_old.hint().expect("hint required");
2906 assert!(
2907 hint.contains("8.3.1"),
2908 "target-state hint must name the minimum: {hint}"
2909 );
2910
2911 let undeployed = CoreError::RoutesNotDeployed {
2915 project: "ign-cli".into(),
2916 route: "tags".into(),
2917 endpoint: None,
2918 };
2919 let hint = undeployed.hint().expect("hint required");
2920 assert!(
2921 hint.contains("ign webdev deploy"),
2922 "absent-routes hint must name the fix: {hint}"
2923 );
2924
2925 let older = CoreError::RouteVersionMismatch {
2926 route: "tags".into(),
2927 deployed: "0.9.0".into(),
2928 expected: "1.0.0".into(),
2929 endpoint: None,
2930 };
2931 let hint = older.hint().expect("hint required");
2932 assert!(
2933 hint.contains("ign webdev deploy") && !hint.contains("update ign"),
2934 "older-route hint says redeploy: {hint}"
2935 );
2936
2937 let newer = CoreError::RouteVersionMismatch {
2938 route: "tags".into(),
2939 deployed: "1.1.0".into(),
2940 expected: "1.0.0".into(),
2941 endpoint: None,
2942 };
2943 let hint = newer.hint().expect("hint required");
2944 assert!(
2945 hint.contains("update ign") && !hint.contains("ign webdev deploy"),
2946 "newer-route hint says update ign: {hint}"
2947 );
2948
2949 let secret_gate = CoreError::WebdevRouteError {
2950 code: "secret_required".into(),
2951 message: "missing x-ignition-cli-secret header".into(),
2952 endpoint: None,
2953 };
2954 let hint = secret_gate.hint().expect("hint required");
2955 assert!(
2956 hint.contains("--with-script-exec"),
2957 "secret-gate hint names the deploy flag: {hint}"
2958 );
2959
2960 let journal = CoreError::AlarmJournalMissing { endpoint: None };
2963 let hint = journal.hint().expect("hint required");
2964 assert!(
2965 hint.contains("journal profile") && hint.contains("database connection"),
2966 "journal hint names the chain: {hint}"
2967 );
2968 assert!(
2969 hint.contains("README"),
2970 "journal hint points at the README section: {hint}"
2971 );
2972
2973 let prunable = CoreError::SessionNotPrunable {
2976 id: "d-live-1".into(),
2977 endpoint: None,
2978 };
2979 let hint = prunable.hint().expect("hint required");
2980 assert!(
2981 hint.contains("close the Designer"),
2982 "prune hint must name the action: {hint}"
2983 );
2984 assert!(
2985 hint.contains("stale entries"),
2986 "prune hint must name stale-only semantics: {hint}"
2987 );
2988
2989 let held = CoreError::ScanLockHeld {
2992 actor: None,
2993 kind: "config".into(),
2994 endpoint: None,
2995 };
2996 let hint = held.hint().expect("hint required");
2997 assert!(
2998 hint.contains("holdTimeout") && hint.contains("scan runs"),
2999 "scan-lock hint names the release conditions: {hint}"
3000 );
3001 assert!(
3002 !hint.contains("ign "),
3003 "scan-lock hint names no ign command: {hint}"
3004 );
3005
3006 let rejected = CoreError::ResourceSignatureRejected {
3008 name: "DB_A".into(),
3009 reason: SignatureReason::Stale,
3010 endpoint: None,
3011 };
3012 let hint = rejected.hint().expect("hint required");
3013 assert!(
3014 hint.contains("re-read") && hint.contains("signatures change on every write"),
3015 "signature hint names the action: {hint}"
3016 );
3017
3018 let exists = CoreError::ResourceExists {
3021 name: "DB_A".into(),
3022 endpoint: None,
3023 };
3024 let hint = exists.hint().expect("hint required");
3025 assert!(
3026 hint.contains("update it"),
3027 "resource_exists hint names the action: {hint}"
3028 );
3029 assert_eq!(exists.to_string(), "resource \"DB_A\" already exists");
3030 let mode_exists = CoreError::ModeExists {
3031 name: "production".into(),
3032 endpoint: None,
3033 };
3034 let hint = mode_exists.hint().expect("hint required");
3035 assert!(
3036 hint.contains("skip the create"),
3037 "mode_exists hint names the action: {hint}"
3038 );
3039
3040 let tty = CoreError::tui_tty_refusal();
3045 assert_eq!(tty.code(), "invalid_input", "slug unchanged");
3046 assert_eq!(tty.exit_code(), 2, "usage class unchanged");
3047 let hint = tty.hint().expect("hint required");
3048 assert!(
3049 hint.contains("interactive terminal"),
3050 "TTY hint must name the fix: {hint}"
3051 );
3052 assert!(
3053 !hint.contains("--file"),
3054 "TTY hint must not carry the resource-put hint: {hint}"
3055 );
3056
3057 let top = CoreError::top_tty_refusal();
3062 assert_eq!(top.code(), "invalid_input", "slug unchanged");
3063 assert_eq!(top.exit_code(), 2, "usage class unchanged");
3064 let top_hint = top.hint().expect("hint required");
3065 assert!(
3066 top_hint.contains("`ign top`"),
3067 "the top refusal's hint names ign top: {top_hint}"
3068 );
3069 assert!(
3070 !top_hint.contains("ign tui"),
3071 "the top refusal never names the cockpit command: {top_hint}"
3072 );
3073 assert!(
3074 top_hint.contains("interactive terminal"),
3075 "the top refusal keeps the terminal-contextual fix: {top_hint}"
3076 );
3077 let put = CoreError::InvalidInput {
3078 reason: "cannot read put.json".into(),
3079 };
3080 let hint = put.hint().expect("hint required");
3081 assert!(
3082 hint.contains("--file") && hint.contains("stdin"),
3083 "resource-put hint unchanged: {hint}"
3084 );
3085
3086 let controller = CoreError::EamNotController { endpoint: None };
3090 let hint = controller.hint().expect("hint required");
3091 assert!(
3092 hint.contains("installMode"),
3093 "controller hint names the flip: {hint}"
3094 );
3095 assert!(
3096 hint.contains("Controller"),
3097 "controller hint names the target role: {hint}"
3098 );
3099 assert!(
3100 hint.contains("README"),
3101 "controller hint points at the README section: {hint}"
3102 );
3103
3104 let refused = CoreError::EamTaskTypeRefused {
3107 task_type: "eam_restoreBackup".into(),
3108 };
3109 assert_eq!(refused.exit_code(), 6, "target state");
3110 let hint = refused.hint().expect("hint required");
3111 assert!(
3112 hint.contains("EAM console"),
3113 "refused hint names where to run it: {hint}"
3114 );
3115 assert!(
3116 hint.contains("fleet-wide") || hint.contains("EXT-03"),
3117 "refused hint names the fleet consequence / scope: {hint}"
3118 );
3119
3120 let in_flight = CoreError::EamTaskInFlight {
3124 task: "cli-research-backup".into(),
3125 detail: "the previous '(forced)' run must be completed or deleted first".into(),
3126 endpoint: None,
3127 };
3128 assert_eq!(in_flight.exit_code(), 6, "target state");
3129 let hint = in_flight.hint().expect("hint required");
3130 assert!(
3131 hint.contains("EAM console"),
3132 "in-flight hint names where to resolve it: {hint}"
3133 );
3134 assert!(
3135 hint.contains("complete or delete"),
3136 "in-flight hint names the resolution: {hint}"
3137 );
3138
3139 let unconfigured = CoreError::ScriptExecNotConfigured {
3143 profile: "dev".into(),
3144 };
3145 assert_eq!(unconfigured.exit_code(), 6, "target state");
3146 let hint = unconfigured.hint().expect("hint required");
3147 assert!(
3148 hint.contains("ign webdev deploy --with-script-exec"),
3149 "the hint names the deploy flag verbatim: {hint}"
3150 );
3151
3152 let no_active = CoreError::NoActiveProfile;
3154 assert!(no_active.hint().is_some());
3155 }
3156
3157 #[test]
3162 fn error_envelope_locked_shape_and_endpoint() {
3163 let auth = CoreError::Auth {
3164 status: 401,
3165 endpoint: Some("https://gw.example.com/data/api/v1/gateway-info".into()),
3166 };
3167 let envelope: ErrorEnvelope<'_> = auth.envelope(Some("dev"));
3168 let json = serde_json::to_string(&envelope).expect("serialize envelope");
3169
3170 assert_eq!(
3171 json,
3172 concat!(
3173 r#"{"ok":false,"profile":"dev","error":{"code":"auth_rejected","#,
3174 r#""message":"gateway rejected credentials (HTTP 401)","#,
3175 r#""endpoint":"https://gw.example.com/data/api/v1/gateway-info","#,
3176 r#""hint":"token not recognized — the X-Ignition-API-Token header must be the FULL `name:key` string from the gateway UI (Platform→Security→API Keys); Basic auth does not work on 8.3 /data routes — create an API token"}}"#
3177 )
3178 );
3179
3180 let no_request = CoreError::NoActiveProfile;
3182 let body: &ErrorBody = &no_request.envelope(None).error;
3183 assert_eq!(body.endpoint, None);
3184 }
3185
3186 #[test]
3192 fn truncates_at_cap_with_marker() {
3193 let short = r#"{"error":{"code":"NOT_FOUND"}}"#;
3195 assert_eq!(truncate_api_body(short), short);
3196 assert!(!short.contains(GATEWAY_CLIENT_BODY_TRUNCATION_MARKER));
3197
3198 let exact = "x".repeat(GATEWAY_CLIENT_BODY_CAP_BYTES);
3201 assert_eq!(truncate_api_body(&exact), exact);
3202
3203 let over = "x".repeat(GATEWAY_CLIENT_BODY_CAP_BYTES + 1);
3205 let truncated = truncate_api_body(&over);
3206 assert!(
3207 truncated.ends_with(GATEWAY_CLIENT_BODY_TRUNCATION_MARKER),
3208 "truncated body must end with the explicit marker"
3209 );
3210 assert!(
3211 truncated.len()
3212 <= GATEWAY_CLIENT_BODY_CAP_BYTES + GATEWAY_CLIENT_BODY_TRUNCATION_MARKER.len(),
3213 "truncated body must stay within cap + marker: {}",
3214 truncated.len()
3215 );
3216
3217 let multibyte = "é".repeat(GATEWAY_CLIENT_BODY_CAP_BYTES); let truncated = truncate_api_body(&multibyte);
3222 assert!(truncated.ends_with(GATEWAY_CLIENT_BODY_TRUNCATION_MARKER));
3223 assert!(
3224 truncated
3225 .is_char_boundary(truncated.len() - GATEWAY_CLIENT_BODY_TRUNCATION_MARKER.len())
3226 );
3227 }
3228
3229 #[test]
3238 fn loss_gate_hint_override_and_neighbors_unchanged() {
3239 let loss_gate = CoreError::InvalidInput {
3242 reason: format!(
3243 "{}xml): the scan reports 2 finding(s) before the import:\nre-run \
3244 with --yes to import anyway",
3245 LOSS_GATE_REFUSAL_REASON_PREFIX
3246 ),
3247 };
3248 assert_eq!(loss_gate.code(), "invalid_input", "slug unchanged");
3249 assert_eq!(loss_gate.exit_code(), 2, "usage class unchanged");
3250 let hint = loss_gate.hint().expect("hint required");
3251 assert!(
3252 hint.contains("--yes"),
3253 "loss-gate hint must name the --yes re-run: {hint}"
3254 );
3255 assert!(
3256 hint.contains("loss report above"),
3257 "loss-gate hint must point back at the report: {hint}"
3258 );
3259 assert!(
3260 !hint.contains("fix the input source"),
3261 "the file-read default must never leak onto the loss-gate path: {hint}"
3262 );
3263
3264 let generic = CoreError::InvalidInput {
3267 reason: "x is not valid JSON: expected value at line 1".into(),
3268 };
3269 let hint = generic.hint().expect("hint required");
3270 assert!(
3271 hint.contains("--file") && hint.contains("stdin"),
3272 "resource-put hint unchanged: {hint}"
3273 );
3274 assert!(
3275 !hint.contains("--yes"),
3276 "the --yes hint must not leak onto generic reasons: {hint}"
3277 );
3278
3279 let tty = CoreError::tui_tty_refusal();
3281 let hint = tty.hint().expect("hint required");
3282 assert!(
3283 hint.contains("interactive terminal"),
3284 "TTY hint unchanged: {hint}"
3285 );
3286 assert!(
3287 !hint.contains("--yes"),
3288 "the --yes hint must not leak onto the TTY path: {hint}"
3289 );
3290 }
3291
3292 #[test]
3300 fn routes_not_deployed_hint_is_testing_scoped() {
3301 let testing = CoreError::RoutesNotDeployed {
3302 project: "Flux".into(),
3303 route: format!("{TESTING_ROUTE_PREFIX}run"),
3304 endpoint: None,
3305 };
3306 assert_eq!(testing.code(), "routes_not_deployed", "slug unchanged");
3307 assert_eq!(testing.exit_code(), 6, "target-state class unchanged");
3308 let hint = testing.hint().expect("hint required");
3309 assert!(
3310 hint.contains("ign webdev deploy --with-testing"),
3311 "testing hint must name the deploy flag: {hint}"
3312 );
3313 assert!(
3314 hint.contains("ign adopt") && hint.contains("--testing"),
3315 "testing hint must name the adopt path: {hint}"
3316 );
3317
3318 let generic = CoreError::RoutesNotDeployed {
3320 project: "ign-cli".into(),
3321 route: "tags".into(),
3322 endpoint: None,
3323 };
3324 let hint = generic.hint().expect("hint required");
3325 assert_eq!(
3326 hint,
3327 "run `ign webdev deploy` to install the CLI's WebDev routes into \
3328 the gateway, then retry",
3329 "the generic branch is byte-identical to its pre-QUICK-p0g text"
3330 );
3331 }
3332
3333 fn finding(file: &str, line: Option<usize>, column: Option<usize>, msg: &str) -> DeployFinding {
3334 DeployFinding {
3335 file: file.into(),
3336 line,
3337 column,
3338 message: msg.into(),
3339 }
3340 }
3341
3342 #[test]
3343 fn deploy_finding_display_names_file_line_column() {
3344 let positioned = finding("targets/prod.yaml", Some(3), Some(1), "unknown field `x`");
3345 assert_eq!(
3346 positioned.to_string(),
3347 "targets/prod.yaml:3:1: unknown field `x`"
3348 );
3349 let bare = finding("targets/prod.yaml", None, None, "missing field `gateway`");
3350 assert_eq!(
3351 bare.to_string(),
3352 "targets/prod.yaml: missing field `gateway`"
3353 );
3354 let half = finding("m.yaml", Some(3), None, "m");
3356 assert_eq!(half.to_string(), "m.yaml: m");
3357 }
3358
3359 #[test]
3360 fn deploy_findings_cap_at_twenty() {
3361 let many: Vec<DeployFinding> = (0..25)
3362 .map(|i| finding("manifest.yaml", None, None, &format!("problem {i}")))
3363 .collect();
3364 let shown = CoreError::deploy_findings(many).to_string();
3365 assert!(shown.contains("25 findings: "), "{shown}");
3366 assert!(shown.contains("problem 19"), "{shown}");
3367 assert!(!shown.contains("problem 20"), "{shown}");
3368 assert!(shown.ends_with("; and 5 more"), "{shown}");
3369
3370 let single = CoreError::deploy_tree_invalid("m.yaml", Some(2), Some(1), "bad");
3371 assert_eq!(single.to_string(), "deploy tree invalid: m.yaml:2:1: bad");
3372 }
3373
3374 #[test]
3375 fn embedded_secret_display_lists_paths_only() {
3376 let err = CoreError::embedded_secret(
3377 vec![
3378 "ignition/database-connection/A/config.json: settings.password".into(),
3379 "ignition/database-connection/B/config.json: settings.password".into(),
3380 ],
3381 vec![finding("manifest.yaml", None, None, "duplicate type")],
3382 );
3383 let shown = err.to_string();
3384 assert!(shown.contains("2 path(s)"), "{shown}");
3385 assert!(
3386 shown.contains("A/config.json: settings.password"),
3387 "{shown}"
3388 );
3389 assert!(
3390 shown.contains("B/config.json: settings.password"),
3391 "{shown}"
3392 );
3393 assert!(shown.contains("also 1 other tree finding(s)"), "{shown}");
3394 assert!(shown.contains("manifest.yaml: duplicate type"), "{shown}");
3395
3396 let paths: Vec<String> = (0..25).map(|i| format!("f{i}.json: a.b")).collect();
3398 let shown =
3399 CoreError::embedded_secret(paths, vec![finding("m.yaml", None, None, "x")]).to_string();
3400 assert!(shown.contains("25 path(s)"), "{shown}");
3401 assert!(shown.contains("and 5 more"), "{shown}");
3402 assert!(shown.contains("f19.json: a.b"), "{shown}");
3403 assert!(!shown.contains("f20.json: a.b"), "{shown}");
3404 }
3405
3406 #[test]
3407 fn deploy_slugs_have_their_exit_codes() {
3408 let errs = [
3409 (
3410 CoreError::deploy_tree_invalid("m.yaml", None, None, "bad"),
3411 2,
3412 ),
3413 (
3414 CoreError::embedded_secret(vec!["a.json: p".into()], vec![]),
3415 2,
3416 ),
3417 (
3418 CoreError::TransportFailed {
3419 operation: "read".into(),
3420 node: "nodes.master".into(),
3421 detail: "data_dir is missing".into(),
3422 },
3423 4,
3424 ),
3425 (
3426 CoreError::DeployLedgerInvalid {
3427 location: "ign-deploy/gw/dev/ledger.json".into(),
3428 problem: "the file is empty".into(),
3429 },
3430 6,
3431 ),
3432 (
3433 CoreError::PlanStale {
3434 expected: "a".repeat(64),
3435 actual: "b".repeat(64),
3436 detail: "review hash".into(),
3437 },
3438 6,
3439 ),
3440 (
3441 CoreError::DestructiveChangeRefused {
3442 gate: "destructive".into(),
3443 keys: vec!["ignition/a/x".into()],
3444 count: 1,
3445 },
3446 2,
3447 ),
3448 (
3449 CoreError::DeployBlocked {
3450 keys: vec!["ignition/a/x".into()],
3451 },
3452 6,
3453 ),
3454 (
3455 CoreError::DeployPreflightFailed {
3456 failures: vec![PreflightFailure {
3457 check: "mode_master".into(),
3458 detail: "the gateway is not in the declared mode".into(),
3459 }]
3460 .into(),
3461 },
3462 6,
3463 ),
3464 (
3465 CoreError::DeployJournalPresent {
3466 run_id: "20261015T142233Z-a1b2c3".into(),
3467 env: "prod".into(),
3468 location: "ign-deploy/gw/m/journal.json".into(),
3469 },
3470 6,
3471 ),
3472 (
3473 CoreError::DeployLiveChanged {
3474 run_id: "r1".into(),
3475 paths: vec!["a/b.json".into()],
3476 count: 1,
3477 },
3478 6,
3479 ),
3480 (
3481 CoreError::DeployRunInvalid {
3482 run_id: "r1".into(),
3483 problem: "record.json is missing".into(),
3484 },
3485 6,
3486 ),
3487 (
3488 CoreError::RollbackFailed(Box::new(RollbackFailure {
3489 run_id: "r1".into(),
3490 env: "dev".into(),
3491 run_dir: Some(".ign/deploy/r1".into()),
3492 journal: "ign-deploy/gw/m/journal.json".into(),
3493 cause: "write_failed".into(),
3494 })),
3495 6,
3496 ),
3497 (
3498 CoreError::ProjectGitManaged {
3499 project: "CapApp".into(),
3500 location: "projects/CapApp/.git".into(),
3501 kind: "directory".into(),
3502 },
3503 6,
3504 ),
3505 (
3506 CoreError::ProjectParentInvalid {
3507 project: "CapApp".into(),
3508 parent: "WHK-Global".into(),
3509 reason: "cycle".into(),
3510 },
3511 6,
3512 ),
3513 (
3514 CoreError::ProjectHashMismatch {
3515 project: "CapApp".into(),
3516 expected: "0".repeat(64),
3517 actual: "1".repeat(64),
3518 },
3519 6,
3520 ),
3521 ];
3522 let codes: Vec<&str> = errs.iter().map(|(e, _)| e.code()).collect();
3523 assert_eq!(codes, CoreError::DEPLOY_SLUGS);
3524 for (err, exit) in &errs {
3525 assert_eq!(err.exit_code(), *exit, "{err}");
3526 assert_eq!(err.endpoint(), None, "deploy errors name no URL: {err}");
3527 let hint = err.hint().expect("hint required");
3528 assert!(
3529 !hint.contains("`ign ") && !hint.contains("ign "),
3530 "hint must not name an ign subcommand: {hint}"
3531 );
3532 }
3533 }
3534
3535 #[test]
3536 fn apply_gate_flag_refusals_do_not_get_the_file_hint() {
3537 for reason in [
3538 "--plan-sha256 must be 64 lowercase hex characters",
3539 "--allow-drift reason must not be empty",
3540 "--artifact-sha256 must be 64 lowercase hex characters",
3541 "--release <tag>: not a semver tag (for example v1.4.0)",
3542 "--allow-first-change <reason>: the reason is 1 to 500 characters",
3543 "deploy projects needs --artifact-sha256 when --json is given",
3544 ] {
3545 let hint = CoreError::InvalidInput {
3546 reason: reason.into(),
3547 }
3548 .hint()
3549 .expect("hint");
3550 assert!(!hint.contains("--file"), "{hint}");
3551 assert!(hint.contains("flag value"), "{hint}");
3552 }
3553 }
3554
3555 #[test]
3560 fn deploy_slugs_are_pinned_and_never_a_verdict() {
3561 assert_eq!(
3562 CoreError::DEPLOY_SLUGS,
3563 &[
3564 "deploy_tree_invalid",
3565 "embedded_secret",
3566 "transport_failed",
3567 "deploy_ledger_invalid",
3568 "plan_stale",
3569 "destructive_change_refused",
3570 "deploy_blocked",
3571 "deploy_preflight_failed",
3572 "deploy_journal_present",
3573 "deploy_live_changed",
3574 "deploy_run_invalid",
3575 "rollback_failed",
3576 "project_git_managed",
3577 "project_parent_invalid",
3578 "project_hash_mismatch",
3579 ]
3580 );
3581 assert_eq!(CoreError::DEPLOY_SLUGS.len(), 15);
3582 for verdict in [
3583 "deploy_rolled_back",
3584 "deploy_backup_unsynced",
3585 "deploy_project_unverified",
3586 ] {
3587 assert!(!CoreError::DEPLOY_SLUGS.contains(&verdict), "{verdict}");
3588 }
3589 }
3590
3591 #[test]
3594 fn project_git_managed_four_places() {
3595 let err = CoreError::ProjectGitManaged {
3596 project: "CapApp".into(),
3597 location: "projects/CapApp/.git".into(),
3598 kind: "directory".into(),
3599 };
3600 assert_eq!(err.code(), "project_git_managed");
3601 assert_eq!(err.exit_code(), 6);
3602 let message = err.to_string();
3603 for needle in [
3604 "CapApp",
3605 "projects/CapApp/.git",
3606 "directory",
3607 "administrator",
3608 ] {
3609 assert!(message.contains(needle), "{needle}: {message}");
3610 }
3611 let hint = err.hint().expect("hint required");
3612 assert!(!hint.contains("ign "), "{hint}");
3613 assert!(hint.contains(".git"), "{hint}");
3614 assert!(CoreError::DEPLOY_SLUGS.contains(&"project_git_managed"));
3615 }
3616
3617 #[test]
3621 fn project_parent_invalid_four_places() {
3622 for (reason, needle) in [
3623 ("missing", "its own repo"),
3624 ("cycle", "no cycle"),
3625 ("not_inheritable", "inheritable"),
3626 ] {
3627 let err = CoreError::ProjectParentInvalid {
3628 project: "CapApp".into(),
3629 parent: "WHK-Global".into(),
3630 reason: reason.into(),
3631 };
3632 assert_eq!(err.code(), "project_parent_invalid");
3633 assert_eq!(err.exit_code(), 6);
3634 let message = err.to_string();
3635 for part in ["CapApp", "WHK-Global", reason] {
3636 assert!(message.contains(part), "{part}: {message}");
3637 }
3638 let hint = err.hint().expect("hint required");
3639 assert!(!hint.contains("ign "), "{hint}");
3640 assert!(hint.contains(needle), "{reason}: {hint}");
3641 assert_eq!(err.endpoint(), None);
3642 }
3643 assert!(CoreError::DEPLOY_SLUGS.contains(&"project_parent_invalid"));
3644 }
3645
3646 #[test]
3650 fn project_hash_mismatch_four_places() {
3651 let err = CoreError::ProjectHashMismatch {
3652 project: "CapApp".into(),
3653 expected: "0".repeat(64),
3654 actual: "1".repeat(64),
3655 };
3656 assert_eq!(err.code(), "project_hash_mismatch");
3657 assert_eq!(err.exit_code(), 6);
3658 let message = err.to_string();
3659 for part in [
3660 "CapApp",
3661 "0000000000000000000000000000000000000000000000000000000000000000",
3662 "1111111111111111111111111111111111111111111111111111111111111111",
3663 "nothing was uploaded",
3664 ] {
3665 assert!(message.contains(part), "{part}: {message}");
3666 }
3667 let hint = err.hint().expect("hint required");
3668 assert!(!hint.contains("ign "), "{hint}");
3669 assert!(hint.contains("artifact_sha256"), "{hint}");
3670 assert_eq!(err.endpoint(), None);
3671 assert!(CoreError::DEPLOY_SLUGS.contains(&"project_hash_mismatch"));
3672 }
3673
3674 #[test]
3677 fn plan_stale_hint_names_the_live_project_case() {
3678 let live = CoreError::PlanStale {
3679 expected: "a".repeat(64),
3680 actual: "b".repeat(64),
3681 detail: "live project changed since the plan".into(),
3682 };
3683 let hint = live.hint().expect("hint");
3684 assert!(hint.contains("live project"), "{hint}");
3685 assert!(hint.contains("nothing was imported"), "{hint}");
3686 assert!(!hint.contains("ign "), "{hint}");
3687 let review = CoreError::PlanStale {
3688 expected: "a".repeat(64),
3689 actual: "b".repeat(64),
3690 detail: "review hash".into(),
3691 };
3692 assert!(
3693 review.hint().expect("hint").contains("plan_sha256"),
3694 "the plan-hash case keeps its hint"
3695 );
3696 }
3697
3698 #[test]
3699 fn journal_present_message_carries_the_recovery_command() {
3700 let err = CoreError::DeployJournalPresent {
3701 run_id: "20261015T142233Z-a1b2c3".into(),
3702 env: "prod".into(),
3703 location: "ign-deploy/gw/m/journal.json".into(),
3704 };
3705 let shown = err.to_string();
3706 assert!(
3707 shown.contains("ign deploy rollback --run 20261015T142233Z-a1b2c3 --env prod"),
3708 "{shown}"
3709 );
3710 assert!(shown.contains("ign-deploy/gw/m/journal.json"), "{shown}");
3711 let hint = err.hint().expect("hint");
3712 assert!(
3713 !hint.contains("ign deploy"),
3714 "D-09: the command lives in Display: {hint}"
3715 );
3716 }
3717
3718 #[test]
3719 fn rollback_failed_message_names_run_dir_journal_and_the_guidance() {
3720 let err = CoreError::RollbackFailed(Box::new(RollbackFailure {
3721 run_id: "r-7".into(),
3722 env: "prod".into(),
3723 run_dir: Some(".ign/deploy/r-7".into()),
3724 journal: "ign-deploy/gw/m/journal.json".into(),
3725 cause: "write_failed".into(),
3726 }));
3727 let shown = err.to_string();
3728 for needle in [
3729 "r-7",
3730 ".ign/deploy/r-7",
3731 "ign-deploy/gw/m/journal.json",
3732 ".gwbk",
3733 "snapshot",
3734 "by hand",
3735 "ign deploy rollback --run r-7 --env prod",
3738 "ign-deploy/gw/m/snapshots/r-7/",
3739 ] {
3740 assert!(shown.contains(needle), "{needle} missing from: {shown}");
3741 }
3742 assert!(
3743 shown.find("Retry with").expect("retry") < shown.find("by hand").expect("by hand"),
3744 "the retry precedes the hand deletion: {shown}"
3745 );
3746 assert!(
3747 !shown.contains("run-directory snapshot"),
3748 "the run directory has no snapshot to offer: {shown}"
3749 );
3750 let hint = err.hint().expect("hint");
3751 assert!(
3752 hint.contains("retry") && !hint.contains("run-directory snapshot"),
3753 "{hint}"
3754 );
3755 let none = CoreError::RollbackFailed(Box::new(RollbackFailure {
3756 run_id: "r-7".into(),
3757 env: "prod".into(),
3758 run_dir: None,
3759 journal: "j".into(),
3760 cause: "c".into(),
3761 }));
3762 assert!(!none.to_string().contains("None"), "{none}");
3763 }
3764
3765 #[test]
3766 fn preflight_failed_lists_every_failure_in_order() {
3767 let err = CoreError::DeployPreflightFailed {
3768 failures: vec![
3769 PreflightFailure {
3770 check: "mode_master".into(),
3771 detail: "first".into(),
3772 },
3773 PreflightFailure {
3774 check: "gwbk".into(),
3775 detail: "second".into(),
3776 },
3777 ]
3778 .into(),
3779 };
3780 let shown = err.to_string();
3781 let first = shown.find("mode_master: first").expect("first");
3782 let second = shown.find("gwbk: second").expect("second");
3783 assert!(first < second, "{shown}");
3784 assert_eq!(shown.lines().count(), 3, "{shown}");
3785 }
3786
3787 #[test]
3788 fn apply_refusal_lists_cap_at_ten() {
3789 let keys: Vec<String> = (0..10).map(|i| format!("u{i}")).collect();
3790 let shown = CoreError::DestructiveChangeRefused {
3791 gate: "destructive".into(),
3792 keys: keys.clone(),
3793 count: 13,
3794 }
3795 .to_string();
3796 assert!(shown.contains("13 entries"), "{shown}");
3797 assert!(shown.contains("and 3 more"), "{shown}");
3798 assert!(shown.contains("--allow-destructive"), "{shown}");
3799 let drift = CoreError::DestructiveChangeRefused {
3800 gate: "drift".into(),
3801 keys: keys[..1].to_vec(),
3802 count: 1,
3803 }
3804 .to_string();
3805 assert!(drift.contains("--allow-drift"), "{drift}");
3806 assert!(drift.contains("1 entry"), "{drift}");
3807 let downgrade = CoreError::DestructiveChangeRefused {
3808 gate: "downgrade".into(),
3809 keys: vec!["CapApp".into()],
3810 count: 1,
3811 };
3812 let shown = downgrade.to_string();
3813 assert!(shown.contains("downgrade"), "{shown}");
3814 assert!(shown.contains("CapApp"), "{shown}");
3815 assert!(
3816 shown.contains("deploy projects needs --allow-downgrade"),
3817 "{shown}"
3818 );
3819 assert!(!shown.contains("apply needs"), "{shown}");
3820 let first = CoreError::DestructiveChangeRefused {
3821 gate: "first_deploy".into(),
3822 keys: vec!["CapApp".into()],
3823 count: 1,
3824 };
3825 let shown = first.to_string();
3826 assert!(shown.contains("first_deploy"), "{shown}");
3827 assert!(
3828 shown.contains("deploy projects needs --allow-first-change <reason>"),
3829 "{shown}"
3830 );
3831 let hints: Vec<String> = [&downgrade, &first]
3833 .iter()
3834 .map(|e| e.hint().expect("hint"))
3835 .collect();
3836 assert!(hints[0].contains("--allow-downgrade"), "{}", hints[0]);
3837 assert!(hints[1].contains("--allow-first-change"), "{}", hints[1]);
3838 for hint in &hints {
3839 assert!(!hint.contains("ign "), "{hint}");
3840 assert!(!hint.contains("--allow-destructive"), "{hint}");
3841 }
3842
3843 let many: Vec<String> = (0..25).map(|i| format!("a/{i:02}")).collect();
3844 let blocked = CoreError::DeployBlocked { keys: many.clone() }.to_string();
3845 assert!(blocked.contains("and 15 more"), "{blocked}");
3846 assert!(!blocked.contains("a/10"), "{blocked}");
3847 let live = CoreError::DeployLiveChanged {
3848 run_id: "r".into(),
3849 paths: many[..10].to_vec(),
3850 count: 12,
3851 }
3852 .to_string();
3853 assert!(live.contains("and 2 more"), "{live}");
3854 }
3855}