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