1use std::path::{Path, PathBuf};
16
17use chrono::{SecondsFormat, Utc};
18use greentic_deploy_spec::EnvId;
19use serde::Serialize;
20use serde_json::{Value, json};
21
22use super::migrate::{FindingSeverity, MigrationFinding};
23use super::{AuditCtx, OpError, OpFlags, OpOutcome, audit_and_record};
24use crate::environment::store::dirs_home;
25use crate::environment::{EnvFlock, EnvironmentStore, LocalFsStore};
26
27const NOUN: &str = "env";
28const OP: &str = "migrate-state";
29const MIGRATED_PREFIX: &str = ".deploy-migrated-";
30const FINDING_DEPLOY_TREE: &str = "legacy-deploy-tree";
31const FINDING_DEPLOY_UNREADABLE: &str = "legacy-deploy-unreadable";
32
33const NOTE_SUFFIX: &str = " note: this verb renames the legacy tree to a hidden `.deploy-migrated-<ts>/` sentinel — it does NOT move contents into the new env-pack-bound layout. `greentic-deployer::apply::run` still writes to this location until Phase B ships the path flip; re-running `--check` after a deploy will surface new findings.";
34
35#[derive(Debug, Clone, Serialize)]
37pub struct MigrateStateReport {
38 pub env_id: String,
39 pub state_dir: String,
40 pub clean: bool,
42 pub leaf_count: usize,
44 pub findings: Vec<MigrationFinding>,
45}
46
47#[derive(Debug, Clone, Serialize)]
49pub struct MigrateStateApplyOutcome {
50 pub env_id: String,
51 pub state_dir: String,
52 pub legacy_dir_renamed_to: Option<String>,
55 pub scanned_paths_count: usize,
59}
60
61pub fn check(
64 store: &LocalFsStore,
65 flags: &OpFlags,
66 target: &str,
67 state_dir_override: Option<&Path>,
68) -> Result<OpOutcome, OpError> {
69 if flags.schema_only {
70 return Ok(OpOutcome::new(NOUN, OP, schema()));
71 }
72 let env_id = parse_env_id(target)?;
73 require_env_exists(store, &env_id)?;
74 let state_dir = resolve_state_dir(state_dir_override)?;
75 let report = run_check(&env_id, &state_dir);
76 Ok(OpOutcome::new(
77 NOUN,
78 OP,
79 serde_json::to_value(report).expect("MigrateStateReport is json-safe"),
80 ))
81}
82
83pub fn apply(
90 store: &LocalFsStore,
91 flags: &OpFlags,
92 target: &str,
93 state_dir_override: Option<&Path>,
94) -> Result<OpOutcome, OpError> {
95 if flags.schema_only {
96 return Ok(OpOutcome::new(NOUN, OP, schema()));
97 }
98 let env_id = parse_env_id(target)?;
99 let state_dir = resolve_state_dir(state_dir_override)?;
103 let ctx = AuditCtx {
104 env_id: env_id.clone(),
105 noun: NOUN,
106 verb: OP,
107 target: json!({"state_dir": state_dir.display().to_string()}),
108 idempotency_key: None,
109 };
110 audit_and_record(store, ctx, |_committed| {
115 require_env_exists(store, &env_id)?;
116 apply_inner(&env_id, &state_dir)
117 })
118}
119
120fn apply_inner(env_id: &EnvId, state_dir: &Path) -> Result<(OpOutcome, super::AuditGens), OpError> {
121 let lock_path = migration_lock_path(state_dir);
136 let _lock = EnvFlock::acquire(&lock_path).map_err(|source| OpError::Store(source.into()))?;
137
138 let report = run_check(env_id, state_dir);
142 if !report.clean {
143 let blocking = report
144 .findings
145 .iter()
146 .filter(|f| f.severity == FindingSeverity::Blocking)
147 .count();
148 return Err(OpError::Conflict(format!(
149 "migrate-state refuses --apply: {blocking} blocking finding(s); run `--check` for the full list"
150 )));
151 }
152 let deploy_dir = state_dir.join("deploy");
153 let scanned_paths_count = report.leaf_count;
154 match deploy_dir.try_exists() {
157 Ok(false) => {
158 let outcome = MigrateStateApplyOutcome {
159 env_id: env_id.as_str().to_string(),
160 state_dir: state_dir.display().to_string(),
161 legacy_dir_renamed_to: None,
162 scanned_paths_count: 0,
163 };
164 return Ok((
165 OpOutcome::new(
166 NOUN,
167 OP,
168 serde_json::to_value(outcome).expect("apply outcome is json-safe"),
169 ),
170 super::AuditGens::NONE,
171 ));
172 }
173 Err(err) => {
174 return Err(OpError::Io {
175 path: deploy_dir,
176 source: err,
177 });
178 }
179 Ok(true) => {}
180 }
181 let renamed = rename_legacy_tree(state_dir, &deploy_dir)?;
182 let (post, _) = scan_legacy_deploy_dir(&deploy_dir);
185 if !post.is_empty() {
186 return Err(OpError::Conflict(format!(
187 "residue detected after rename — concurrent writer or partial permissions issue; {} finding(s) remain",
188 post.len()
189 )));
190 }
191 let outcome = MigrateStateApplyOutcome {
192 env_id: env_id.as_str().to_string(),
193 state_dir: state_dir.display().to_string(),
194 legacy_dir_renamed_to: Some(renamed.display().to_string()),
195 scanned_paths_count,
196 };
197 Ok((
198 OpOutcome::new(
199 NOUN,
200 OP,
201 serde_json::to_value(outcome).expect("apply outcome is json-safe"),
202 ),
203 super::AuditGens::NONE,
204 ))
205}
206
207fn migration_lock_path(state_dir: &Path) -> PathBuf {
211 state_dir.join(".migrate-state.lock")
212}
213
214fn run_check(env_id: &EnvId, state_dir: &Path) -> MigrateStateReport {
215 let deploy_dir = state_dir.join("deploy");
216 let (findings, leaf_count) = scan_legacy_deploy_dir(&deploy_dir);
217 let clean = !findings
218 .iter()
219 .any(|f| f.severity == FindingSeverity::Blocking);
220 MigrateStateReport {
221 env_id: env_id.as_str().to_string(),
222 state_dir: state_dir.display().to_string(),
223 clean,
224 leaf_count,
225 findings,
226 }
227}
228
229fn scan_legacy_deploy_dir(deploy_dir: &Path) -> (Vec<MigrationFinding>, usize) {
235 let mut findings: Vec<MigrationFinding> = Vec::new();
236 match deploy_dir.try_exists() {
237 Ok(false) => return (findings, 0),
238 Ok(true) => {}
239 Err(err) => {
240 findings.push(blocking(
241 deploy_dir,
242 format!("existence probe failed: {err}"),
243 ));
244 return (findings, 0);
245 }
246 }
247 let md = match std::fs::symlink_metadata(deploy_dir) {
248 Ok(md) => md,
249 Err(err) => {
250 findings.push(blocking(
251 deploy_dir,
252 format!("symlink_metadata failed: {err}"),
253 ));
254 return (findings, 0);
255 }
256 };
257 if !md.file_type().is_dir() {
258 findings.push(blocking(
259 deploy_dir,
260 format!(
261 "expected `{}` to be a directory; found a non-directory entry (file_type: {:?}). resolve before migrating",
262 deploy_dir.display(),
263 md.file_type()
264 ),
265 ));
266 return (findings, 0);
267 }
268 let mut tuples: Vec<String> = Vec::new();
269 let mut leaf_count: usize = 0;
270 if !walk_provider_layer(deploy_dir, &mut tuples, &mut leaf_count, &mut findings) {
271 return (findings, leaf_count);
272 }
273 let message = if tuples.is_empty() {
274 format!(
275 "legacy `{}` exists but is empty; eligible for `--apply` rename (hygiene).{NOTE_SUFFIX}",
276 deploy_dir.display()
277 )
278 } else {
279 format!(
280 "legacy `{}` contains {} `<provider>/<tenant>/<env>` tuple(s): [{}] across {} leaf scope dir(s). eligible for `--apply` rename.{NOTE_SUFFIX}",
281 deploy_dir.display(),
282 tuples.len(),
283 tuples.join(", "),
284 leaf_count
285 )
286 };
287 findings.push(MigrationFinding {
288 kind: FINDING_DEPLOY_TREE,
289 severity: FindingSeverity::Info,
290 location: deploy_dir.display().to_string(),
291 message,
292 });
293 (findings, leaf_count)
294}
295
296fn walk_provider_layer(
300 deploy_dir: &Path,
301 tuples: &mut Vec<String>,
302 leaf_count: &mut usize,
303 findings: &mut Vec<MigrationFinding>,
304) -> bool {
305 let providers = match std::fs::read_dir(deploy_dir) {
306 Ok(it) => it,
307 Err(err) => {
308 findings.push(blocking(deploy_dir, format!("read_dir failed: {err}")));
309 return false;
310 }
311 };
312 for entry_result in providers {
313 let provider_entry = match entry_result {
314 Ok(e) => e,
315 Err(err) => {
316 findings.push(blocking(
317 deploy_dir,
318 format!("read_dir entry failed: {err}"),
319 ));
320 continue;
321 }
322 };
323 let path = provider_entry.path();
324 if !is_dir_loud(&path, findings) {
325 continue;
326 }
327 let provider = provider_entry.file_name().to_string_lossy().into_owned();
328 walk_tenant_layer(&path, &provider, tuples, leaf_count, findings);
329 }
330 true
331}
332
333fn walk_tenant_layer(
334 provider_dir: &Path,
335 provider: &str,
336 tuples: &mut Vec<String>,
337 leaf_count: &mut usize,
338 findings: &mut Vec<MigrationFinding>,
339) {
340 let tenants = match std::fs::read_dir(provider_dir) {
341 Ok(it) => it,
342 Err(err) => {
343 findings.push(blocking(provider_dir, format!("read_dir failed: {err}")));
344 return;
345 }
346 };
347 for entry_result in tenants {
348 let tenant_entry = match entry_result {
349 Ok(e) => e,
350 Err(err) => {
351 findings.push(blocking(
352 provider_dir,
353 format!("read_dir entry failed: {err}"),
354 ));
355 continue;
356 }
357 };
358 let path = tenant_entry.path();
359 if !is_dir_loud(&path, findings) {
360 continue;
361 }
362 let tenant = tenant_entry.file_name().to_string_lossy().into_owned();
363 walk_env_layer(&path, provider, &tenant, tuples, leaf_count, findings);
364 }
365}
366
367fn walk_env_layer(
368 tenant_dir: &Path,
369 provider: &str,
370 tenant: &str,
371 tuples: &mut Vec<String>,
372 leaf_count: &mut usize,
373 findings: &mut Vec<MigrationFinding>,
374) {
375 let envs = match std::fs::read_dir(tenant_dir) {
376 Ok(it) => it,
377 Err(err) => {
378 findings.push(blocking(tenant_dir, format!("read_dir failed: {err}")));
379 return;
380 }
381 };
382 for entry_result in envs {
383 let env_entry = match entry_result {
384 Ok(e) => e,
385 Err(err) => {
386 findings.push(blocking(
387 tenant_dir,
388 format!("read_dir entry failed: {err}"),
389 ));
390 continue;
391 }
392 };
393 let path = env_entry.path();
394 if !is_dir_loud(&path, findings) {
395 continue;
396 }
397 let env = env_entry.file_name().to_string_lossy().into_owned();
398 tuples.push(format!("{provider}/{tenant}/{env}"));
399 count_scope_leafs(&path, leaf_count, findings);
400 }
401}
402
403fn count_scope_leafs(env_dir: &Path, leaf_count: &mut usize, findings: &mut Vec<MigrationFinding>) {
404 let scopes = match std::fs::read_dir(env_dir) {
405 Ok(it) => it,
406 Err(err) => {
407 findings.push(blocking(env_dir, format!("read_dir failed: {err}")));
408 return;
409 }
410 };
411 for entry_result in scopes {
412 let scope_entry = match entry_result {
413 Ok(e) => e,
414 Err(err) => {
415 findings.push(blocking(env_dir, format!("read_dir entry failed: {err}")));
416 continue;
417 }
418 };
419 if is_dir_loud(&scope_entry.path(), findings) {
420 *leaf_count += 1;
421 }
422 }
423}
424
425fn is_dir_loud(path: &Path, findings: &mut Vec<MigrationFinding>) -> bool {
428 match std::fs::symlink_metadata(path) {
429 Ok(md) => md.file_type().is_dir(),
430 Err(err) => {
431 findings.push(blocking(path, format!("symlink_metadata failed: {err}")));
432 false
433 }
434 }
435}
436
437fn blocking(location: &Path, message: String) -> MigrationFinding {
438 MigrationFinding {
439 kind: FINDING_DEPLOY_UNREADABLE,
440 severity: FindingSeverity::Blocking,
441 location: location.display().to_string(),
442 message,
443 }
444}
445
446fn parse_env_id(target: &str) -> Result<EnvId, OpError> {
447 EnvId::try_from(target)
448 .map_err(|e| OpError::InvalidArgument(format!("target env_id `{target}`: {e}")))
449}
450
451fn require_env_exists(store: &LocalFsStore, env_id: &EnvId) -> Result<(), OpError> {
452 if store.exists(env_id)? {
453 Ok(())
454 } else {
455 Err(OpError::NotFound(format!(
456 "target env `{env_id}` does not exist; bootstrap it (e.g. `gtc op env create {env_id}` or `gtc setup`) before running migrate-state"
457 )))
458 }
459}
460
461fn resolve_state_dir(override_path: Option<&Path>) -> Result<PathBuf, OpError> {
464 if let Some(p) = override_path {
465 return Ok(p.to_path_buf());
466 }
467 dirs_home()
468 .map(|h| h.join(".greentic").join("state"))
469 .ok_or_else(|| {
470 OpError::InvalidArgument("no --state-dir and HOME / USERPROFILE not set".to_string())
471 })
472}
473
474fn rename_legacy_tree(state_dir: &Path, deploy_dir: &Path) -> Result<PathBuf, OpError> {
476 let ts = Utc::now()
477 .to_rfc3339_opts(SecondsFormat::Nanos, true)
478 .replace([':', '.'], "-");
479 let dst_name = format!("{MIGRATED_PREFIX}{ts}");
480 let dst = state_dir.join(dst_name);
481 std::fs::rename(deploy_dir, &dst).map_err(|source| OpError::Io {
482 path: deploy_dir.to_path_buf(),
483 source,
484 })?;
485 Ok(dst)
486}
487
488fn schema() -> Value {
489 json!({
490 "$schema": "https://json-schema.org/draft/2020-12/schema",
491 "title": "MigrateStatePayload",
492 "description": "Inputs to `op env migrate-state`: positional `<target>` env id (must exist in EnvironmentStore), plus `--check` or `--apply`, plus optional `--state-dir` override (defaults to $HOME/.greentic/state).",
493 "type": "object",
494 "additionalProperties": false,
495 "properties": {
496 "target_env_id": {"type": "string", "description": "Env id whose existence gates the migration; the entire <state_dir>/deploy tree is renamed regardless."},
497 "mode": {"type": "string", "enum": ["check", "apply"]},
498 "state_dir": {"type": "string", "description": "Optional override for the legacy state-dir root. Defaults to $HOME/.greentic/state."}
499 },
500 "required": ["target_env_id", "mode"]
501 })
502}
503
504#[cfg(test)]
505mod tests {
506 use super::*;
507 use crate::cli::tests_common::make_env;
508 use tempfile::tempdir;
509
510 fn seed_local_env(store: &LocalFsStore) {
511 store.save(&make_env("local")).expect("seed local env");
512 }
513
514 fn write_file(path: &Path, contents: &str) {
515 if let Some(parent) = path.parent() {
516 std::fs::create_dir_all(parent).expect("create parent");
517 }
518 std::fs::write(path, contents).expect("write file");
519 }
520
521 #[test]
522 fn check_clean_when_no_deploy_dir() {
523 let dir = tempdir().unwrap();
524 let store = LocalFsStore::new(dir.path().join("envs"));
525 seed_local_env(&store);
526 let state_dir = dir.path().join("state");
527 std::fs::create_dir_all(&state_dir).unwrap();
528 let outcome = check(&store, &OpFlags::default(), "local", Some(&state_dir)).unwrap();
529 assert_eq!(outcome.op, OP);
530 assert_eq!(outcome.noun, NOUN);
531 assert_eq!(outcome.result["clean"], true);
532 assert_eq!(outcome.result["env_id"], "local");
533 assert_eq!(outcome.result["findings"].as_array().unwrap().len(), 0);
534 }
535
536 #[test]
537 fn check_reports_populated_tree() {
538 let dir = tempdir().unwrap();
539 let store = LocalFsStore::new(dir.path().join("envs"));
540 seed_local_env(&store);
541 let state_dir = dir.path().join("state");
542 write_file(
543 &state_dir.join("deploy/aws/acme/prod/scope-xyz/plan.json"),
544 "{}",
545 );
546 write_file(
547 &state_dir.join("deploy/aws/acme/prod/scope-abc/invoke.json"),
548 "{}",
549 );
550 let outcome = check(&store, &OpFlags::default(), "local", Some(&state_dir)).unwrap();
551 assert_eq!(outcome.result["clean"], true);
552 assert_eq!(outcome.result["leaf_count"], 2);
553 let findings = outcome.result["findings"].as_array().unwrap();
554 assert_eq!(findings.len(), 1);
555 assert_eq!(findings[0]["kind"], "legacy-deploy-tree");
556 assert_eq!(findings[0]["severity"], "info");
557 let msg = findings[0]["message"].as_str().unwrap();
558 assert!(msg.contains("aws/acme/prod"), "got: {msg}");
559 assert!(msg.contains("across 2 leaf scope"), "got: {msg}");
560 }
561
562 #[test]
563 fn check_reports_empty_deploy_dir() {
564 let dir = tempdir().unwrap();
565 let store = LocalFsStore::new(dir.path().join("envs"));
566 seed_local_env(&store);
567 let state_dir = dir.path().join("state");
568 std::fs::create_dir_all(state_dir.join("deploy")).unwrap();
569 let outcome = check(&store, &OpFlags::default(), "local", Some(&state_dir)).unwrap();
570 assert_eq!(outcome.result["clean"], true);
571 let findings = outcome.result["findings"].as_array().unwrap();
572 assert_eq!(findings.len(), 1);
573 assert_eq!(findings[0]["kind"], "legacy-deploy-tree");
574 let msg = findings[0]["message"].as_str().unwrap();
575 assert!(msg.contains("exists but is empty"), "got: {msg}");
576 }
577
578 #[test]
579 fn check_blocks_on_unreadable_deploy() {
580 let dir = tempdir().unwrap();
581 let store = LocalFsStore::new(dir.path().join("envs"));
582 seed_local_env(&store);
583 let state_dir = dir.path().join("state");
584 std::fs::create_dir_all(&state_dir).unwrap();
585 std::fs::write(state_dir.join("deploy"), "not a dir").unwrap();
587 let outcome = check(&store, &OpFlags::default(), "local", Some(&state_dir)).unwrap();
588 assert_eq!(outcome.result["clean"], false);
589 let findings = outcome.result["findings"].as_array().unwrap();
590 assert_eq!(findings.len(), 1);
591 assert_eq!(findings[0]["kind"], "legacy-deploy-unreadable");
592 assert_eq!(findings[0]["severity"], "blocking");
593 }
594
595 #[test]
596 fn check_requires_env_exists() {
597 let dir = tempdir().unwrap();
598 let store = LocalFsStore::new(dir.path().join("envs"));
599 let state_dir = dir.path().join("state");
601 let err = check(&store, &OpFlags::default(), "local", Some(&state_dir)).unwrap_err();
602 assert!(matches!(err, OpError::NotFound(_)), "got {err:?}");
603 }
604
605 #[test]
606 fn check_schema_only_returns_schema() {
607 let dir = tempdir().unwrap();
608 let store = LocalFsStore::new(dir.path().join("envs"));
609 let flags = OpFlags {
611 schema_only: true,
612 answers: None,
613 };
614 let outcome = check(&store, &flags, "local", None).unwrap();
615 assert_eq!(outcome.result["title"], "MigrateStatePayload");
616 }
617
618 #[test]
619 fn apply_happy_path_renames_and_verifies() {
620 let dir = tempdir().unwrap();
621 let store = LocalFsStore::new(dir.path().join("envs"));
622 seed_local_env(&store);
623 let state_dir = dir.path().join("state");
624 write_file(
625 &state_dir.join("deploy/aws/acme/prod/scope-xyz/plan.json"),
626 "{}",
627 );
628 let outcome = apply(&store, &OpFlags::default(), "local", Some(&state_dir)).unwrap();
629 let renamed = outcome.result["legacy_dir_renamed_to"].as_str().unwrap();
630 assert!(renamed.contains(".deploy-migrated-"), "got: {renamed}");
631 assert!(Path::new(renamed).exists());
632 assert!(!state_dir.join("deploy").exists());
633 assert_eq!(outcome.result["scanned_paths_count"], 1);
634 }
635
636 #[test]
637 fn apply_idempotent_after_success() {
638 let dir = tempdir().unwrap();
639 let store = LocalFsStore::new(dir.path().join("envs"));
640 seed_local_env(&store);
641 let state_dir = dir.path().join("state");
642 write_file(
643 &state_dir.join("deploy/aws/acme/prod/scope-xyz/plan.json"),
644 "{}",
645 );
646 let _ = apply(&store, &OpFlags::default(), "local", Some(&state_dir)).unwrap();
647 let outcome = apply(&store, &OpFlags::default(), "local", Some(&state_dir)).unwrap();
648 assert_eq!(outcome.result["legacy_dir_renamed_to"], Value::Null);
649 assert_eq!(outcome.result["scanned_paths_count"], 0);
650 }
651
652 #[test]
653 fn apply_refuses_on_blocking_finding() {
654 let dir = tempdir().unwrap();
655 let store = LocalFsStore::new(dir.path().join("envs"));
656 seed_local_env(&store);
657 let state_dir = dir.path().join("state");
658 std::fs::create_dir_all(&state_dir).unwrap();
659 std::fs::write(state_dir.join("deploy"), "not a dir").unwrap();
660 let err = apply(&store, &OpFlags::default(), "local", Some(&state_dir)).unwrap_err();
661 assert!(matches!(err, OpError::Conflict(_)), "got {err:?}");
662 }
663
664 #[test]
665 fn apply_requires_env_exists() {
666 let dir = tempdir().unwrap();
667 let store = LocalFsStore::new(dir.path().join("envs"));
668 let state_dir = dir.path().join("state");
669 write_file(
670 &state_dir.join("deploy/aws/acme/prod/scope-xyz/plan.json"),
671 "{}",
672 );
673 let err = apply(&store, &OpFlags::default(), "local", Some(&state_dir)).unwrap_err();
674 assert!(matches!(err, OpError::NotFound(_)), "got {err:?}");
675 assert!(state_dir.join("deploy").exists());
677 }
678
679 #[test]
680 fn apply_non_local_target_is_not_authz_denied() {
681 let dir = tempdir().unwrap();
688 let envs_root = dir.path().join("envs");
689 let store = LocalFsStore::new(&envs_root);
690 let state_dir = dir.path().join("state");
692 write_file(
693 &state_dir.join("deploy/aws/acme/prod/scope-xyz/plan.json"),
694 "{}",
695 );
696 let err = apply(&store, &OpFlags::default(), "prod", Some(&state_dir)).unwrap_err();
697 assert!(
698 !matches!(err, OpError::Unauthorized { .. }),
699 "named env target must no longer be authz-denied; got {err:?}"
700 );
701 assert!(state_dir.join("deploy").exists());
703 }
704
705 #[test]
706 fn apply_no_op_when_deploy_dir_missing() {
707 let dir = tempdir().unwrap();
708 let store = LocalFsStore::new(dir.path().join("envs"));
709 seed_local_env(&store);
710 let state_dir = dir.path().join("state");
711 std::fs::create_dir_all(&state_dir).unwrap();
712 let outcome = apply(&store, &OpFlags::default(), "local", Some(&state_dir)).unwrap();
713 assert_eq!(outcome.result["legacy_dir_renamed_to"], Value::Null);
714 assert_eq!(outcome.result["scanned_paths_count"], 0);
715 }
716
717 #[test]
718 fn resolve_state_dir_uses_override() {
719 let custom = PathBuf::from("/tmp/custom-state-a6");
720 let resolved = resolve_state_dir(Some(&custom)).unwrap();
721 assert_eq!(resolved, custom);
722 }
723
724 #[test]
725 fn resolve_state_dir_falls_back_to_home() {
726 let resolved = resolve_state_dir(None);
734 match (std::env::var_os("HOME"), resolved) {
735 (Some(home), Ok(p)) => {
736 let expected = PathBuf::from(home).join(".greentic").join("state");
737 assert_eq!(p, expected);
738 }
739 (None, Err(OpError::InvalidArgument(msg))) => {
740 assert!(msg.contains("HOME"));
741 }
742 (have_home, result) => {
743 panic!("unexpected combination: HOME={have_home:?}, resolved={result:?}")
744 }
745 }
746 }
747
748 #[cfg(unix)]
752 #[test]
753 fn check_blocks_on_unreadable_provider_subtree() {
754 use std::os::unix::fs::PermissionsExt;
755
756 let dir = tempdir().unwrap();
757 let store = LocalFsStore::new(dir.path().join("envs"));
758 seed_local_env(&store);
759 let state_dir = dir.path().join("state");
760 write_file(
762 &state_dir.join("deploy/aws/acme/prod/scope-1/plan.json"),
763 "{}",
764 );
765 let walled = state_dir.join("deploy/gcp");
766 std::fs::create_dir_all(walled.join("acme/prod/scope-2")).unwrap();
767 std::fs::write(walled.join("acme/prod/scope-2/plan.json"), "{}").unwrap();
768 let mut perms = std::fs::metadata(&walled).unwrap().permissions();
769 perms.set_mode(0o000);
770 std::fs::set_permissions(&walled, perms).unwrap();
771
772 let outcome = check(&store, &OpFlags::default(), "local", Some(&state_dir));
773
774 let mut restore = std::fs::metadata(&walled).unwrap().permissions();
777 restore.set_mode(0o755);
778 std::fs::set_permissions(&walled, restore).unwrap();
779
780 let outcome = outcome.unwrap();
781 assert_eq!(outcome.result["clean"], false);
782 let findings = outcome.result["findings"].as_array().unwrap();
783 let blocking: Vec<&serde_json::Value> = findings
784 .iter()
785 .filter(|f| f["severity"] == "blocking")
786 .collect();
787 assert!(
788 !blocking.is_empty(),
789 "expected at least one blocking finding for the unreadable subtree, got: {findings:?}"
790 );
791 assert!(
792 blocking
793 .iter()
794 .any(|f| f["kind"] == "legacy-deploy-unreadable"),
795 "expected legacy-deploy-unreadable kind, got: {blocking:?}"
796 );
797 }
798
799 #[cfg(unix)]
802 #[test]
803 fn check_blocks_on_top_level_probe_io_error() {
804 use std::os::unix::fs::PermissionsExt;
805
806 let dir = tempdir().unwrap();
807 let store = LocalFsStore::new(dir.path().join("envs"));
808 seed_local_env(&store);
809 let outer = dir.path().join("outer");
810 std::fs::create_dir_all(&outer).unwrap();
811 let state_dir = outer.join("state");
812 std::fs::create_dir_all(&state_dir).unwrap();
816 let mut perms = std::fs::metadata(&outer).unwrap().permissions();
817 perms.set_mode(0o000);
818 std::fs::set_permissions(&outer, perms).unwrap();
819
820 let outcome = check(&store, &OpFlags::default(), "local", Some(&state_dir));
821
822 let mut restore = std::fs::metadata(&outer).unwrap().permissions();
824 restore.set_mode(0o755);
825 std::fs::set_permissions(&outer, restore).unwrap();
826
827 let outcome = outcome.unwrap();
828 assert_eq!(outcome.result["clean"], false);
829 let findings = outcome.result["findings"].as_array().unwrap();
830 assert!(
831 findings
832 .iter()
833 .any(|f| f["kind"] == "legacy-deploy-unreadable" && f["severity"] == "blocking"),
834 "expected blocking probe finding, got: {findings:?}"
835 );
836 }
837
838 #[test]
843 fn apply_serializes_under_state_dir_lock() {
844 let dir = tempdir().unwrap();
845 let store = LocalFsStore::new(dir.path().join("envs"));
846 seed_local_env(&store);
847 let state_dir = dir.path().join("state");
848 write_file(
849 &state_dir.join("deploy/aws/acme/prod/scope-1/plan.json"),
850 "{}",
851 );
852
853 let store_a = store.clone();
854 let store_b = store.clone();
855 let state_a = state_dir.clone();
856 let state_b = state_dir.clone();
857
858 let h1 = std::thread::spawn(move || {
859 apply(&store_a, &OpFlags::default(), "local", Some(&state_a))
860 });
861 let h2 = std::thread::spawn(move || {
862 apply(&store_b, &OpFlags::default(), "local", Some(&state_b))
863 });
864 let r1 = h1.join().expect("thread 1");
865 let r2 = h2.join().expect("thread 2");
866
867 let o1 = r1.unwrap();
870 let o2 = r2.unwrap();
871
872 let renamed = [
875 o1.result["legacy_dir_renamed_to"].clone(),
876 o2.result["legacy_dir_renamed_to"].clone(),
877 ];
878 let non_null = renamed.iter().filter(|v| !v.is_null()).count();
879 assert_eq!(
880 non_null, 1,
881 "exactly one apply should have renamed; got {renamed:?}"
882 );
883
884 assert!(
886 state_dir.join(".migrate-state.lock").exists(),
887 "migration lock file should remain after apply"
888 );
889 }
890
891 #[test]
892 fn migration_lock_path_lives_under_state_dir() {
893 let p = migration_lock_path(Path::new("/tmp/state-a6"));
894 assert_eq!(p, PathBuf::from("/tmp/state-a6/.migrate-state.lock"));
895 }
896}