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_unreadable_text(.node, .keys))]
887 DeployUnreadable {
888 node: String,
890 keys: Vec<String>,
893 },
894
895 #[error("{}", deploy_pair_tags_refused_text(.mode, .keys))]
903 DeployPairTagsRefused {
904 mode: String,
906 keys: Vec<String>,
908 },
909
910 #[error("{}", deploy_preflight_failed_text(.failures))]
914 DeployPreflightFailed {
915 failures: Box<[PreflightFailure]>,
917 },
918
919 #[error(
922 "an interrupted deploy left the journal {location} for run {run_id}; \
923 recover it with: ign deploy rollback --run {run_id} --env {env}"
924 )]
925 DeployJournalPresent {
926 run_id: String,
928 env: String,
930 location: String,
932 },
933
934 #[error("{}", deploy_live_changed_text(.run_id, .paths, *.count))]
938 DeployLiveChanged {
939 run_id: String,
941 paths: Vec<String>,
943 count: usize,
945 },
946
947 #[error("deploy run {run_id} is unusable: {problem}")]
950 DeployRunInvalid {
951 run_id: String,
953 problem: String,
955 },
956
957 #[error("{0}")]
960 RollbackFailed(Box<RollbackFailure>),
961
962 #[error(
970 "project {project} is still managed by the git module: {location} is present ({kind}); an administrator removes it before this project is deployed"
971 )]
972 ProjectGitManaged {
973 project: String,
975 location: String,
977 kind: String,
979 },
980
981 #[error("project {project} cannot be deployed under parent {parent}: {reason}")]
988 ProjectParentInvalid {
989 project: String,
991 parent: String,
993 reason: String,
995 },
996
997 #[error(
1005 "artifact hash mismatch for project {project}: expected {expected}, the checkout builds {actual}; nothing was uploaded"
1006 )]
1007 ProjectHashMismatch {
1008 project: String,
1010 expected: String,
1012 actual: String,
1014 },
1015}
1016
1017pub const APPLY_LIST_SHOWN: usize = 10;
1020
1021#[derive(Debug, Clone, PartialEq, Eq)]
1024pub struct PreflightFailure {
1025 pub check: String,
1027 pub detail: String,
1029}
1030
1031#[derive(Debug, Clone, PartialEq, Eq)]
1033pub struct RollbackFailure {
1034 pub run_id: String,
1036 pub env: String,
1038 pub run_dir: Option<String>,
1040 pub journal: String,
1042 pub cause: String,
1044}
1045
1046impl RollbackFailure {
1047 pub fn snapshot_dir(&self) -> String {
1050 match self.journal.rsplit_once('/') {
1051 Some((dir, _)) => format!("{dir}/snapshots/{}/", self.run_id),
1052 None => format!("snapshots/{}/", self.run_id),
1053 }
1054 }
1055}
1056
1057impl std::fmt::Display for RollbackFailure {
1058 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1059 write!(
1064 f,
1065 "rollback of run {run} failed ({cause}); the gateway files may be half restored. \
1066 Retry with: ign deploy rollback --run {run} --env {env} (the journal at {journal} \
1067 and the snapshot are still on the host). If the retry reports a lost or damaged \
1068 snapshot, restore the files from the .gwbk backup (when this run took one) or from \
1069 the on-box snapshot {snapshot}, then delete the journal by hand. \
1070 run directory: {run_dir}",
1071 run = self.run_id,
1072 cause = self.cause,
1073 env = self.env,
1074 journal = self.journal,
1075 snapshot = self.snapshot_dir(),
1076 run_dir = self.run_dir.as_deref().unwrap_or("none was created"),
1077 )
1078 }
1079}
1080
1081fn listed(shown: &[String], total: usize) -> String {
1084 let take = shown.len().min(APPLY_LIST_SHOWN);
1085 let mut out = shown[..take].join(", ");
1086 if total > take {
1087 out.push_str(&format!(" and {} more", total - take));
1088 }
1089 out
1090}
1091
1092fn destructive_change_refused_text(gate: &str, keys: &[String], count: usize) -> String {
1093 let (flag, verb) = match gate {
1094 "drift" => ("--allow-drift <reason>", "apply"),
1095 "downgrade" => ("--allow-downgrade", "deploy projects"),
1096 "first_deploy" => ("--allow-first-change <reason>", "deploy projects"),
1097 _ => ("--allow-destructive", "apply"),
1098 };
1099 format!(
1100 "{gate} change refused for {count} entr{}: {}; {verb} needs {flag}",
1101 if count == 1 { "y" } else { "ies" },
1102 listed(keys, count)
1103 )
1104}
1105
1106fn deploy_blocked_text(keys: &[String]) -> String {
1107 format!(
1108 "deploy blocked: {} unit(s) are removed from the declared tree but a core copy \
1109 shadows them, so deleting them from the mode collection cannot take effect: {}",
1110 keys.len(),
1111 listed(keys, keys.len())
1112 )
1113}
1114
1115fn deploy_unreadable_text(node: &str, keys: &[String]) -> String {
1116 format!(
1117 "deploy refused: {} unit(s) the deploy would write or delete have a live path the \
1118 {node} host's sftp server cannot read, so their live copy is unknown: {}; nothing was \
1119 written",
1120 keys.len(),
1121 listed(keys, keys.len())
1122 )
1123}
1124
1125fn deploy_pair_tags_refused_text(mode: &str, keys: &[String]) -> String {
1126 format!(
1127 "deploy refused: {} tag unit(s) would be written to, kept in or restored to the {mode} \
1128 collection of a redundant pair, which Ignition's redundancy sync cannot carry (the \
1129 backup stops syncing configuration): {}; nothing was written",
1130 keys.len(),
1131 listed(keys, keys.len())
1132 )
1133}
1134
1135fn deploy_preflight_failed_text(failures: &[PreflightFailure]) -> String {
1136 let mut out = format!("deploy preflight failed ({} check(s)):", failures.len());
1137 for failure in failures {
1138 out.push_str(&format!("\n {}: {}", failure.check, failure.detail));
1139 }
1140 out
1141}
1142
1143fn deploy_live_changed_text(run_id: &str, paths: &[String], count: usize) -> String {
1144 format!(
1145 "live files changed since run {run_id} wrote them ({count}): {}; restoring the \
1146 snapshot would overwrite them",
1147 listed(paths, count)
1148 )
1149}
1150
1151pub const DEPLOY_FINDINGS_SHOWN: usize = 20;
1154
1155#[derive(Debug, Clone, PartialEq, Eq)]
1157pub struct DeployFinding {
1158 pub file: String,
1160 pub line: Option<usize>,
1162 pub column: Option<usize>,
1164 pub message: String,
1166}
1167
1168impl std::fmt::Display for DeployFinding {
1169 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1170 match (self.line, self.column) {
1171 (Some(line), Some(column)) => {
1172 write!(f, "{}:{line}:{column}: {}", self.file, self.message)
1173 }
1174 _ => write!(f, "{}: {}", self.file, self.message),
1175 }
1176 }
1177}
1178
1179#[derive(Debug, Clone, PartialEq, Eq)]
1182pub struct DeployTreeInvalidDetails {
1183 pub findings: Vec<DeployFinding>,
1185}
1186
1187impl std::fmt::Display for DeployTreeInvalidDetails {
1188 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1189 if let [only] = self.findings.as_slice() {
1190 return write!(f, "{only}");
1191 }
1192 write!(f, "{} findings: ", self.findings.len())?;
1193 write_findings(f, &self.findings, DEPLOY_FINDINGS_SHOWN)
1194 }
1195}
1196
1197fn write_findings(
1200 f: &mut std::fmt::Formatter<'_>,
1201 findings: &[DeployFinding],
1202 shown: usize,
1203) -> std::fmt::Result {
1204 for (i, finding) in findings.iter().take(shown).enumerate() {
1205 if i > 0 {
1206 f.write_str("; ")?;
1207 }
1208 write!(f, "{finding}")?;
1209 }
1210 if findings.len() > shown {
1211 write!(f, "; and {} more", findings.len() - shown)?;
1212 }
1213 Ok(())
1214}
1215
1216#[derive(Debug, Clone, PartialEq, Eq)]
1220pub struct EmbeddedSecretDetails {
1221 pub paths: Vec<String>,
1223 pub other_findings: Vec<DeployFinding>,
1225}
1226
1227impl std::fmt::Display for EmbeddedSecretDetails {
1228 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1229 write!(f, "embedded secret at {} path(s): ", self.paths.len())?;
1230 let shown_paths = self.paths.len().min(DEPLOY_FINDINGS_SHOWN);
1231 f.write_str(&self.paths[..shown_paths].join("; "))?;
1232 if self.paths.len() > shown_paths {
1233 write!(f, "; and {} more", self.paths.len() - shown_paths)?;
1234 }
1235 if !self.other_findings.is_empty() {
1236 write!(
1237 f,
1238 "; also {} other tree finding(s): ",
1239 self.other_findings.len()
1240 )?;
1241 let room = DEPLOY_FINDINGS_SHOWN.saturating_sub(shown_paths);
1243 write_findings(f, &self.other_findings, room)?;
1244 }
1245 Ok(())
1246 }
1247}
1248
1249#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1252pub enum SignatureReason {
1253 Stale,
1256 Missing,
1260}
1261
1262impl std::fmt::Display for SignatureReason {
1263 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1264 f.write_str(match self {
1265 Self::Stale => "stale",
1266 Self::Missing => "missing",
1267 })
1268 }
1269}
1270
1271#[derive(Debug, Clone, PartialEq, Eq)]
1274pub struct ModuleDigestMismatchDetails {
1275 pub module: String,
1277 pub version: String,
1279 pub url: String,
1281 pub expected: String,
1283 pub actual: String,
1285}
1286
1287#[derive(Debug, Clone, PartialEq, Eq)]
1290pub struct ModuleDigestChangedDetails {
1291 pub module: String,
1293 pub version: String,
1295 pub cached_digest: String,
1297 pub upstream_digest: String,
1299 pub cached_path: String,
1301}
1302
1303impl std::fmt::Display for ModuleDigestMismatchDetails {
1304 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1305 write!(
1306 f,
1307 "module {:?} version {:?} digest mismatch at {}: expected {}, got {}",
1308 self.module, self.version, self.url, self.expected, self.actual
1309 )
1310 }
1311}
1312
1313impl std::fmt::Display for ModuleDigestChangedDetails {
1314 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1315 write!(
1316 f,
1317 "module {:?} version {:?} digest changed upstream: cached {}, \
1318 upstream now reports {} (cached artifact kept at {})",
1319 self.module, self.version, self.cached_digest, self.upstream_digest, self.cached_path
1320 )
1321 }
1322}
1323
1324impl CoreError {
1325 pub fn code(&self) -> &'static str {
1327 match self {
1328 Self::Internal(_) => "internal",
1329 Self::ConfirmationRequired { .. } => "confirmation_required",
1330 Self::ProfilingRiskNotAccepted { .. } => "profiling_risk_not_accepted",
1331 Self::InvalidImportFile { .. } => "invalid_import_file",
1332 Self::ImportDenied { .. } => "import_denied",
1333 Self::InvalidInput { .. } => "invalid_input",
1334 Self::ProfileNotFound { .. } => "profile_not_found",
1335 Self::NoActiveProfile => "no_active_profile",
1336 Self::SecretUnavailable { .. } => "secret_unavailable",
1337 Self::PasswordUnavailable { .. } => "password_unavailable",
1338 Self::ConfigInvalid { .. } => "config_invalid",
1339 Self::PollIntervalTooSmall { .. } => "poll_interval_too_small",
1340 Self::Network { .. } => "network_error",
1341 Self::Auth { .. } => "auth_rejected",
1342 Self::GatewayTooOld { .. } => "gateway_too_old",
1343 Self::GatewayNotCommissioned { .. } => "gateway_not_commissioned",
1344 Self::GatewayRestarting { .. } => "gateway_restarting",
1345 Self::NotFound { .. } => "not_found",
1346 Self::ProjectExists { .. } => "project_exists",
1347 Self::ResourceBinary { .. } => "resource_binary",
1348 Self::TrialNotExpired { .. } => "trial_not_expired",
1349 Self::ProviderNotFound { .. } => "provider_not_found",
1350 Self::RoutesNotDeployed { .. } => "routes_not_deployed",
1351 Self::WebdevUnlicensed { .. } => "webdev_unlicensed",
1352 Self::RouteVersionMismatch { .. } => "route_version_mismatch",
1353 Self::WebdevRouteError { .. } => "webdev_route_error",
1354 Self::TagCollision { .. } => "tag_collision",
1355 Self::AlarmJournalMissing { .. } => "alarm_journal_missing",
1356 Self::SessionNotPrunable { .. } => "session_not_prunable",
1357 Self::Rig(_) => "rig_error",
1358 Self::EamNotController { .. } => "eam_not_controller",
1359 Self::ProviderRootUnsupported { .. } => "provider_root_unsupported",
1360 Self::EamTaskTypeRefused { .. } => "eam_task_type_refused",
1361 Self::EamTaskInFlight { .. } => "eam_task_in_flight",
1362 Self::ScriptExecNotConfigured { .. } => "script_exec_not_configured",
1363 Self::LintToolAbsent => "lint_tool_absent",
1364 Self::NodeToolAbsent { .. } => "node_tool_absent",
1365 Self::GatewayClientError { .. } => "gateway_client_error",
1366 Self::BundleNotAvailable { .. } => "bundle_not_available",
1367 Self::ModuleFeedUnreachable { .. } => "module_feed_unreachable",
1368 Self::ModuleReleaseNotFound { .. } => "module_release_not_found",
1369 Self::ModuleDigestMismatch(..) => "module_digest_mismatch",
1370 Self::ModuleFeedUnusable { .. } => "module_feed_unusable",
1371 Self::ModuleDigestChanged(..) => "module_digest_changed",
1372 Self::ModuleNotRegistered { .. } => "module_not_registered",
1373 Self::ModuleUninstallDenied { .. } => "module_uninstall_denied",
1374 Self::ScanLockHeld { .. } => "scan_lock_held",
1375 Self::ResourceSignatureRejected { .. } => "resource_signature_rejected",
1376 Self::ResourceExists { .. } => "resource_exists",
1377 Self::ModeExists { .. } => "mode_exists",
1378 Self::DeployTreeInvalid(..) => "deploy_tree_invalid",
1379 Self::EmbeddedSecret(..) => "embedded_secret",
1380 Self::TransportFailed { .. } => "transport_failed",
1381 Self::DeployLedgerInvalid { .. } => "deploy_ledger_invalid",
1382 Self::PlanStale { .. } => "plan_stale",
1383 Self::DestructiveChangeRefused { .. } => "destructive_change_refused",
1384 Self::DeployBlocked { .. } => "deploy_blocked",
1385 Self::DeployUnreadable { .. } => "deploy_unreadable",
1386 Self::DeployPairTagsRefused { .. } => "deploy_pair_tags_refused",
1387 Self::DeployPreflightFailed { .. } => "deploy_preflight_failed",
1388 Self::DeployJournalPresent { .. } => "deploy_journal_present",
1389 Self::DeployLiveChanged { .. } => "deploy_live_changed",
1390 Self::DeployRunInvalid { .. } => "deploy_run_invalid",
1391 Self::RollbackFailed(..) => "rollback_failed",
1392 Self::ProjectGitManaged { .. } => "project_git_managed",
1393 Self::ProjectParentInvalid { .. } => "project_parent_invalid",
1394 Self::ProjectHashMismatch { .. } => "project_hash_mismatch",
1395 }
1396 }
1397
1398 pub fn exit_code(&self) -> u8 {
1400 match self {
1401 Self::Internal(_) => 1,
1402 Self::ConfirmationRequired { .. }
1403 | Self::ProfilingRiskNotAccepted { .. }
1404 | Self::InvalidImportFile { .. }
1405 | Self::InvalidInput { .. }
1406 | Self::GatewayClientError { .. }
1407 | Self::DeployTreeInvalid(..)
1408 | Self::EmbeddedSecret(..)
1409 | Self::DestructiveChangeRefused { .. } => 2,
1410 Self::ProfileNotFound { .. }
1411 | Self::NoActiveProfile
1412 | Self::SecretUnavailable { .. }
1413 | Self::PasswordUnavailable { .. }
1414 | Self::ConfigInvalid { .. }
1415 | Self::PollIntervalTooSmall { .. }
1416 | Self::ModuleNotRegistered { .. } => 3,
1417 Self::Network { .. }
1418 | Self::ModuleFeedUnreachable { .. }
1419 | Self::TransportFailed { .. } => 4,
1420 Self::Auth { .. } => 5,
1421 Self::GatewayTooOld { .. }
1422 | Self::GatewayNotCommissioned { .. }
1423 | Self::GatewayRestarting { .. }
1424 | Self::NotFound { .. }
1425 | Self::ProjectExists { .. }
1426 | Self::ResourceBinary { .. }
1427 | Self::TrialNotExpired { .. }
1428 | Self::ProviderNotFound { .. }
1429 | Self::RoutesNotDeployed { .. }
1430 | Self::WebdevUnlicensed { .. }
1431 | Self::RouteVersionMismatch { .. }
1432 | Self::WebdevRouteError { .. }
1433 | Self::TagCollision { .. }
1434 | Self::AlarmJournalMissing { .. }
1435 | Self::SessionNotPrunable { .. }
1436 | Self::ImportDenied { .. }
1437 | Self::EamNotController { .. }
1438 | Self::ProviderRootUnsupported { .. }
1439 | Self::EamTaskTypeRefused { .. }
1440 | Self::EamTaskInFlight { .. }
1441 | Self::ScriptExecNotConfigured { .. }
1442 | Self::LintToolAbsent
1443 | Self::NodeToolAbsent { .. }
1444 | Self::BundleNotAvailable { .. }
1445 | Self::ModuleReleaseNotFound { .. }
1446 | Self::ModuleDigestMismatch(..)
1447 | Self::ModuleFeedUnusable { .. }
1448 | Self::ModuleDigestChanged(..)
1449 | Self::ModuleUninstallDenied { .. }
1450 | Self::ScanLockHeld { .. }
1451 | Self::ResourceSignatureRejected { .. }
1452 | Self::ResourceExists { .. }
1453 | Self::ModeExists { .. }
1454 | Self::DeployLedgerInvalid { .. }
1455 | Self::PlanStale { .. }
1456 | Self::DeployBlocked { .. }
1457 | Self::DeployUnreadable { .. }
1458 | Self::DeployPairTagsRefused { .. }
1459 | Self::DeployPreflightFailed { .. }
1460 | Self::DeployJournalPresent { .. }
1461 | Self::DeployLiveChanged { .. }
1462 | Self::DeployRunInvalid { .. }
1463 | Self::RollbackFailed(..)
1464 | Self::ProjectGitManaged { .. }
1465 | Self::ProjectParentInvalid { .. }
1466 | Self::ProjectHashMismatch { .. } => 6,
1467 Self::Rig(_) => 7,
1468 }
1469 }
1470
1471 pub fn hint(&self) -> Option<String> {
1473 match self {
1474 Self::Internal(_) => Some(
1475 "internal errors are bugs; re-run with -vv and report the \
1476 diagnostics output"
1477 .to_string(),
1478 ),
1479 Self::ConfirmationRequired { .. } => Some(
1480 "this operation is destructive; re-run with --yes or set \
1481 IGNITION_YES=1"
1482 .to_string(),
1483 ),
1484 Self::ProfilingRiskNotAccepted { .. } => Some(
1485 "JVM profiling ACTIVELY instruments the gateway: stack sampling \
1486 adds CPU overhead proportional to thread count x frequency, \
1487 and flight recordings buffer in JVM memory and write to \
1488 gateway disk. On a PRODUCTION server this belongs inside an \
1489 approved change window with a rollback plan — never as a \
1490 casual probe. --yes deliberately does NOT satisfy this gate; \
1491 re-run with --i-accept-the-risk after confirming that"
1492 .to_string(),
1493 ),
1494 Self::InvalidImportFile { .. } => Some(
1495 "import expects a project-export ZIP (PK\\x03\\x04 magic) of at \
1496 most 512 MB — pass a file produced by `ign project export` \
1497 via --file (or `-` to pipe one on stdin)"
1498 .to_string(),
1499 ),
1500 Self::ImportDenied { problem, .. } => Some(format!(
1501 "the gateway refused the import over a 200 answer — the problem \
1502 text above is the gateway's own; `ign project export` of the \
1503 current state is the honest baseline for hand-editing ({problem})"
1504 )),
1505 Self::InvalidInput { reason } => Some(
1506 if reason == TUI_TTY_REFUSAL_REASON {
1507 "run `ign tui` in an interactive terminal (the cockpit \
1511 needs a TTY on stdout — not a pipe or redirect)"
1512 } else if reason == TOP_TTY_REFUSAL_REASON {
1513 "run `ign top` in an interactive terminal (the monitor \
1517 needs a TTY on stdout — not a pipe or redirect)"
1518 } else if reason.starts_with(LOSS_GATE_REFUSAL_REASON_PREFIX) {
1519 "the loss report above names what this import would drop or \
1524 coerce — re-run with --yes to import anyway"
1525 } else if reason.starts_with("--plan-sha256 ")
1526 || reason.starts_with("--allow-drift ")
1527 || reason.starts_with("--artifact-sha256 ")
1528 || reason.starts_with("--release ")
1529 || reason.starts_with("--allow-first-change ")
1530 || reason.starts_with("deploy projects needs --artifact-sha256")
1531 {
1532 "correct the flag value named above and rerun; no host or gateway \
1536 was contacted"
1537 } else {
1538 "fix the input source — a readable file path via --file, or `-` \
1539 to pipe the content on stdin"
1540 }
1541 .to_string(),
1542 ),
1543 Self::ProfileNotFound { known, .. } => Some(if known.is_empty() {
1544 "no profiles configured yet; run `ign profile add` to create \
1545 one"
1546 .to_string()
1547 } else {
1548 format!(
1549 "known profiles: {}; run `ign profile add` to add another",
1550 known.join(", ")
1551 )
1552 }),
1553 Self::NoActiveProfile => Some(
1554 "pass --profile NAME, set IGNITION_PROFILE, or mark a profile \
1555 active with `ign profile use`"
1556 .to_string(),
1557 ),
1558 Self::SecretUnavailable { profile } => Some(format!(
1559 "set IGNITION_TOKEN (or token_env in the profile), or store a \
1560 keyring entry: service 'ignition-cli', user 'profile:{profile}'"
1561 )),
1562 Self::PasswordUnavailable { .. } => Some(
1563 "ign adopt logs in with the gateway admin password — export \
1564 IGNITION_PASSWORD (env-only, never a flag)"
1565 .to_string(),
1566 ),
1567 Self::ConfigInvalid { .. } => Some(
1568 "verify the config file is valid TOML with [profiles.NAME] \
1569 tables; `ign profile add` writes a known-good one"
1570 .to_string(),
1571 ),
1572 Self::PollIntervalTooSmall { profile } => Some(format!(
1573 "set poll_interval_secs to 1 or higher in [profiles.{profile}], \
1574 or remove the key to use the default cadence"
1575 )),
1576 Self::Network {
1577 url,
1578 observation,
1579 ..
1580 } => Some(match observation {
1581 Some(observation) => format!(
1582 "the gateway answered but no terminal state arrived before the deadline — \
1583 the last observation was: {observation}; address that state, not the \
1584 connection ({url})"
1585 ),
1586 None => format!("check the gateway is reachable at {url} (host, port, VPN, TLS)"),
1587 }),
1588 Self::Auth { status, .. } => Some(match status {
1589 401 => {
1590 "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"
1594 }
1595 403 => {
1596 "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"
1599 }
1600 _ => "check the credential; Ignition token setup is three parts: security level, write permissions, token assignment",
1601 }
1602 .to_string()),
1603 Self::GatewayTooOld { minimum, .. } => {
1604 Some(format!("upgrade the gateway to at least {minimum}"))
1605 }
1606 Self::GatewayNotCommissioned { .. } => Some(
1607 "open http://<host>:<port>/welcome in a browser and complete the \
1608 commissioning wizard"
1609 .to_string(),
1610 ),
1611 Self::GatewayRestarting { .. } => Some(
1612 "wait for readiness with `ign wait restart` or retry in ~1 minute".to_string(),
1613 ),
1614 Self::NotFound { .. } => Some(
1615 "check the id/path; a 404 JSON 'No route match' body can also mean \
1616 a pre-8.3 gateway"
1617 .to_string(),
1618 ),
1619 Self::ProjectExists { .. } => Some(
1620 "the default collision policy refuses to overwrite; re-run with \
1621 --collision-policy overwrite to replace it — overwrite \
1622 REPLACES the ENTIRE project (resources absent from the ZIP \
1623 are deleted; merge is Designer-only)"
1624 .to_string(),
1625 ),
1626 Self::ResourceBinary { .. } => Some(
1627 "resource content is binary — use `ign project export`/`import` \
1628 for data.bin-class resources"
1629 .to_string(),
1630 ),
1631 Self::TrialNotExpired { .. } => Some(
1632 "wait for the trial to expire (watch `ign rig trial status`), or \
1633 run `ign rig reset --yes` for a completely fresh trial volume"
1634 .to_string(),
1635 ),
1636 Self::RoutesNotDeployed { route, .. } => Some(
1637 if route.starts_with(TESTING_ROUTE_PREFIX) {
1645 "run `ign webdev deploy --with-testing` to install the gateway \
1646 testing bundle into the project (or bootstrap it in one shot \
1647 with `ign adopt --project NAME --testing`), then retry"
1648 } else {
1649 "run `ign webdev deploy` to install the CLI's WebDev routes into \
1650 the gateway, then retry"
1651 }
1652 .to_string(),
1653 ),
1654 Self::WebdevUnlicensed { .. } => Some(
1655 "license the gateway — the WebDev module answers 402 while \
1656 unlicensed (on a rig, `ign rig trial reset --yes` restarts an \
1657 expired trial)"
1658 .to_string(),
1659 ),
1660 Self::RouteVersionMismatch { deployed, expected, .. } => {
1661 let newer = semver::Version::parse(deployed)
1666 .ok()
1667 .zip(semver::Version::parse(expected).ok())
1668 .is_some_and(|(deployed, expected)| deployed > expected);
1669 Some(if newer {
1670 "the deployed routes are NEWER than this CLI — update ign \
1671 (the route bundle travels with the binary)"
1672 .to_string()
1673 } else {
1674 "run `ign webdev deploy` to redeploy the route version \
1675 this CLI expects"
1676 .to_string()
1677 })
1678 }
1679 Self::ProviderNotFound { .. } => Some(
1680 "check the provider name; `ign tags provider list` shows the \
1681 gateway's tag providers"
1682 .to_string(),
1683 ),
1684 Self::TagCollision { .. } => Some(
1685 "re-run with --collision-policy overwrite to replace the \
1686 existing tags (destructive: requires --yes)"
1687 .to_string(),
1688 ),
1689 Self::AlarmJournalMissing { .. } => Some(
1690 "alarm history needs a journal profile — provision a database \
1691 connection + alarm-journal profile on the gateway (and point \
1692 the general-alarm-settings singleton at it), then retry; see \
1693 the README 'Alarm history' section"
1694 .to_string(),
1695 ),
1696 Self::SessionNotPrunable { .. } => Some(
1697 "close the Designer first — prune removes stale entries only".to_string(),
1698 ),
1699 Self::WebdevRouteError { code, .. } => Some(if code == "secret_required" || code == "secret_mismatch" {
1700 "the scriptExec route is secret-gated — deploy it with `ign \
1701 webdev deploy --with-script-exec` (the secret is generated \
1702 and stored in the profile config at 0600); a mismatch means \
1703 the route was deployed with a different secret: redeploy or \
1704 pass --rotate-secret"
1705 .to_string()
1706 } else {
1707 "the deployed route refused the action — the code and message \
1708 are the route's stable contract; `ign webdev status` \
1709 diagnoses the deployment"
1710 .to_string()
1711 }),
1712 Self::EamNotController { .. } => Some(
1713 "flip the gateway's EAM role: config-resource PUT on \
1714 com.inductiveautomation.eam/module-settings with \
1715 installMode \"Controller\" (array body carrying the current \
1716 signature) — a manual gateway-role decision this CLI \
1717 deliberately does not automate; see the README 'EAM tasks' \
1718 section"
1719 .to_string(),
1720 ),
1721 Self::ProviderRootUnsupported { .. } => Some(
1722 "target a subtree path like [provider]folder — provider-ROOT \
1723 forms ([default] alone, or a bare provider name) need an \
1724 RPC context WebDev threads don't carry (8.3.3); subtree \
1725 paths are the supported form"
1726 .to_string(),
1727 ),
1728 Self::EamTaskTypeRefused { .. } => Some(
1729 "restore/install/upgrade tasks dispatch fleet-wide (every \
1730 agent target); run them from the Ignition EAM console — \
1731 EXT-03 (v2) will scope them into the CLI"
1732 .to_string(),
1733 ),
1734 Self::EamTaskInFlight { .. } => Some(
1735 "complete or delete the leftover '(forced)' run from the EAM \
1736 console — no ign verb deletes runs; the slot frees once the \
1737 run is resolved"
1738 .to_string(),
1739 ),
1740 Self::ScriptExecNotConfigured { .. } => Some(
1741 "run `ign webdev deploy --with-script-exec` to deploy the route and \
1742 generate + persist its secret (the deploy flag IS the opt-in — \
1743 `ign script run` has no --yes by design)"
1744 .to_string(),
1745 ),
1746 Self::LintToolAbsent => Some(
1747 "install the linter: `uv tool install ignition-lint-toolkit` \
1748 (or `pip install ignition-lint-toolkit`) — \
1749 github.com/TheThoughtagen/ignition-lint; then re-run with \
1750 ignition-lint on PATH"
1751 .to_string(),
1752 ),
1753 Self::NodeToolAbsent { .. } => {
1757 Some(crate::actions::e2e::NODE_INSTALL_HINT.to_string())
1758 }
1759 Self::GatewayClientError { .. } => Some(
1760 "the gateway rejected this request — the body above is the \
1761 gateway's own answer; fix the path/method/body, or use a \
1762 curated `ign` command when one exists"
1763 .to_string(),
1764 ),
1765 Self::BundleNotAvailable { .. } => Some(
1766 "generate a fresh bundle with `ign diagnostics bundle generate`, \
1767 then wait again — the gateway reports no current bundle and \
1768 polling cannot produce one"
1769 .to_string(),
1770 ),
1771 Self::ModuleFeedUnreachable { .. } => Some(
1772 "check network reachability to the module release feed — an \
1773 already-cached version works fully offline, so this only \
1774 blocks a first-time fetch of a new version"
1775 .to_string(),
1776 ),
1777 Self::ModuleReleaseNotFound {
1778 module, version, ..
1779 } => Some(format!(
1780 "check that {module} version {version} exists as a release upstream \
1781 (the tag and asset name must match exactly) — this CLI never falls \
1782 back to \"latest\""
1783 )),
1784 Self::ModuleDigestMismatch(d) => Some(format!(
1785 "the downloaded bytes for {} version {} do not match \
1786 the feed's published digest — do not trust this artifact; nothing \
1787 was cached, so re-running the fetch tries again from scratch",
1788 d.module,
1789 d.version
1790 )),
1791 Self::ModuleFeedUnusable { .. } => Some(
1792 "the module release feed answered but was not usable — if the \
1793 detail names a rate limit, wait and retry; otherwise the feed's \
1794 response itself needs investigation"
1795 .to_string(),
1796 ),
1797 Self::ModuleDigestChanged(d) => Some(format!(
1798 "upstream re-released this version with different bytes — the cached \
1799 artifact at {} is still there and usable offline; re-run with \
1800 FetchPolicy::AcceptUpstreamChange to deliberately accept and verify the \
1801 new bytes",
1802 d.cached_path
1803 )),
1804 Self::Rig(_) => Some(
1805 "check Docker is running and inspect the rig containers \
1806 (docker ps)"
1807 .to_string(),
1808 ),
1809 Self::ModuleNotRegistered { known, .. } => Some(if known.is_empty() {
1810 "no modules are registered in this build of ign".to_string()
1811 } else {
1812 format!(
1813 "known module ids: {} — check the id passed to --with-module \
1814 or declared under [rigs.NAME.modules.*]",
1815 known.join(", ")
1816 )
1817 }),
1818 Self::ModuleUninstallDenied { .. } => Some(
1819 "the gateway refuses to uninstall a module whose .modl is still \
1820 mounted — remove it from [rigs.NAME.modules], run `ign rig up` \
1821 so the mount goes, then retry; a successful uninstall is NOT \
1822 undone by re-declaring the module — recovery today means \
1823 `ign rig reset --yes`, which destroys the rig's data volume"
1824 .to_string(),
1825 ),
1826 Self::ScanLockHeld { .. } => Some(
1827 "the scan-lock is released when the holder's scan runs or its \
1828 holdTimeout expires — wait for that, then retry"
1829 .to_string(),
1830 ),
1831 Self::ResourceSignatureRejected { .. } => Some(
1832 "re-read the resource for its current signature and retry — \
1833 signatures change on every write"
1834 .to_string(),
1835 ),
1836 Self::ResourceExists { .. } => Some(
1837 "a resource of that name is already present — read it for its \
1838 current signature and update it, or choose another name"
1839 .to_string(),
1840 ),
1841 Self::ModeExists { .. } => Some(
1842 "that deployment mode is already defined — list the modes and \
1843 skip the create"
1844 .to_string(),
1845 ),
1846 Self::DeployTreeInvalid(..) => Some(
1847 "fix each file named above in the declared gateway tree; render \
1848 refuses until every finding is resolved"
1849 .to_string(),
1850 ),
1851 Self::EmbeddedSecret(..) => Some(
1852 "store the value in the gateway's secret provider and reference it \
1853 as {\"type\":\"Referenced\",\"data\":{\"providerName\":\"…\",\
1854 \"secretName\":\"…\"}}; never commit an Embedded or plaintext secret"
1855 .to_string(),
1856 ),
1857 Self::TransportFailed { .. } => Some(
1858 "check the node's host access in targets/<env>.yaml: for `local`, that \
1859 data_dir exists and is readable; for `ssh`, the pinned host key, the \
1860 key and reachability. Nothing was read as absent, so no plan was made"
1861 .to_string(),
1862 ),
1863 Self::DeployLedgerInvalid { .. } => Some(
1864 "the delete set is never assumed empty, so no plan was made; inspect or \
1865 restore the ledger file named above (only an apply writes it) and rerun"
1866 .to_string(),
1867 ),
1868 Self::PlanStale { detail, .. } if detail.starts_with("live project") => Some(
1869 "the live project changed after the plan was drawn; run the check again, \
1870 review what it reports and rerun the deploy, nothing was imported"
1871 .to_string(),
1872 ),
1873 Self::PlanStale { .. } => Some(
1874 "the plan changed after it was reviewed; review the new plan and rerun \
1875 with its plan_sha256, nothing was written"
1876 .to_string(),
1877 ),
1878 Self::DestructiveChangeRefused { gate, .. } => Some(match gate.as_str() {
1879 "drift" => "live files differ from both the base render and the declared tree; \
1880 review the drift, then rerun with --allow-drift and a reason of at most \
1881 500 characters"
1882 .to_string(),
1883 "downgrade" => "the gateway last received a newer release of this project; \
1884 deploy the newer release, or rerun with --allow-downgrade when the older \
1885 release must replace it"
1886 .to_string(),
1887 "first_deploy" => "this is the first deploy of the project by this tool and it \
1888 would change what the gateway runs; release what the gateway runs first, \
1889 or rerun with --allow-first-change and a reason"
1890 .to_string(),
1891 _ => "review the destructive entries named above, then rerun with \
1892 --allow-destructive and the plan_sha256 you reviewed"
1893 .to_string(),
1894 }),
1895 Self::DeployBlocked { .. } => Some(
1896 "a core copy still shadows each unit named above, so no flag permits the \
1897 removal; keep the unit in the declared tree, or remove the core copy on \
1898 the gateway first"
1899 .to_string(),
1900 ),
1901 Self::DeployUnreadable { .. } => Some(
1902 "the live path of each unit named above is too long for the host's sftp \
1903 server; shorten it on the gateway (rename the folder), or, when the gateway \
1904 owns the provider, list the provider in exclude_tag_providers and stop \
1905 declaring it; nothing was written"
1906 .to_string(),
1907 ),
1908 Self::DeployPairTagsRefused { .. } => Some(
1909 "tag units cannot be deployed to a redundant pair's mode collection; stop \
1910 declaring the units named above for this target (remove them from the \
1911 declared tree), so that an apply deletes the copy the ledger lists; remove \
1912 by hand from the master's mode collection any file of theirs the ledger \
1913 does not list; keep those tags in the gateway's core collection, which \
1914 syncs to the backup; nothing was written"
1915 .to_string(),
1916 ),
1917 Self::DeployPreflightFailed { .. } => Some(
1918 "fix each failed check named above and rerun; no gateway file was written"
1919 .to_string(),
1920 ),
1921 Self::DeployJournalPresent { .. } => Some(
1922 "an earlier deploy was interrupted and its journal is still on the box; run \
1923 the recovery command in the message, then rerun; plan and apply refuse \
1924 until the journal is cleared"
1925 .to_string(),
1926 ),
1927 Self::DeployLiveChanged { .. } => Some(
1928 "someone changed the files named above after the run wrote them; review \
1929 those changes, then rerun the rollback with --discard-live-changes to \
1930 restore the snapshot over them"
1931 .to_string(),
1932 ),
1933 Self::DeployRunInvalid { .. } => Some(
1934 "the run directory or the on-box record is missing or damaged; restore the \
1935 files from the .gwbk backup or the run-directory snapshot instead"
1936 .to_string(),
1937 ),
1938 Self::RollbackFailed(..) => Some(
1939 "do not apply again until the files are restored: run the retry command in \
1940 the message first, because the journal and the snapshot are usually intact; \
1941 only if it reports a lost or damaged snapshot restore from the .gwbk backup \
1942 or the on-box snapshot named there, and then delete the journal at the path \
1943 in the message by hand"
1944 .to_string(),
1945 ),
1946 Self::ProjectGitManaged { .. } => Some(
1947 "follow the README cutover runbook: remove this project's .git on the \
1948 gateway (the deploy tool never deletes it), then run the check again"
1949 .to_string(),
1950 ),
1951 Self::ProjectParentInvalid { reason, .. } => Some(
1952 match reason.as_str() {
1953 "missing" => "deploy the parent project from its own repo first, then run \
1954 the check again",
1955 "cycle" => "fix the parent in project.json so the inheritance has no cycle",
1956 _ => "the parent is not inheritable; fix the parent in project.json or \
1957 make the parent inheritable",
1958 }
1959 .to_string(),
1960 ),
1961 Self::ProjectHashMismatch { .. } => Some(
1962 "the checkout is not the reviewed one; rerun the check on this commit and \
1963 pass its artifact_sha256"
1964 .to_string(),
1965 ),
1966 }
1967 }
1968
1969 pub fn endpoint(&self) -> Option<String> {
1972 match self {
1973 Self::Network { url, .. } => Some(url.clone()),
1974 Self::Auth { endpoint, .. } => endpoint.clone(),
1975 Self::GatewayTooOld { endpoint, .. }
1976 | Self::GatewayNotCommissioned { endpoint }
1977 | Self::GatewayRestarting { endpoint }
1978 | Self::NotFound { endpoint }
1979 | Self::ProjectExists { endpoint, .. }
1980 | Self::ResourceBinary { endpoint, .. }
1981 | Self::TrialNotExpired { endpoint, .. }
1982 | Self::ProviderNotFound { endpoint, .. }
1983 | Self::RoutesNotDeployed { endpoint, .. }
1984 | Self::WebdevUnlicensed { endpoint }
1985 | Self::RouteVersionMismatch { endpoint, .. }
1986 | Self::WebdevRouteError { endpoint, .. }
1987 | Self::TagCollision { endpoint, .. }
1988 | Self::AlarmJournalMissing { endpoint }
1989 | Self::SessionNotPrunable { endpoint, .. }
1990 | Self::ImportDenied { endpoint, .. }
1991 | Self::EamNotController { endpoint }
1992 | Self::ProviderRootUnsupported { endpoint }
1993 | Self::EamTaskInFlight { endpoint, .. }
1994 | Self::ModuleUninstallDenied { endpoint, .. }
1995 | Self::ScanLockHeld { endpoint, .. }
1996 | Self::ResourceSignatureRejected { endpoint, .. }
1997 | Self::ResourceExists { endpoint, .. }
1998 | Self::ModeExists { endpoint, .. } => endpoint.clone(),
1999 Self::ModuleFeedUnreachable { url, .. }
2000 | Self::ModuleReleaseNotFound { url, .. }
2001 | Self::ModuleFeedUnusable { url, .. } => Some(url.clone()),
2002 Self::ModuleDigestMismatch(d) => Some(d.url.clone()),
2004 _ => None,
2005 }
2006 }
2007
2008 pub const DEPLOY_SLUGS: &'static [&'static str] = &[
2010 "deploy_tree_invalid",
2011 "embedded_secret",
2012 "transport_failed",
2013 "deploy_ledger_invalid",
2014 "plan_stale",
2015 "destructive_change_refused",
2016 "deploy_blocked",
2017 "deploy_preflight_failed",
2018 "deploy_journal_present",
2019 "deploy_live_changed",
2020 "deploy_run_invalid",
2021 "rollback_failed",
2022 "project_git_managed",
2023 "project_parent_invalid",
2024 "project_hash_mismatch",
2025 "deploy_unreadable",
2026 "deploy_pair_tags_refused",
2027 ];
2028
2029 pub fn deploy_tree_invalid(
2032 file: impl Into<String>,
2033 line: Option<usize>,
2034 column: Option<usize>,
2035 message: impl Into<String>,
2036 ) -> Self {
2037 Self::deploy_findings(vec![DeployFinding {
2038 file: file.into(),
2039 line,
2040 column,
2041 message: message.into(),
2042 }])
2043 }
2044
2045 pub fn deploy_findings(findings: Vec<DeployFinding>) -> Self {
2049 debug_assert!(!findings.is_empty(), "deploy_findings needs a finding");
2050 let findings = if findings.is_empty() {
2051 vec![DeployFinding {
2052 file: String::new(),
2053 line: None,
2054 column: None,
2055 message: "the declared tree is invalid".to_string(),
2056 }]
2057 } else {
2058 findings
2059 };
2060 Self::DeployTreeInvalid(Box::new(DeployTreeInvalidDetails { findings }))
2061 }
2062
2063 pub fn embedded_secret(paths: Vec<String>, other_findings: Vec<DeployFinding>) -> Self {
2067 Self::EmbeddedSecret(Box::new(EmbeddedSecretDetails {
2068 paths,
2069 other_findings,
2070 }))
2071 }
2072
2073 pub fn tui_tty_refusal() -> Self {
2078 Self::InvalidInput {
2079 reason: TUI_TTY_REFUSAL_REASON.to_string(),
2080 }
2081 }
2082
2083 pub fn top_tty_refusal() -> Self {
2087 Self::InvalidInput {
2088 reason: TOP_TTY_REFUSAL_REASON.to_string(),
2089 }
2090 }
2091
2092 pub fn envelope<'a>(&self, profile: Option<&'a str>) -> ErrorEnvelope<'a> {
2096 ErrorEnvelope {
2097 ok: false,
2098 profile,
2099 error: ErrorBody {
2100 code: self.code(),
2101 message: self.to_string(),
2102 endpoint: self.endpoint(),
2103 hint: self.hint(),
2104 },
2105 }
2106 }
2107}
2108
2109#[derive(Debug, Serialize)]
2112pub struct ErrorEnvelope<'a> {
2113 pub ok: bool,
2115 pub profile: Option<&'a str>,
2118 pub error: ErrorBody,
2120}
2121
2122#[derive(Debug, Serialize)]
2125pub struct ErrorBody {
2126 pub code: &'static str,
2128 pub message: String,
2130 pub endpoint: Option<String>,
2132 pub hint: Option<String>,
2134}
2135
2136#[cfg(test)]
2137mod tests {
2138 use super::{
2139 CoreError, DeployFinding, ErrorBody, ErrorEnvelope, GATEWAY_CLIENT_BODY_CAP_BYTES,
2140 GATEWAY_CLIENT_BODY_TRUNCATION_MARKER, LOSS_GATE_REFUSAL_REASON_PREFIX,
2141 ModuleDigestChangedDetails, ModuleDigestMismatchDetails, PreflightFailure, RollbackFailure,
2142 SignatureReason, TESTING_ROUTE_PREFIX, truncate_api_body,
2143 };
2144
2145 fn network_error() -> CoreError {
2149 crate::client::install_crypto_provider();
2156 let rt = tokio::runtime::Builder::new_current_thread()
2157 .enable_all()
2158 .build()
2159 .expect("test runtime");
2160 let url = "http://127.0.0.1:1";
2161 let source = rt
2162 .block_on(reqwest::get(url))
2163 .expect_err("request to an unroutable port must fail");
2164 CoreError::Network {
2165 url: url.to_string(),
2166 source: Some(source),
2167 observation: None,
2168 }
2169 }
2170
2171 fn module_feed_unreachable_error() -> CoreError {
2175 crate::client::install_crypto_provider();
2176 let rt = tokio::runtime::Builder::new_current_thread()
2177 .enable_all()
2178 .build()
2179 .expect("test runtime");
2180 let url = "http://127.0.0.1:1";
2181 let source = rt
2182 .block_on(reqwest::get(url))
2183 .expect_err("request to an unroutable port must fail");
2184 CoreError::ModuleFeedUnreachable {
2185 url: url.to_string(),
2186 source: Some(source),
2187 }
2188 }
2189
2190 #[test]
2194 fn exit_code_mapping_enumerated() {
2195 let cases: Vec<(CoreError, u8, &'static str)> = vec![
2196 (CoreError::Internal("boom".into()), 1, "internal"),
2197 (
2198 CoreError::ConfirmationRequired {
2199 operation: "project download".into(),
2200 },
2201 2,
2202 "confirmation_required",
2203 ),
2204 (
2205 CoreError::ProfilingRiskNotAccepted {
2206 operation: "jvm sample".into(),
2207 },
2208 2,
2209 "profiling_risk_not_accepted",
2210 ),
2211 (
2212 CoreError::InvalidImportFile {
2213 reason: "missing ZIP magic".into(),
2214 },
2215 2,
2216 "invalid_import_file",
2217 ),
2218 (
2219 CoreError::InvalidInput {
2220 reason: "cannot read put.json".into(),
2221 },
2222 2,
2223 "invalid_input",
2224 ),
2225 (
2226 CoreError::ProfileNotFound {
2227 name: "nope".into(),
2228 known: vec!["dev".into()],
2229 },
2230 3,
2231 "profile_not_found",
2232 ),
2233 (CoreError::NoActiveProfile, 3, "no_active_profile"),
2234 (
2235 CoreError::SecretUnavailable {
2236 profile: "dev".into(),
2237 },
2238 3,
2239 "secret_unavailable",
2240 ),
2241 (
2242 CoreError::PasswordUnavailable {
2243 profile: "dev".into(),
2244 },
2245 3,
2246 "password_unavailable",
2247 ),
2248 (
2249 CoreError::ConfigInvalid {
2250 reason: "bad toml".into(),
2251 },
2252 3,
2253 "config_invalid",
2254 ),
2255 (
2256 CoreError::PollIntervalTooSmall {
2257 profile: "dev".into(),
2258 },
2259 3,
2260 "poll_interval_too_small",
2261 ),
2262 (network_error(), 4, "network_error"),
2263 (
2264 CoreError::Auth {
2265 status: 401,
2266 endpoint: None,
2267 },
2268 5,
2269 "auth_rejected",
2270 ),
2271 (
2272 CoreError::GatewayTooOld {
2273 found: "8.1.0".into(),
2274 minimum: "8.3.1".into(),
2275 endpoint: None,
2276 },
2277 6,
2278 "gateway_too_old",
2279 ),
2280 (
2281 CoreError::GatewayNotCommissioned {
2282 endpoint: Some("http://gw:8088/data/api/v1/gateway-info".into()),
2283 },
2284 6,
2285 "gateway_not_commissioned",
2286 ),
2287 (
2288 CoreError::GatewayRestarting {
2289 endpoint: Some("http://gw:8088/data/api/v1/gateway-info".into()),
2290 },
2291 6,
2292 "gateway_restarting",
2293 ),
2294 (
2295 CoreError::NotFound {
2296 endpoint: Some("http://gw:8088/data/api/v1/designer/42".into()),
2297 },
2298 6,
2299 "not_found",
2300 ),
2301 (
2302 CoreError::ProjectExists {
2303 name: "PlantFloor".into(),
2304 endpoint: None,
2305 },
2306 6,
2307 "project_exists",
2308 ),
2309 (
2310 CoreError::ResourceBinary {
2311 path: "com.x/perspective/session-permissions".into(),
2312 endpoint: None,
2313 },
2314 6,
2315 "resource_binary",
2316 ),
2317 (
2318 CoreError::TrialNotExpired {
2319 remaining_s: 6590,
2320 endpoint: Some("http://localhost:9088/data/api/v1/trial".into()),
2321 },
2322 6,
2323 "trial_not_expired",
2324 ),
2325 (
2326 CoreError::ProviderNotFound {
2327 name: "nope".into(),
2328 endpoint: Some("/data/api/v1/resources/find/ignition/tag-provider/nope".into()),
2329 },
2330 6,
2331 "provider_not_found",
2332 ),
2333 (
2334 CoreError::RoutesNotDeployed {
2335 project: "ign-cli".into(),
2336 route: "tags".into(),
2337 endpoint: Some("/system/webdev/ign-cli/cli/tags".into()),
2338 },
2339 6,
2340 "routes_not_deployed",
2341 ),
2342 (
2343 CoreError::WebdevUnlicensed {
2344 endpoint: Some("/system/webdev/ign-cli/cli/tags".into()),
2345 },
2346 6,
2347 "webdev_unlicensed",
2348 ),
2349 (
2350 CoreError::RouteVersionMismatch {
2351 route: "tags".into(),
2352 deployed: "0.9.0".into(),
2353 expected: "1.0.0".into(),
2354 endpoint: Some("/system/webdev/ign-cli/cli/tags".into()),
2355 },
2356 6,
2357 "route_version_mismatch",
2358 ),
2359 (
2360 CoreError::WebdevRouteError {
2361 code: "route_error".into(),
2362 message: "boom".into(),
2363 endpoint: Some("/system/webdev/ign-cli/cli/tags".into()),
2364 },
2365 6,
2366 "webdev_route_error",
2367 ),
2368 (
2369 CoreError::TagCollision {
2370 provider: "p5import".into(),
2371 names: vec!["T1".into(), "P5".into()],
2372 endpoint: Some("/system/webdev/ign-cli/cli/tags".into()),
2373 },
2374 6,
2375 "tag_collision",
2376 ),
2377 (
2378 CoreError::AlarmJournalMissing {
2379 endpoint: Some("/system/webdev/ign-cli/cli/alarms".into()),
2380 },
2381 6,
2382 "alarm_journal_missing",
2383 ),
2384 (
2385 CoreError::ImportDenied {
2386 project: "PlantFloor".into(),
2387 problem: "resource already exists: ResourceId{resourcePath=com.example, collectionName=views}".into(),
2388 endpoint: Some("http://gw:8088/data/api/v1/projects/import/PlantFloor?overwrite=true".into()),
2389 },
2390 6,
2391 "import_denied",
2392 ),
2393 (
2394 CoreError::SessionNotPrunable {
2395 id: "d-live-1".into(),
2396 endpoint: Some("http://gw:8088/data/api/v1/designer/d-live-1".into()),
2397 },
2398 6,
2399 "session_not_prunable",
2400 ),
2401 (
2402 CoreError::EamNotController {
2403 endpoint: Some("http://gw:8088/data/eam/api/v1/eam-tasks/history".into()),
2404 },
2405 6,
2406 "eam_not_controller",
2407 ),
2408 (
2409 CoreError::ProviderRootUnsupported {
2410 endpoint: Some("/system/webdev/ign-cli/cli/tagConfig".into()),
2411 },
2412 6,
2413 "provider_root_unsupported",
2414 ),
2415 (
2416 CoreError::EamTaskTypeRefused {
2417 task_type: "eam_remoteUpgrade".into(),
2418 },
2419 6,
2420 "eam_task_type_refused",
2421 ),
2422 (
2423 CoreError::EamTaskInFlight {
2424 task: "cli-research-backup".into(),
2425 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(),
2426 endpoint: Some(
2427 "http://gw:8088/data/eam/api/v1/eam-tasks/force/eam/cli-research-backup"
2428 .into(),
2429 ),
2430 },
2431 6,
2432 "eam_task_in_flight",
2433 ),
2434 (
2435 CoreError::ScriptExecNotConfigured {
2436 profile: "dev".into(),
2437 },
2438 6,
2439 "script_exec_not_configured",
2440 ),
2441 (CoreError::LintToolAbsent, 6, "lint_tool_absent"),
2442 (
2443 CoreError::NodeToolAbsent {
2444 tool: "node".into(),
2445 },
2446 6,
2447 "node_tool_absent",
2448 ),
2449 (
2450 CoreError::BundleNotAvailable {
2451 state: "Invalid".into(),
2452 },
2453 6,
2454 "bundle_not_available",
2455 ),
2456 (
2457 CoreError::GatewayClientError {
2458 status: 400,
2459 endpoint: "http://gw:8088/data/api/v1/nonexistent".into(),
2460 body: r#"{"error":{"code":"NOT_FOUND"}}"#.into(),
2461 },
2462 2,
2463 "gateway_client_error",
2464 ),
2465 (CoreError::Rig("compose up failed".into()), 7, "rig_error"),
2466 (
2467 module_feed_unreachable_error(),
2468 4,
2469 "module_feed_unreachable",
2470 ),
2471 (
2472 CoreError::ModuleReleaseNotFound {
2473 module: "git".into(),
2474 version: "99.99.99".into(),
2475 url: "https://api.github.com/repos/WhiskeyHouse/ignition-git-module/releases/tags/v99.99.99".into(),
2476 detail: "no release exists for tag \"v99.99.99\"".into(),
2477 },
2478 6,
2479 "module_release_not_found",
2480 ),
2481 (
2482 CoreError::ModuleDigestMismatch(Box::new(ModuleDigestMismatchDetails {
2483 module: "git".into(),
2484 version: "2.3.4".into(),
2485 url: "https://release-assets.githubusercontent.com/Git-2.3.4-signed.modl".into(),
2486 expected: "b".repeat(64),
2487 actual: "c".repeat(64),
2488 })),
2489 6,
2490 "module_digest_mismatch",
2491 ),
2492 (
2493 CoreError::ModuleFeedUnusable {
2494 url: "https://api.github.com/repos/WhiskeyHouse/ignition-git-module/releases/tags/v2.3.4".into(),
2495 detail: "rate limit exceeded".into(),
2496 },
2497 6,
2498 "module_feed_unusable",
2499 ),
2500 (
2501 CoreError::ModuleDigestChanged(Box::new(ModuleDigestChangedDetails {
2502 module: "git".into(),
2503 version: "2.3.4".into(),
2504 cached_digest: "a".repeat(64),
2505 upstream_digest: "b".repeat(64),
2506 cached_path: "/cache/modules/git/2.3.4-aaaa.modl".into(),
2507 })),
2508 6,
2509 "module_digest_changed",
2510 ),
2511 (
2512 CoreError::ModuleNotRegistered {
2513 id: "bogus".into(),
2514 known: vec!["git".into()],
2515 },
2516 3,
2517 "module_not_registered",
2518 ),
2519 (
2520 CoreError::ModuleUninstallDenied {
2521 module_id: "git".into(),
2522 gateway_module_id: "com.axone_io.ignition.git".into(),
2523 failed: vec!["com.axone_io.ignition.git".into()],
2524 endpoint: Some(
2525 "http://gw:8088/data/api/v1/modules/uninstall".into(),
2526 ),
2527 },
2528 6,
2529 "module_uninstall_denied",
2530 ),
2531 (
2532 CoreError::ScanLockHeld {
2533 actor: Some("api-token:cap-a:172.17.0.1".into()),
2534 kind: "config".into(),
2535 endpoint: Some("http://gw:8088/data/api/v1/scan-lock/config".into()),
2536 },
2537 6,
2538 "scan_lock_held",
2539 ),
2540 (
2541 CoreError::ResourceSignatureRejected {
2542 name: "DB_A".into(),
2543 reason: SignatureReason::Stale,
2544 endpoint: Some(
2545 "http://gw:8088/data/api/v1/resources/find/ignition/database-connection/DB_A"
2546 .into(),
2547 ),
2548 },
2549 6,
2550 "resource_signature_rejected",
2551 ),
2552 (
2553 CoreError::ResourceExists {
2554 name: "DB_A".into(),
2555 endpoint: Some("http://gw:8088/data/api/v1/resources/ignition/database-connection".into()),
2556 },
2557 6,
2558 "resource_exists",
2559 ),
2560 (
2561 CoreError::ModeExists {
2562 name: "production".into(),
2563 endpoint: Some("http://gw:8088/data/api/v1/mode".into()),
2564 },
2565 6,
2566 "mode_exists",
2567 ),
2568 (
2569 CoreError::deploy_tree_invalid("manifest.yaml", Some(2), Some(1), "unknown field"),
2570 2,
2571 "deploy_tree_invalid",
2572 ),
2573 (
2574 CoreError::embedded_secret(vec!["a.json: settings.password".into()], vec![]),
2575 2,
2576 "embedded_secret",
2577 ),
2578 (
2579 CoreError::TransportFailed {
2580 operation: "read".into(),
2581 node: "nodes.master".into(),
2582 detail: "cannot read directory config/resources (NotFound)".into(),
2583 },
2584 4,
2585 "transport_failed",
2586 ),
2587 (
2588 CoreError::DeployLedgerInvalid {
2589 location: "ign-deploy/gw/dev/ledger.json".into(),
2590 problem: "files is not byte-sorted".into(),
2591 },
2592 6,
2593 "deploy_ledger_invalid",
2594 ),
2595 (
2596 CoreError::PlanStale {
2597 expected: "a".repeat(64),
2598 actual: "b".repeat(64),
2599 detail: "review hash".into(),
2600 },
2601 6,
2602 "plan_stale",
2603 ),
2604 (
2605 CoreError::DestructiveChangeRefused {
2606 gate: "destructive".into(),
2607 keys: vec!["ignition/a/x".into()],
2608 count: 1,
2609 },
2610 2,
2611 "destructive_change_refused",
2612 ),
2613 (
2614 CoreError::DeployBlocked {
2615 keys: vec!["ignition/a/x".into()],
2616 },
2617 6,
2618 "deploy_blocked",
2619 ),
2620 (
2621 CoreError::DeployPreflightFailed {
2622 failures: vec![PreflightFailure {
2623 check: "mode_master".into(),
2624 detail: "the gateway is not in the declared mode".into(),
2625 }]
2626 .into(),
2627 },
2628 6,
2629 "deploy_preflight_failed",
2630 ),
2631 (
2632 CoreError::DeployJournalPresent {
2633 run_id: "r1".into(),
2634 env: "prod".into(),
2635 location: "ign-deploy/gw/m/journal.json".into(),
2636 },
2637 6,
2638 "deploy_journal_present",
2639 ),
2640 (
2641 CoreError::DeployLiveChanged {
2642 run_id: "r1".into(),
2643 paths: vec!["a/b.json".into()],
2644 count: 1,
2645 },
2646 6,
2647 "deploy_live_changed",
2648 ),
2649 (
2650 CoreError::DeployRunInvalid {
2651 run_id: "r1".into(),
2652 problem: "record.json is missing".into(),
2653 },
2654 6,
2655 "deploy_run_invalid",
2656 ),
2657 (
2658 CoreError::RollbackFailed(Box::new(RollbackFailure {
2659 run_id: "r1".into(),
2660 env: "dev".into(),
2661 run_dir: Some(".ign/deploy/r1".into()),
2662 journal: "ign-deploy/gw/m/journal.json".into(),
2663 cause: "write_failed".into(),
2664 })),
2665 6,
2666 "rollback_failed",
2667 ),
2668 (
2669 CoreError::ProjectGitManaged {
2670 project: "CapApp".into(),
2671 location: "projects/CapApp/.git".into(),
2672 kind: "directory".into(),
2673 },
2674 6,
2675 "project_git_managed",
2676 ),
2677 (
2678 CoreError::ProjectParentInvalid {
2679 project: "CapApp".into(),
2680 parent: "WHK-Global".into(),
2681 reason: "missing".into(),
2682 },
2683 6,
2684 "project_parent_invalid",
2685 ),
2686 (
2687 CoreError::ProjectHashMismatch {
2688 project: "CapApp".into(),
2689 expected: "0".repeat(64),
2690 actual: "1".repeat(64),
2691 },
2692 6,
2693 "project_hash_mismatch",
2694 ),
2695 (
2696 CoreError::DeployUnreadable {
2697 node: "master".into(),
2698 keys: vec!["ignition/tag-definition/default/Deep".into()],
2699 },
2700 6,
2701 "deploy_unreadable",
2702 ),
2703 (
2704 CoreError::DeployPairTagsRefused {
2705 mode: "production".into(),
2706 keys: vec!["ignition/tag-definition/default/Deploy".into()],
2707 },
2708 6,
2709 "deploy_pair_tags_refused",
2710 ),
2711 ];
2712 for (err, code, slug) in cases {
2713 assert_eq!(err.exit_code(), code, "wrong exit code for: {err}");
2714 assert_eq!(err.code(), slug, "unstable slug for: {err}");
2715 }
2716 }
2717
2718 const EXIT_SLUG_LITERALS: &[(u8, &str)] = &[
2727 (1, "internal"),
2728 (2, "confirmation_required"),
2729 (2, "profiling_risk_not_accepted"),
2730 (2, "invalid_import_file"),
2731 (2, "invalid_input"),
2732 (2, "gateway_client_error"),
2733 (2, "deploy_tree_invalid"),
2734 (2, "embedded_secret"),
2735 (2, "destructive_change_refused"),
2736 (3, "profile_not_found"),
2737 (3, "no_active_profile"),
2738 (3, "secret_unavailable"),
2739 (3, "password_unavailable"),
2740 (3, "config_invalid"),
2741 (3, "poll_interval_too_small"),
2742 (3, "module_not_registered"),
2743 (4, "network_error"),
2744 (5, "auth_rejected"),
2745 (6, "gateway_too_old"),
2746 (6, "gateway_not_commissioned"),
2747 (6, "gateway_restarting"),
2748 (6, "not_found"),
2749 (6, "project_exists"),
2750 (6, "resource_binary"),
2751 (6, "trial_not_expired"),
2752 (6, "provider_not_found"),
2753 (6, "routes_not_deployed"),
2754 (6, "webdev_unlicensed"),
2755 (6, "route_version_mismatch"),
2756 (6, "webdev_route_error"),
2757 (6, "tag_collision"),
2758 (6, "alarm_journal_missing"),
2759 (6, "import_denied"),
2760 (6, "session_not_prunable"),
2761 (6, "eam_not_controller"),
2762 (6, "eam_task_type_refused"),
2763 (6, "eam_task_in_flight"),
2764 (6, "script_exec_not_configured"),
2765 (6, "lint_tool_absent"),
2766 (6, "node_tool_absent"),
2767 (6, "provider_root_unsupported"),
2768 (6, "bundle_not_available"),
2769 (4, "module_feed_unreachable"),
2770 (4, "transport_failed"),
2771 (6, "module_release_not_found"),
2772 (6, "module_digest_mismatch"),
2773 (6, "module_feed_unusable"),
2774 (6, "module_digest_changed"),
2775 (6, "module_uninstall_denied"),
2776 (6, "scan_lock_held"),
2777 (6, "resource_signature_rejected"),
2778 (6, "resource_exists"),
2779 (6, "mode_exists"),
2780 (6, "deploy_ledger_invalid"),
2781 (6, "plan_stale"),
2782 (6, "deploy_blocked"),
2783 (6, "deploy_preflight_failed"),
2784 (6, "deploy_journal_present"),
2785 (6, "deploy_live_changed"),
2786 (6, "deploy_run_invalid"),
2787 (6, "rollback_failed"),
2788 (6, "project_git_managed"),
2789 (6, "project_parent_invalid"),
2790 (6, "project_hash_mismatch"),
2791 (6, "deploy_unreadable"),
2792 (6, "deploy_pair_tags_refused"),
2793 (7, "rig_error"),
2794 ];
2795
2796 #[test]
2799 fn deploy_slugs_never_carry_signature_or_problem_text() {
2800 const SAMPLE_SIG: &str = "d80c81d167362252bf389893ea0f3a81479521ba0bb68ee3bfc46e94bc2540b7";
2801
2802 let held = CoreError::ScanLockHeld {
2803 actor: Some("api-token:cap-a:172.17.0.1".into()),
2804 kind: "config".into(),
2805 endpoint: Some("http://gw:8088/data/api/v1/scan-lock/config".into()),
2806 };
2807 assert_eq!(held.code(), "scan_lock_held");
2808 assert_eq!(held.exit_code(), 6);
2809 let shown = held.to_string();
2810 assert!(shown.contains("api-token:cap-a:172.17.0.1"), "{shown}");
2811 assert!(shown.contains("config"), "{shown}");
2812 assert_eq!(
2813 held.endpoint().as_deref(),
2814 Some("http://gw:8088/data/api/v1/scan-lock/config")
2815 );
2816
2817 let anonymous = CoreError::ScanLockHeld {
2818 actor: None,
2819 kind: "config".into(),
2820 endpoint: None,
2821 };
2822 let shown = anonymous.to_string();
2823 assert!(shown.contains("another actor"), "{shown}");
2824 assert!(!shown.contains("None"), "{shown}");
2825
2826 for reason in [SignatureReason::Stale, SignatureReason::Missing] {
2827 let err = CoreError::ResourceSignatureRejected {
2828 name: "DB_A".into(),
2829 reason,
2830 endpoint: Some("http://gw:8088/data/api/v1/resources/x".into()),
2831 };
2832 assert_eq!(err.code(), "resource_signature_rejected");
2833 assert_eq!(err.exit_code(), 6);
2834 let shown = err.to_string();
2835 assert!(shown.contains("DB_A"), "{shown}");
2836 assert!(shown.contains(&reason.to_string()), "{shown}");
2837 assert!(!shown.contains(SAMPLE_SIG), "{shown}");
2838 let hint = err.hint().expect("hint required");
2839 assert!(!hint.contains(SAMPLE_SIG), "{hint}");
2840 let endpoint = err.endpoint().expect("endpoint");
2841 assert!(!endpoint.contains(SAMPLE_SIG), "{endpoint}");
2842 }
2843 assert_eq!(SignatureReason::Stale.to_string(), "stale");
2844 assert_eq!(SignatureReason::Missing.to_string(), "missing");
2845 }
2846
2847 fn parse_readme_exit_table(readme: &str) -> Vec<(u8, Vec<String>)> {
2856 let section = readme.split("## Exit codes").nth(1).unwrap_or_default();
2857 let mut rows = Vec::new();
2858 let table_lines = section
2859 .lines()
2860 .skip_while(|line| !line.trim_start().starts_with('|'));
2861 for line in table_lines {
2862 if !line.trim_start().starts_with('|') {
2863 break; }
2865 let cells: Vec<&str> = line.split('|').map(str::trim).collect();
2866 let Some(exit) = cells.get(1).and_then(|cell| cell.parse::<u8>().ok()) else {
2867 continue;
2868 };
2869 if !(1..=7).contains(&exit) {
2870 continue;
2871 }
2872 let slug_cell = cells
2873 .iter()
2874 .rev()
2875 .find(|cell| !cell.is_empty())
2876 .copied()
2877 .unwrap_or_default();
2878 let slugs: Vec<String> = slug_cell
2879 .split('`')
2880 .enumerate()
2881 .filter(|(idx, _)| idx % 2 == 1)
2882 .map(|(_, token)| token.trim().to_string())
2883 .filter(|token| {
2884 token.starts_with(|c: char| c.is_ascii_alphabetic()) && !token.contains(' ')
2885 })
2886 .collect();
2887 rows.push((exit, slugs));
2888 }
2889 rows
2890 }
2891
2892 #[test]
2903 fn readme_exit_table_agreement() {
2904 let readme = include_str!("../../../README.md");
2905 let rows = parse_readme_exit_table(readme);
2906 assert!(
2907 rows.len() >= 7,
2908 "README exit-code table not found — the parser must see all 7 \
2909 failure-class rows (found {})",
2910 rows.len()
2911 );
2912
2913 for (exit, slug) in EXIT_SLUG_LITERALS {
2916 let row = rows
2917 .iter()
2918 .find(|(readme_exit, _)| readme_exit == exit)
2919 .unwrap_or_else(|| panic!("README table has no row for exit {exit}"));
2920 assert!(
2921 row.1.iter().any(|s| s == slug),
2922 "README exit-{exit} row is missing slug {slug:?} (row: {:?})",
2923 row.1
2924 );
2925 }
2926
2927 for (exit, slugs) in &rows {
2930 for slug in slugs {
2931 assert!(
2932 EXIT_SLUG_LITERALS
2933 .iter()
2934 .any(|(lit_exit, lit_slug)| lit_exit == exit && lit_slug == slug),
2935 "README exit-{exit} row carries slug {slug:?} that no \
2936 CoreError variant emits — stale table row or slug/exit \
2937 drift between README and error.rs"
2938 );
2939 }
2940 }
2941 }
2942
2943 #[test]
2946 fn hints_are_actionable_for_config_auth_target_state() {
2947 let profile_not_found = CoreError::ProfileNotFound {
2948 name: "x".into(),
2949 known: vec!["dev".into(), "prod".into()],
2950 };
2951 let hint = profile_not_found.hint().expect("hint required");
2952 assert!(
2953 hint.contains("dev"),
2954 "hint must list known profiles: {hint}"
2955 );
2956 assert!(
2957 hint.contains("ign profile add"),
2958 "hint must name the fix: {hint}"
2959 );
2960
2961 let auth = CoreError::Auth {
2962 status: 403,
2963 endpoint: None,
2964 };
2965 let hint = auth.hint().expect("hint required");
2966 assert!(
2967 hint.contains("three parts"),
2968 "auth hint must name the three-part token setup: {hint}"
2969 );
2970
2971 let unauthorized = CoreError::Auth {
2974 status: 401,
2975 endpoint: None,
2976 };
2977 let hint = unauthorized.hint().expect("hint required");
2978 assert!(
2979 hint.contains("name:key"),
2980 "401 hint must name the full name:key token format: {hint}"
2981 );
2982 assert!(
2983 hint.contains("API token"),
2984 "401 hint must say Basic cannot work: {hint}"
2985 );
2986 let hint403 = auth.hint().expect("hint required");
2987 assert!(
2988 hint403.contains("secure connections"),
2989 "403 hint must name the secure-channel part: {hint403}"
2990 );
2991
2992 let too_old = CoreError::GatewayTooOld {
2993 found: "8.1.0".into(),
2994 minimum: "8.3.1".into(),
2995 endpoint: None,
2996 };
2997 let hint = too_old.hint().expect("hint required");
2998 assert!(
2999 hint.contains("8.3.1"),
3000 "target-state hint must name the minimum: {hint}"
3001 );
3002
3003 let undeployed = CoreError::RoutesNotDeployed {
3007 project: "ign-cli".into(),
3008 route: "tags".into(),
3009 endpoint: None,
3010 };
3011 let hint = undeployed.hint().expect("hint required");
3012 assert!(
3013 hint.contains("ign webdev deploy"),
3014 "absent-routes hint must name the fix: {hint}"
3015 );
3016
3017 let older = CoreError::RouteVersionMismatch {
3018 route: "tags".into(),
3019 deployed: "0.9.0".into(),
3020 expected: "1.0.0".into(),
3021 endpoint: None,
3022 };
3023 let hint = older.hint().expect("hint required");
3024 assert!(
3025 hint.contains("ign webdev deploy") && !hint.contains("update ign"),
3026 "older-route hint says redeploy: {hint}"
3027 );
3028
3029 let newer = CoreError::RouteVersionMismatch {
3030 route: "tags".into(),
3031 deployed: "1.1.0".into(),
3032 expected: "1.0.0".into(),
3033 endpoint: None,
3034 };
3035 let hint = newer.hint().expect("hint required");
3036 assert!(
3037 hint.contains("update ign") && !hint.contains("ign webdev deploy"),
3038 "newer-route hint says update ign: {hint}"
3039 );
3040
3041 let secret_gate = CoreError::WebdevRouteError {
3042 code: "secret_required".into(),
3043 message: "missing x-ignition-cli-secret header".into(),
3044 endpoint: None,
3045 };
3046 let hint = secret_gate.hint().expect("hint required");
3047 assert!(
3048 hint.contains("--with-script-exec"),
3049 "secret-gate hint names the deploy flag: {hint}"
3050 );
3051
3052 let journal = CoreError::AlarmJournalMissing { endpoint: None };
3055 let hint = journal.hint().expect("hint required");
3056 assert!(
3057 hint.contains("journal profile") && hint.contains("database connection"),
3058 "journal hint names the chain: {hint}"
3059 );
3060 assert!(
3061 hint.contains("README"),
3062 "journal hint points at the README section: {hint}"
3063 );
3064
3065 let prunable = CoreError::SessionNotPrunable {
3068 id: "d-live-1".into(),
3069 endpoint: None,
3070 };
3071 let hint = prunable.hint().expect("hint required");
3072 assert!(
3073 hint.contains("close the Designer"),
3074 "prune hint must name the action: {hint}"
3075 );
3076 assert!(
3077 hint.contains("stale entries"),
3078 "prune hint must name stale-only semantics: {hint}"
3079 );
3080
3081 let held = CoreError::ScanLockHeld {
3084 actor: None,
3085 kind: "config".into(),
3086 endpoint: None,
3087 };
3088 let hint = held.hint().expect("hint required");
3089 assert!(
3090 hint.contains("holdTimeout") && hint.contains("scan runs"),
3091 "scan-lock hint names the release conditions: {hint}"
3092 );
3093 assert!(
3094 !hint.contains("ign "),
3095 "scan-lock hint names no ign command: {hint}"
3096 );
3097
3098 let rejected = CoreError::ResourceSignatureRejected {
3100 name: "DB_A".into(),
3101 reason: SignatureReason::Stale,
3102 endpoint: None,
3103 };
3104 let hint = rejected.hint().expect("hint required");
3105 assert!(
3106 hint.contains("re-read") && hint.contains("signatures change on every write"),
3107 "signature hint names the action: {hint}"
3108 );
3109
3110 let exists = CoreError::ResourceExists {
3113 name: "DB_A".into(),
3114 endpoint: None,
3115 };
3116 let hint = exists.hint().expect("hint required");
3117 assert!(
3118 hint.contains("update it"),
3119 "resource_exists hint names the action: {hint}"
3120 );
3121 assert_eq!(exists.to_string(), "resource \"DB_A\" already exists");
3122 let mode_exists = CoreError::ModeExists {
3123 name: "production".into(),
3124 endpoint: None,
3125 };
3126 let hint = mode_exists.hint().expect("hint required");
3127 assert!(
3128 hint.contains("skip the create"),
3129 "mode_exists hint names the action: {hint}"
3130 );
3131
3132 let tty = CoreError::tui_tty_refusal();
3137 assert_eq!(tty.code(), "invalid_input", "slug unchanged");
3138 assert_eq!(tty.exit_code(), 2, "usage class unchanged");
3139 let hint = tty.hint().expect("hint required");
3140 assert!(
3141 hint.contains("interactive terminal"),
3142 "TTY hint must name the fix: {hint}"
3143 );
3144 assert!(
3145 !hint.contains("--file"),
3146 "TTY hint must not carry the resource-put hint: {hint}"
3147 );
3148
3149 let top = CoreError::top_tty_refusal();
3154 assert_eq!(top.code(), "invalid_input", "slug unchanged");
3155 assert_eq!(top.exit_code(), 2, "usage class unchanged");
3156 let top_hint = top.hint().expect("hint required");
3157 assert!(
3158 top_hint.contains("`ign top`"),
3159 "the top refusal's hint names ign top: {top_hint}"
3160 );
3161 assert!(
3162 !top_hint.contains("ign tui"),
3163 "the top refusal never names the cockpit command: {top_hint}"
3164 );
3165 assert!(
3166 top_hint.contains("interactive terminal"),
3167 "the top refusal keeps the terminal-contextual fix: {top_hint}"
3168 );
3169 let put = CoreError::InvalidInput {
3170 reason: "cannot read put.json".into(),
3171 };
3172 let hint = put.hint().expect("hint required");
3173 assert!(
3174 hint.contains("--file") && hint.contains("stdin"),
3175 "resource-put hint unchanged: {hint}"
3176 );
3177
3178 let controller = CoreError::EamNotController { endpoint: None };
3182 let hint = controller.hint().expect("hint required");
3183 assert!(
3184 hint.contains("installMode"),
3185 "controller hint names the flip: {hint}"
3186 );
3187 assert!(
3188 hint.contains("Controller"),
3189 "controller hint names the target role: {hint}"
3190 );
3191 assert!(
3192 hint.contains("README"),
3193 "controller hint points at the README section: {hint}"
3194 );
3195
3196 let refused = CoreError::EamTaskTypeRefused {
3199 task_type: "eam_restoreBackup".into(),
3200 };
3201 assert_eq!(refused.exit_code(), 6, "target state");
3202 let hint = refused.hint().expect("hint required");
3203 assert!(
3204 hint.contains("EAM console"),
3205 "refused hint names where to run it: {hint}"
3206 );
3207 assert!(
3208 hint.contains("fleet-wide") || hint.contains("EXT-03"),
3209 "refused hint names the fleet consequence / scope: {hint}"
3210 );
3211
3212 let in_flight = CoreError::EamTaskInFlight {
3216 task: "cli-research-backup".into(),
3217 detail: "the previous '(forced)' run must be completed or deleted first".into(),
3218 endpoint: None,
3219 };
3220 assert_eq!(in_flight.exit_code(), 6, "target state");
3221 let hint = in_flight.hint().expect("hint required");
3222 assert!(
3223 hint.contains("EAM console"),
3224 "in-flight hint names where to resolve it: {hint}"
3225 );
3226 assert!(
3227 hint.contains("complete or delete"),
3228 "in-flight hint names the resolution: {hint}"
3229 );
3230
3231 let unconfigured = CoreError::ScriptExecNotConfigured {
3235 profile: "dev".into(),
3236 };
3237 assert_eq!(unconfigured.exit_code(), 6, "target state");
3238 let hint = unconfigured.hint().expect("hint required");
3239 assert!(
3240 hint.contains("ign webdev deploy --with-script-exec"),
3241 "the hint names the deploy flag verbatim: {hint}"
3242 );
3243
3244 let no_active = CoreError::NoActiveProfile;
3246 assert!(no_active.hint().is_some());
3247 }
3248
3249 #[test]
3254 fn error_envelope_locked_shape_and_endpoint() {
3255 let auth = CoreError::Auth {
3256 status: 401,
3257 endpoint: Some("https://gw.example.com/data/api/v1/gateway-info".into()),
3258 };
3259 let envelope: ErrorEnvelope<'_> = auth.envelope(Some("dev"));
3260 let json = serde_json::to_string(&envelope).expect("serialize envelope");
3261
3262 assert_eq!(
3263 json,
3264 concat!(
3265 r#"{"ok":false,"profile":"dev","error":{"code":"auth_rejected","#,
3266 r#""message":"gateway rejected credentials (HTTP 401)","#,
3267 r#""endpoint":"https://gw.example.com/data/api/v1/gateway-info","#,
3268 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"}}"#
3269 )
3270 );
3271
3272 let no_request = CoreError::NoActiveProfile;
3274 let body: &ErrorBody = &no_request.envelope(None).error;
3275 assert_eq!(body.endpoint, None);
3276 }
3277
3278 #[test]
3284 fn truncates_at_cap_with_marker() {
3285 let short = r#"{"error":{"code":"NOT_FOUND"}}"#;
3287 assert_eq!(truncate_api_body(short), short);
3288 assert!(!short.contains(GATEWAY_CLIENT_BODY_TRUNCATION_MARKER));
3289
3290 let exact = "x".repeat(GATEWAY_CLIENT_BODY_CAP_BYTES);
3293 assert_eq!(truncate_api_body(&exact), exact);
3294
3295 let over = "x".repeat(GATEWAY_CLIENT_BODY_CAP_BYTES + 1);
3297 let truncated = truncate_api_body(&over);
3298 assert!(
3299 truncated.ends_with(GATEWAY_CLIENT_BODY_TRUNCATION_MARKER),
3300 "truncated body must end with the explicit marker"
3301 );
3302 assert!(
3303 truncated.len()
3304 <= GATEWAY_CLIENT_BODY_CAP_BYTES + GATEWAY_CLIENT_BODY_TRUNCATION_MARKER.len(),
3305 "truncated body must stay within cap + marker: {}",
3306 truncated.len()
3307 );
3308
3309 let multibyte = "é".repeat(GATEWAY_CLIENT_BODY_CAP_BYTES); let truncated = truncate_api_body(&multibyte);
3314 assert!(truncated.ends_with(GATEWAY_CLIENT_BODY_TRUNCATION_MARKER));
3315 assert!(
3316 truncated
3317 .is_char_boundary(truncated.len() - GATEWAY_CLIENT_BODY_TRUNCATION_MARKER.len())
3318 );
3319 }
3320
3321 #[test]
3330 fn loss_gate_hint_override_and_neighbors_unchanged() {
3331 let loss_gate = CoreError::InvalidInput {
3334 reason: format!(
3335 "{}xml): the scan reports 2 finding(s) before the import:\nre-run \
3336 with --yes to import anyway",
3337 LOSS_GATE_REFUSAL_REASON_PREFIX
3338 ),
3339 };
3340 assert_eq!(loss_gate.code(), "invalid_input", "slug unchanged");
3341 assert_eq!(loss_gate.exit_code(), 2, "usage class unchanged");
3342 let hint = loss_gate.hint().expect("hint required");
3343 assert!(
3344 hint.contains("--yes"),
3345 "loss-gate hint must name the --yes re-run: {hint}"
3346 );
3347 assert!(
3348 hint.contains("loss report above"),
3349 "loss-gate hint must point back at the report: {hint}"
3350 );
3351 assert!(
3352 !hint.contains("fix the input source"),
3353 "the file-read default must never leak onto the loss-gate path: {hint}"
3354 );
3355
3356 let generic = CoreError::InvalidInput {
3359 reason: "x is not valid JSON: expected value at line 1".into(),
3360 };
3361 let hint = generic.hint().expect("hint required");
3362 assert!(
3363 hint.contains("--file") && hint.contains("stdin"),
3364 "resource-put hint unchanged: {hint}"
3365 );
3366 assert!(
3367 !hint.contains("--yes"),
3368 "the --yes hint must not leak onto generic reasons: {hint}"
3369 );
3370
3371 let tty = CoreError::tui_tty_refusal();
3373 let hint = tty.hint().expect("hint required");
3374 assert!(
3375 hint.contains("interactive terminal"),
3376 "TTY hint unchanged: {hint}"
3377 );
3378 assert!(
3379 !hint.contains("--yes"),
3380 "the --yes hint must not leak onto the TTY path: {hint}"
3381 );
3382 }
3383
3384 #[test]
3392 fn routes_not_deployed_hint_is_testing_scoped() {
3393 let testing = CoreError::RoutesNotDeployed {
3394 project: "Flux".into(),
3395 route: format!("{TESTING_ROUTE_PREFIX}run"),
3396 endpoint: None,
3397 };
3398 assert_eq!(testing.code(), "routes_not_deployed", "slug unchanged");
3399 assert_eq!(testing.exit_code(), 6, "target-state class unchanged");
3400 let hint = testing.hint().expect("hint required");
3401 assert!(
3402 hint.contains("ign webdev deploy --with-testing"),
3403 "testing hint must name the deploy flag: {hint}"
3404 );
3405 assert!(
3406 hint.contains("ign adopt") && hint.contains("--testing"),
3407 "testing hint must name the adopt path: {hint}"
3408 );
3409
3410 let generic = CoreError::RoutesNotDeployed {
3412 project: "ign-cli".into(),
3413 route: "tags".into(),
3414 endpoint: None,
3415 };
3416 let hint = generic.hint().expect("hint required");
3417 assert_eq!(
3418 hint,
3419 "run `ign webdev deploy` to install the CLI's WebDev routes into \
3420 the gateway, then retry",
3421 "the generic branch is byte-identical to its pre-QUICK-p0g text"
3422 );
3423 }
3424
3425 fn finding(file: &str, line: Option<usize>, column: Option<usize>, msg: &str) -> DeployFinding {
3426 DeployFinding {
3427 file: file.into(),
3428 line,
3429 column,
3430 message: msg.into(),
3431 }
3432 }
3433
3434 #[test]
3435 fn deploy_finding_display_names_file_line_column() {
3436 let positioned = finding("targets/prod.yaml", Some(3), Some(1), "unknown field `x`");
3437 assert_eq!(
3438 positioned.to_string(),
3439 "targets/prod.yaml:3:1: unknown field `x`"
3440 );
3441 let bare = finding("targets/prod.yaml", None, None, "missing field `gateway`");
3442 assert_eq!(
3443 bare.to_string(),
3444 "targets/prod.yaml: missing field `gateway`"
3445 );
3446 let half = finding("m.yaml", Some(3), None, "m");
3448 assert_eq!(half.to_string(), "m.yaml: m");
3449 }
3450
3451 #[test]
3452 fn deploy_findings_cap_at_twenty() {
3453 let many: Vec<DeployFinding> = (0..25)
3454 .map(|i| finding("manifest.yaml", None, None, &format!("problem {i}")))
3455 .collect();
3456 let shown = CoreError::deploy_findings(many).to_string();
3457 assert!(shown.contains("25 findings: "), "{shown}");
3458 assert!(shown.contains("problem 19"), "{shown}");
3459 assert!(!shown.contains("problem 20"), "{shown}");
3460 assert!(shown.ends_with("; and 5 more"), "{shown}");
3461
3462 let single = CoreError::deploy_tree_invalid("m.yaml", Some(2), Some(1), "bad");
3463 assert_eq!(single.to_string(), "deploy tree invalid: m.yaml:2:1: bad");
3464 }
3465
3466 #[test]
3467 fn embedded_secret_display_lists_paths_only() {
3468 let err = CoreError::embedded_secret(
3469 vec![
3470 "ignition/database-connection/A/config.json: settings.password".into(),
3471 "ignition/database-connection/B/config.json: settings.password".into(),
3472 ],
3473 vec![finding("manifest.yaml", None, None, "duplicate type")],
3474 );
3475 let shown = err.to_string();
3476 assert!(shown.contains("2 path(s)"), "{shown}");
3477 assert!(
3478 shown.contains("A/config.json: settings.password"),
3479 "{shown}"
3480 );
3481 assert!(
3482 shown.contains("B/config.json: settings.password"),
3483 "{shown}"
3484 );
3485 assert!(shown.contains("also 1 other tree finding(s)"), "{shown}");
3486 assert!(shown.contains("manifest.yaml: duplicate type"), "{shown}");
3487
3488 let paths: Vec<String> = (0..25).map(|i| format!("f{i}.json: a.b")).collect();
3490 let shown =
3491 CoreError::embedded_secret(paths, vec![finding("m.yaml", None, None, "x")]).to_string();
3492 assert!(shown.contains("25 path(s)"), "{shown}");
3493 assert!(shown.contains("and 5 more"), "{shown}");
3494 assert!(shown.contains("f19.json: a.b"), "{shown}");
3495 assert!(!shown.contains("f20.json: a.b"), "{shown}");
3496 }
3497
3498 #[test]
3499 fn deploy_slugs_have_their_exit_codes() {
3500 let errs = [
3501 (
3502 CoreError::deploy_tree_invalid("m.yaml", None, None, "bad"),
3503 2,
3504 ),
3505 (
3506 CoreError::embedded_secret(vec!["a.json: p".into()], vec![]),
3507 2,
3508 ),
3509 (
3510 CoreError::TransportFailed {
3511 operation: "read".into(),
3512 node: "nodes.master".into(),
3513 detail: "data_dir is missing".into(),
3514 },
3515 4,
3516 ),
3517 (
3518 CoreError::DeployLedgerInvalid {
3519 location: "ign-deploy/gw/dev/ledger.json".into(),
3520 problem: "the file is empty".into(),
3521 },
3522 6,
3523 ),
3524 (
3525 CoreError::PlanStale {
3526 expected: "a".repeat(64),
3527 actual: "b".repeat(64),
3528 detail: "review hash".into(),
3529 },
3530 6,
3531 ),
3532 (
3533 CoreError::DestructiveChangeRefused {
3534 gate: "destructive".into(),
3535 keys: vec!["ignition/a/x".into()],
3536 count: 1,
3537 },
3538 2,
3539 ),
3540 (
3541 CoreError::DeployBlocked {
3542 keys: vec!["ignition/a/x".into()],
3543 },
3544 6,
3545 ),
3546 (
3547 CoreError::DeployPreflightFailed {
3548 failures: vec![PreflightFailure {
3549 check: "mode_master".into(),
3550 detail: "the gateway is not in the declared mode".into(),
3551 }]
3552 .into(),
3553 },
3554 6,
3555 ),
3556 (
3557 CoreError::DeployJournalPresent {
3558 run_id: "20261015T142233Z-a1b2c3".into(),
3559 env: "prod".into(),
3560 location: "ign-deploy/gw/m/journal.json".into(),
3561 },
3562 6,
3563 ),
3564 (
3565 CoreError::DeployLiveChanged {
3566 run_id: "r1".into(),
3567 paths: vec!["a/b.json".into()],
3568 count: 1,
3569 },
3570 6,
3571 ),
3572 (
3573 CoreError::DeployRunInvalid {
3574 run_id: "r1".into(),
3575 problem: "record.json is missing".into(),
3576 },
3577 6,
3578 ),
3579 (
3580 CoreError::RollbackFailed(Box::new(RollbackFailure {
3581 run_id: "r1".into(),
3582 env: "dev".into(),
3583 run_dir: Some(".ign/deploy/r1".into()),
3584 journal: "ign-deploy/gw/m/journal.json".into(),
3585 cause: "write_failed".into(),
3586 })),
3587 6,
3588 ),
3589 (
3590 CoreError::ProjectGitManaged {
3591 project: "CapApp".into(),
3592 location: "projects/CapApp/.git".into(),
3593 kind: "directory".into(),
3594 },
3595 6,
3596 ),
3597 (
3598 CoreError::ProjectParentInvalid {
3599 project: "CapApp".into(),
3600 parent: "WHK-Global".into(),
3601 reason: "cycle".into(),
3602 },
3603 6,
3604 ),
3605 (
3606 CoreError::ProjectHashMismatch {
3607 project: "CapApp".into(),
3608 expected: "0".repeat(64),
3609 actual: "1".repeat(64),
3610 },
3611 6,
3612 ),
3613 (
3614 CoreError::DeployUnreadable {
3615 node: "master".into(),
3616 keys: vec!["ignition/tag-definition/default/Deep".into()],
3617 },
3618 6,
3619 ),
3620 (
3621 CoreError::DeployPairTagsRefused {
3622 mode: "production".into(),
3623 keys: vec!["ignition/tag-definition/default/Deploy".into()],
3624 },
3625 6,
3626 ),
3627 ];
3628 let codes: Vec<&str> = errs.iter().map(|(e, _)| e.code()).collect();
3629 assert_eq!(codes, CoreError::DEPLOY_SLUGS);
3630 for (err, exit) in &errs {
3631 assert_eq!(err.exit_code(), *exit, "{err}");
3632 assert_eq!(err.endpoint(), None, "deploy errors name no URL: {err}");
3633 let hint = err.hint().expect("hint required");
3634 assert!(
3635 !hint.contains("`ign ") && !hint.contains("ign "),
3636 "hint must not name an ign subcommand: {hint}"
3637 );
3638 }
3639 }
3640
3641 #[test]
3642 fn apply_gate_flag_refusals_do_not_get_the_file_hint() {
3643 for reason in [
3644 "--plan-sha256 must be 64 lowercase hex characters",
3645 "--allow-drift reason must not be empty",
3646 "--artifact-sha256 must be 64 lowercase hex characters",
3647 "--release <tag>: not a semver tag (for example v1.4.0)",
3648 "--allow-first-change <reason>: the reason is 1 to 500 characters",
3649 "deploy projects needs --artifact-sha256 when --json is given",
3650 ] {
3651 let hint = CoreError::InvalidInput {
3652 reason: reason.into(),
3653 }
3654 .hint()
3655 .expect("hint");
3656 assert!(!hint.contains("--file"), "{hint}");
3657 assert!(hint.contains("flag value"), "{hint}");
3658 }
3659 }
3660
3661 #[test]
3666 fn deploy_slugs_are_pinned_and_never_a_verdict() {
3667 assert_eq!(
3668 CoreError::DEPLOY_SLUGS,
3669 &[
3670 "deploy_tree_invalid",
3671 "embedded_secret",
3672 "transport_failed",
3673 "deploy_ledger_invalid",
3674 "plan_stale",
3675 "destructive_change_refused",
3676 "deploy_blocked",
3677 "deploy_preflight_failed",
3678 "deploy_journal_present",
3679 "deploy_live_changed",
3680 "deploy_run_invalid",
3681 "rollback_failed",
3682 "project_git_managed",
3683 "project_parent_invalid",
3684 "project_hash_mismatch",
3685 "deploy_unreadable",
3686 "deploy_pair_tags_refused",
3687 ]
3688 );
3689 assert_eq!(CoreError::DEPLOY_SLUGS.len(), 17);
3690 for verdict in [
3691 "deploy_rolled_back",
3692 "deploy_backup_unsynced",
3693 "deploy_project_unverified",
3694 ] {
3695 assert!(!CoreError::DEPLOY_SLUGS.contains(&verdict), "{verdict}");
3696 }
3697 }
3698
3699 #[test]
3702 fn project_git_managed_four_places() {
3703 let err = CoreError::ProjectGitManaged {
3704 project: "CapApp".into(),
3705 location: "projects/CapApp/.git".into(),
3706 kind: "directory".into(),
3707 };
3708 assert_eq!(err.code(), "project_git_managed");
3709 assert_eq!(err.exit_code(), 6);
3710 let message = err.to_string();
3711 for needle in [
3712 "CapApp",
3713 "projects/CapApp/.git",
3714 "directory",
3715 "administrator",
3716 ] {
3717 assert!(message.contains(needle), "{needle}: {message}");
3718 }
3719 let hint = err.hint().expect("hint required");
3720 assert!(!hint.contains("ign "), "{hint}");
3721 assert!(hint.contains(".git"), "{hint}");
3722 assert!(CoreError::DEPLOY_SLUGS.contains(&"project_git_managed"));
3723 }
3724
3725 #[test]
3729 fn project_parent_invalid_four_places() {
3730 for (reason, needle) in [
3731 ("missing", "its own repo"),
3732 ("cycle", "no cycle"),
3733 ("not_inheritable", "inheritable"),
3734 ] {
3735 let err = CoreError::ProjectParentInvalid {
3736 project: "CapApp".into(),
3737 parent: "WHK-Global".into(),
3738 reason: reason.into(),
3739 };
3740 assert_eq!(err.code(), "project_parent_invalid");
3741 assert_eq!(err.exit_code(), 6);
3742 let message = err.to_string();
3743 for part in ["CapApp", "WHK-Global", reason] {
3744 assert!(message.contains(part), "{part}: {message}");
3745 }
3746 let hint = err.hint().expect("hint required");
3747 assert!(!hint.contains("ign "), "{hint}");
3748 assert!(hint.contains(needle), "{reason}: {hint}");
3749 assert_eq!(err.endpoint(), None);
3750 }
3751 assert!(CoreError::DEPLOY_SLUGS.contains(&"project_parent_invalid"));
3752 }
3753
3754 #[test]
3758 fn project_hash_mismatch_four_places() {
3759 let err = CoreError::ProjectHashMismatch {
3760 project: "CapApp".into(),
3761 expected: "0".repeat(64),
3762 actual: "1".repeat(64),
3763 };
3764 assert_eq!(err.code(), "project_hash_mismatch");
3765 assert_eq!(err.exit_code(), 6);
3766 let message = err.to_string();
3767 for part in [
3768 "CapApp",
3769 "0000000000000000000000000000000000000000000000000000000000000000",
3770 "1111111111111111111111111111111111111111111111111111111111111111",
3771 "nothing was uploaded",
3772 ] {
3773 assert!(message.contains(part), "{part}: {message}");
3774 }
3775 let hint = err.hint().expect("hint required");
3776 assert!(!hint.contains("ign "), "{hint}");
3777 assert!(hint.contains("artifact_sha256"), "{hint}");
3778 assert_eq!(err.endpoint(), None);
3779 assert!(CoreError::DEPLOY_SLUGS.contains(&"project_hash_mismatch"));
3780 }
3781
3782 #[test]
3787 fn deploy_unreadable_four_places() {
3788 let keys = vec![
3789 "ignition/database-translator/OLD".to_string(),
3790 "ignition/tag-definition/default/Deep".to_string(),
3791 ];
3792 let err = CoreError::DeployUnreadable {
3793 node: "backup".to_string(),
3794 keys: keys.clone(),
3795 };
3796 assert_eq!(err.code(), "deploy_unreadable");
3797 assert_eq!(err.exit_code(), 6);
3798 assert_eq!(err.endpoint(), None);
3799 let message = err.to_string();
3800 for part in keys.iter().map(String::as_str).chain([
3801 "backup host's sftp server cannot read",
3802 "nothing was written",
3803 "2 unit(s)",
3804 ]) {
3805 assert!(message.contains(part), "{part}: {message}");
3806 }
3807 let hint = err.hint().expect("hint required");
3808 assert!(!hint.contains("ign "), "{hint}");
3809 assert!(hint.contains("exclude_tag_providers"), "{hint}");
3810 assert!(hint.contains("shorten"), "{hint}");
3811 assert!(CoreError::DEPLOY_SLUGS.contains(&"deploy_unreadable"));
3812 }
3813
3814 #[test]
3819 fn deploy_pair_tags_refused_four_places() {
3820 let keys = vec![
3821 "ignition/tag-definition/default/Deploy".to_string(),
3822 "ignition/tag-type-definition/default/DeployTypes".to_string(),
3823 ];
3824 let err = CoreError::DeployPairTagsRefused {
3825 mode: "production".to_string(),
3826 keys: keys.clone(),
3827 };
3828 assert_eq!(err.code(), "deploy_pair_tags_refused");
3829 assert_eq!(err.exit_code(), 6);
3830 assert_eq!(err.endpoint(), None);
3831 let message = err.to_string();
3832 for part in keys.iter().map(String::as_str).chain([
3833 "2 tag unit(s)",
3834 "production collection of a redundant pair",
3835 "nothing was written",
3836 ]) {
3837 assert!(message.contains(part), "{part}: {message}");
3838 }
3839 let hint = err.hint().expect("hint required");
3840 assert!(!hint.contains("ign "), "{hint}");
3841 assert!(!hint.contains("exclude_tag_providers"), "{hint}");
3842 assert!(hint.contains("ledger does not list"), "{hint}");
3843 assert!(hint.contains("core collection"), "{hint}");
3844 assert!(hint.contains("nothing was written"), "{hint}");
3845 assert!(CoreError::DEPLOY_SLUGS.contains(&"deploy_pair_tags_refused"));
3846 }
3847
3848 #[test]
3851 fn plan_stale_hint_names_the_live_project_case() {
3852 let live = CoreError::PlanStale {
3853 expected: "a".repeat(64),
3854 actual: "b".repeat(64),
3855 detail: "live project changed since the plan".into(),
3856 };
3857 let hint = live.hint().expect("hint");
3858 assert!(hint.contains("live project"), "{hint}");
3859 assert!(hint.contains("nothing was imported"), "{hint}");
3860 assert!(!hint.contains("ign "), "{hint}");
3861 let review = CoreError::PlanStale {
3862 expected: "a".repeat(64),
3863 actual: "b".repeat(64),
3864 detail: "review hash".into(),
3865 };
3866 assert!(
3867 review.hint().expect("hint").contains("plan_sha256"),
3868 "the plan-hash case keeps its hint"
3869 );
3870 }
3871
3872 #[test]
3873 fn journal_present_message_carries_the_recovery_command() {
3874 let err = CoreError::DeployJournalPresent {
3875 run_id: "20261015T142233Z-a1b2c3".into(),
3876 env: "prod".into(),
3877 location: "ign-deploy/gw/m/journal.json".into(),
3878 };
3879 let shown = err.to_string();
3880 assert!(
3881 shown.contains("ign deploy rollback --run 20261015T142233Z-a1b2c3 --env prod"),
3882 "{shown}"
3883 );
3884 assert!(shown.contains("ign-deploy/gw/m/journal.json"), "{shown}");
3885 let hint = err.hint().expect("hint");
3886 assert!(
3887 !hint.contains("ign deploy"),
3888 "D-09: the command lives in Display: {hint}"
3889 );
3890 }
3891
3892 #[test]
3893 fn rollback_failed_message_names_run_dir_journal_and_the_guidance() {
3894 let err = CoreError::RollbackFailed(Box::new(RollbackFailure {
3895 run_id: "r-7".into(),
3896 env: "prod".into(),
3897 run_dir: Some(".ign/deploy/r-7".into()),
3898 journal: "ign-deploy/gw/m/journal.json".into(),
3899 cause: "write_failed".into(),
3900 }));
3901 let shown = err.to_string();
3902 for needle in [
3903 "r-7",
3904 ".ign/deploy/r-7",
3905 "ign-deploy/gw/m/journal.json",
3906 ".gwbk",
3907 "snapshot",
3908 "by hand",
3909 "ign deploy rollback --run r-7 --env prod",
3912 "ign-deploy/gw/m/snapshots/r-7/",
3913 ] {
3914 assert!(shown.contains(needle), "{needle} missing from: {shown}");
3915 }
3916 assert!(
3917 shown.find("Retry with").expect("retry") < shown.find("by hand").expect("by hand"),
3918 "the retry precedes the hand deletion: {shown}"
3919 );
3920 assert!(
3921 !shown.contains("run-directory snapshot"),
3922 "the run directory has no snapshot to offer: {shown}"
3923 );
3924 let hint = err.hint().expect("hint");
3925 assert!(
3926 hint.contains("retry") && !hint.contains("run-directory snapshot"),
3927 "{hint}"
3928 );
3929 let none = CoreError::RollbackFailed(Box::new(RollbackFailure {
3930 run_id: "r-7".into(),
3931 env: "prod".into(),
3932 run_dir: None,
3933 journal: "j".into(),
3934 cause: "c".into(),
3935 }));
3936 assert!(!none.to_string().contains("None"), "{none}");
3937 }
3938
3939 #[test]
3940 fn preflight_failed_lists_every_failure_in_order() {
3941 let err = CoreError::DeployPreflightFailed {
3942 failures: vec![
3943 PreflightFailure {
3944 check: "mode_master".into(),
3945 detail: "first".into(),
3946 },
3947 PreflightFailure {
3948 check: "gwbk".into(),
3949 detail: "second".into(),
3950 },
3951 ]
3952 .into(),
3953 };
3954 let shown = err.to_string();
3955 let first = shown.find("mode_master: first").expect("first");
3956 let second = shown.find("gwbk: second").expect("second");
3957 assert!(first < second, "{shown}");
3958 assert_eq!(shown.lines().count(), 3, "{shown}");
3959 }
3960
3961 #[test]
3962 fn apply_refusal_lists_cap_at_ten() {
3963 let keys: Vec<String> = (0..10).map(|i| format!("u{i}")).collect();
3964 let shown = CoreError::DestructiveChangeRefused {
3965 gate: "destructive".into(),
3966 keys: keys.clone(),
3967 count: 13,
3968 }
3969 .to_string();
3970 assert!(shown.contains("13 entries"), "{shown}");
3971 assert!(shown.contains("and 3 more"), "{shown}");
3972 assert!(shown.contains("--allow-destructive"), "{shown}");
3973 let drift = CoreError::DestructiveChangeRefused {
3974 gate: "drift".into(),
3975 keys: keys[..1].to_vec(),
3976 count: 1,
3977 }
3978 .to_string();
3979 assert!(drift.contains("--allow-drift"), "{drift}");
3980 assert!(drift.contains("1 entry"), "{drift}");
3981 let downgrade = CoreError::DestructiveChangeRefused {
3982 gate: "downgrade".into(),
3983 keys: vec!["CapApp".into()],
3984 count: 1,
3985 };
3986 let shown = downgrade.to_string();
3987 assert!(shown.contains("downgrade"), "{shown}");
3988 assert!(shown.contains("CapApp"), "{shown}");
3989 assert!(
3990 shown.contains("deploy projects needs --allow-downgrade"),
3991 "{shown}"
3992 );
3993 assert!(!shown.contains("apply needs"), "{shown}");
3994 let first = CoreError::DestructiveChangeRefused {
3995 gate: "first_deploy".into(),
3996 keys: vec!["CapApp".into()],
3997 count: 1,
3998 };
3999 let shown = first.to_string();
4000 assert!(shown.contains("first_deploy"), "{shown}");
4001 assert!(
4002 shown.contains("deploy projects needs --allow-first-change <reason>"),
4003 "{shown}"
4004 );
4005 let hints: Vec<String> = [&downgrade, &first]
4007 .iter()
4008 .map(|e| e.hint().expect("hint"))
4009 .collect();
4010 assert!(hints[0].contains("--allow-downgrade"), "{}", hints[0]);
4011 assert!(hints[1].contains("--allow-first-change"), "{}", hints[1]);
4012 for hint in &hints {
4013 assert!(!hint.contains("ign "), "{hint}");
4014 assert!(!hint.contains("--allow-destructive"), "{hint}");
4015 }
4016
4017 let many: Vec<String> = (0..25).map(|i| format!("a/{i:02}")).collect();
4018 let blocked = CoreError::DeployBlocked { keys: many.clone() }.to_string();
4019 assert!(blocked.contains("and 15 more"), "{blocked}");
4020 assert!(!blocked.contains("a/10"), "{blocked}");
4021 let live = CoreError::DeployLiveChanged {
4022 run_id: "r".into(),
4023 paths: many[..10].to_vec(),
4024 count: 12,
4025 }
4026 .to_string();
4027 assert!(live.contains("and 2 more"), "{live}");
4028 }
4029}