Skip to main content

greentic_deployer/cli/
migrate_state.rs

1//! `gtc op env migrate-state <env_id>` — archive the legacy
2//! `<state_dir>/deploy/` artifact tree (A6 of `plans/next-gen-deployment.md`).
3//!
4//! `--apply` renames `<state_dir>/deploy/` to a hidden
5//! `.deploy-migrated-<ts>/` sentinel under the same parent. Contents are
6//! NOT copied into the new env-pack-bound layout — Phase B's path-flip
7//! handles future writes; the legacy artifacts (`plan.json`, `invoke.json`,
8//! adapter handoffs) have no downstream readers.
9//!
10//! `--apply` takes an `<state_dir>/.migrate-state.lock` flock for the
11//! scan → decide → rename → verify critical section. `apply::run` does not
12//! participate in this lock today; operators must quiesce live deploys
13//! before running `--apply`.
14
15use 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/// `--check` report.
36#[derive(Debug, Clone, Serialize)]
37pub struct MigrateStateReport {
38    pub env_id: String,
39    pub state_dir: String,
40    /// `true` iff no scanner reported a [`FindingSeverity::Blocking`] finding.
41    pub clean: bool,
42    /// Total `<provider>/<tenant>/<env>/<scope>` leaf directories observed.
43    pub leaf_count: usize,
44    pub findings: Vec<MigrationFinding>,
45}
46
47/// `--apply` outcome.
48#[derive(Debug, Clone, Serialize)]
49pub struct MigrateStateApplyOutcome {
50    pub env_id: String,
51    pub state_dir: String,
52    /// New path the legacy `deploy/` directory was renamed to. `None` if no
53    /// legacy tree existed (apply was a no-op).
54    pub legacy_dir_renamed_to: Option<String>,
55    /// Total count of `<provider>/<tenant>/<env>/<scope>` leaf directories
56    /// observed in the tree at scan time. Zero if the directory existed but
57    /// was empty (or did not exist).
58    pub scanned_paths_count: usize,
59}
60
61/// `op env migrate-state <env_id> --check`. Reports findings without touching
62/// state.
63pub 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
83/// `op env migrate-state <env_id> --apply`. Re-runs the check, refuses with
84/// [`OpError::Conflict`] if not clean, then renames `<state_dir>/deploy/` to
85/// a hidden `.deploy-migrated-<ts>` sentinel under the same parent.
86///
87/// Idempotent: if no legacy tree exists, returns a no-op outcome with
88/// `legacy_dir_renamed_to: None`.
89pub 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    // `resolve_state_dir` is a pure path computation (override or $HOME); it
100    // does not probe the env store, so it cannot leak env existence ahead of
101    // the authorization gate.
102    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    // `require_env_exists` lives inside the closure so a non-local target is
111    // denied + audited by the authorization gate before any store probe —
112    // otherwise an absent non-local env would return NotFound (unaudited) and
113    // a present one Unauthorized, leaking an existence oracle.
114    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    // Acquire the state-dir migration lock for the entire scan → decide →
122    // rename → verify critical section. Prevents two concurrent
123    // `migrate-state --apply` invocations from observing the same `clean`
124    // state and racing on the rename. Held until the function returns.
125    //
126    // KNOWN LIMITATION: `greentic-deployer::apply::run` writes to
127    // `state/deploy/<provider>/<tenant>/<env>/<scope>/...` without taking
128    // this lock today. A concurrent live deploy can still race with the
129    // rename. Cross-module participation in this lock requires resolving the
130    // state_dir-resolver divergence (this verb anchors at
131    // `$HOME/.greentic/state`; `apply::run` reads it from
132    // `GreenticConfig::paths.state_dir`) and is tracked as a separate
133    // hardening step. Operators should quiesce deploys before running
134    // `--apply`.
135    let lock_path = migration_lock_path(state_dir);
136    let _lock = EnvFlock::acquire(&lock_path).map_err(|source| OpError::Store(source.into()))?;
137
138    // Re-scan inside the lock so a concurrent writer that landed changes
139    // between the resolver and the lock acquisition cannot bypass the
140    // blocking-finding gate.
141    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    // `try_exists` so a permission-denied stat surfaces rather than
155    // collapsing into the no-op path.
156    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    // A successful atomic rename leaves zero residue; a non-empty post-scan
183    // implies a concurrent writer recreated the tree.
184    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
207/// `<state_dir>/.migrate-state.lock` — exclusive flock held during `--apply`'s
208/// scan → decide → rename → verify critical section. Parents are
209/// `create_dir_all`-d by [`EnvFlock::acquire`].
210fn 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
229/// Scans `<state_dir>/deploy/` and returns (findings, leaf_count).
230///
231/// Every IO error encountered while walking the tree is propagated as a
232/// [`FindingSeverity::Blocking`] finding rather than silently skipped, so an
233/// unreadable subtree cannot mask itself as `clean=true`.
234fn 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
296/// Returns `false` only if the top-level `read_dir` failed (no tuple info
297/// reachable); per-subtree errors are pushed as findings and the walk
298/// continues.
299fn 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
425/// Surfaces IO failures as blocking findings rather than silently treating
426/// the entry as "not a dir".
427fn 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
461/// Resolve `<state_dir>` either from the explicit override or by anchoring
462/// at `$HOME/.greentic/state/`, mirroring [`LocalFsStore::default_root`].
463fn 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
474/// Rename `<state_dir>/deploy/` to `<state_dir>/.deploy-migrated-<rfc3339-nanos>/`.
475fn 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        // deploy/ is a file, not a dir.
586        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        // no env seeded
600        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        // schema short-circuits before env-existence check, so no seed.
610        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        // Verify nothing was renamed.
676        assert!(state_dir.join("deploy").exists());
677    }
678
679    #[test]
680    fn apply_non_local_target_is_not_authz_denied() {
681        // Named envs are first-class on the local store, so a non-local target
682        // is no longer authz-denied: `apply` proceeds past the authorization
683        // gate to the store probe. (On a single-operator local store there is
684        // no cross-tenant existence-oracle concern; that protection lives on
685        // the remote RBAC path.) An absent env therefore surfaces as the
686        // store-probe error, NOT `Unauthorized`.
687        let dir = tempdir().unwrap();
688        let envs_root = dir.path().join("envs");
689        let store = LocalFsStore::new(&envs_root);
690        // `prod` is never seeded.
691        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        // The legacy tree is untouched (the absent env aborts before migration).
702        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        // Capture and restore HOME safely without `unsafe` env mutation.
727        // We can't actually set env vars under `#![forbid(unsafe_code)]`
728        // (Rust 2024 set_var/remove_var are unsafe). Instead, exercise the
729        // observable HOME-derived path indirectly: when the resolver reads
730        // the current process HOME, it should produce
731        // `<HOME>/.greentic/state`. If HOME is unset on this runner, accept
732        // the InvalidArgument error path instead.
733        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    /// An unreadable provider subtree must surface as a blocking finding
749    /// rather than being silently skipped. Unix-only because the test
750    /// relies on `chmod 000`.
751    #[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        // Two providers: one readable, one we'll chmod 000.
761        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        // Restore permissions BEFORE asserting so a failed assert still
775        // lets tempdir clean up.
776        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    /// Top-level `try_exists()` failures (e.g. unreadable parent) must
800    /// surface as blocking findings rather than collapsing to "not present".
801    #[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        // Create state_dir then strip exec/read from the *outer* parent
813        // so try_exists() on <state_dir>/deploy returns Err (cannot stat
814        // through the unreadable parent).
815        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        // Restore so tempdir can clean up.
823        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    /// Two concurrent `--apply` invocations on the same `state_dir`
839    /// serialize through `<state_dir>/.migrate-state.lock`. Only one
840    /// rename succeeds; the second observes the post-rename empty tree
841    /// and returns the idempotent no-op.
842    #[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        // Both invocations must return Ok (the lock serializes them; the
868        // second sees the post-rename empty tree and no-ops).
869        let o1 = r1.unwrap();
870        let o2 = r2.unwrap();
871
872        // Exactly one of them performed the rename; the other was the
873        // idempotent no-op.
874        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        // Lock file persists at the expected path.
885        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}