Skip to main content

greentic_deployer/cli/
migrate.rs

1//! `gtc op env migrate-dev <target>` (`A4b` of `plans/next-gen-deployment.md`).
2//!
3//! Preflight-gated, one-shot migration of a legacy `dev` environment to the
4//! [A4 bootstrap] target (typically `local`). Two verbs:
5//!
6//! - `--check` — runs every registered [`MigrationScanner`] and emits a
7//!   [`MigrateDevReport`]. `clean` is `true` iff no scanner reported a
8//!   [`FindingSeverity::Blocking`] finding.
9//! - `--apply` — first re-runs the check, refuses with [`OpError::Conflict`]
10//!   if not clean, then merges the source env into the target under the
11//!   target's transact lock and renames the source directory to a
12//!   `.dev-migrated-<ts>` sentinel that's hidden from
13//!   [`EnvironmentStore::list`] (the leading dot is rejected by `EnvId`).
14//!
15//! Scope is intentionally narrow:
16//!
17//! - Refuses migration of any source env that carries bundles, revisions,
18//!   traffic splits, or a `credentials_ref` — those require deep ref rewrites
19//!   (env-scoped `SecretRef`s, `BundleDeployment.env_id`, etc.) which A4b's
20//!   "presence guarantee" semantic does not promise.
21//! - On a "simple" source (only `host_config` + optional pack bindings), the
22//!   merge preserves user-customized bindings on the target: defaults missing
23//!   from `target` get filled from `source`; slots already bound on `target`
24//!   stay untouched (the A4 #206 lesson — bootstrap is presence, not
25//!   replacement).
26//! - Audit-log scanning (A7) and secrets-store scanning (A4b PR4) ship as
27//!   `NotYetImplemented` placeholders so the report surface is forward-stable.
28//!
29//! [A4 bootstrap]: crate::cli::bootstrap
30
31use chrono::{SecondsFormat, Utc};
32use greentic_deploy_spec::{EnvId, Environment};
33use serde::{Deserialize, Serialize};
34use serde_json::{Value, json};
35
36use super::{
37    AuditCtx, OpError, OpFlags, OpOutcome, audit_and_record, map_store_err_preserving_noun,
38};
39use crate::defaults::LOCAL_ENV_ID;
40use crate::environment::{EnvironmentStore, LocalFsStore, MigrateMergePayload, MigrateSeedPayload};
41
42const NOUN: &str = "env";
43const OP: &str = "migrate-dev";
44
45/// Legacy env-id this migration moves *from*. The target env-id is provided
46/// by the caller; defaults to [`LOCAL_ENV_ID`] in the binary clap layer.
47pub const LEGACY_ENV_ID: &str = "dev";
48
49/// Marker prefix prepended to the source directory on `--apply`. Starts with
50/// `.` so the directory is silently skipped by [`EnvironmentStore::list`]
51/// (the `EnvId` validator rejects ids beginning with `.`).
52const MIGRATED_PREFIX: &str = ".dev-migrated-";
53
54/// Severity classification for a single finding.
55#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
56#[serde(rename_all = "kebab-case")]
57pub enum FindingSeverity {
58    /// Informational — `--apply` can proceed.
59    Info,
60    /// Blocks `--apply` until the user manually resolves the issue. The
61    /// per-finding `message` carries the recommended remediation.
62    Blocking,
63}
64
65/// One observation from a scanner run.
66#[derive(Debug, Clone, Serialize, Deserialize)]
67pub struct MigrationFinding {
68    /// Short, scanner-defined kind for machine consumers (e.g.
69    /// `"legacy-env-dir"`, `"runtime-env-var"`).
70    pub kind: &'static str,
71    pub severity: FindingSeverity,
72    /// Path / variable name / source the finding refers to. Free-form
73    /// human-readable string.
74    pub location: String,
75    /// Operator-facing explanation of the finding and (for blocking findings)
76    /// what to do about it.
77    pub message: String,
78}
79
80/// Report returned by `migrate-dev --check`.
81#[derive(Debug, Clone, Serialize)]
82pub struct MigrateDevReport {
83    pub from_env: String,
84    pub to_env: String,
85    /// `true` iff no scanner reported a [`FindingSeverity::Blocking`] finding.
86    pub clean: bool,
87    pub findings: Vec<MigrationFinding>,
88}
89
90/// Outcome of `migrate-dev --apply`. Only emitted when the check was clean.
91#[derive(Debug, Clone, Serialize)]
92pub struct MigrateDevApplyOutcome {
93    pub from_env: String,
94    pub to_env: String,
95    /// Slots that were added to the target during the merge. Empty if the
96    /// target already had everything the source contributed.
97    pub merged_slots: Vec<String>,
98    /// Extension bindings (`(path, instance_id)` keys) added to the target
99    /// during the merge. Empty if the source had none or the target already
100    /// carried them all.
101    pub merged_extensions: Vec<String>,
102    /// New path the legacy source directory was renamed to (so the user can
103    /// verify and remove it manually). `None` if no source directory existed
104    /// (apply was a no-op).
105    pub legacy_dir_renamed_to: Option<String>,
106}
107
108/// Read-only context handed to every scanner.
109pub struct ScanContext<'a> {
110    pub store: &'a LocalFsStore,
111    pub from_env: &'a EnvId,
112    pub to_env: &'a EnvId,
113}
114
115/// Pluggable scanner — each implementation reports zero or more
116/// [`MigrationFinding`]s for one slice of legacy `dev`-named state.
117pub trait MigrationScanner {
118    /// Stable, kebab-case identifier; surfaced in finding `kind` prefixes
119    /// where helpful.
120    fn name(&self) -> &'static str;
121    fn scan(&self, ctx: &ScanContext<'_>) -> Result<Vec<MigrationFinding>, OpError>;
122}
123
124/// Bundle the set of scanners that ship with A4b. Future PRs add to this
125/// vector; consumers that want a different set can call the individual
126/// scanners directly.
127pub fn default_scanners() -> Vec<Box<dyn MigrationScanner>> {
128    vec![
129        Box::new(LocalFsStoreScanner),
130        Box::new(RuntimeConfigScanner),
131        Box::new(SecretsStoreScanner),
132        Box::new(AuditLogScanner),
133        Box::new(BundleHintScanner),
134    ]
135}
136
137/// Inspects the configured [`LocalFsStore`] root for a `<from_env>` directory.
138/// Classifies any source env as either a "simple" candidate (only
139/// `host_config` + optional pack bindings) or "complex" (carries bundles,
140/// revisions, traffic splits, or a `credentials_ref`).
141pub struct LocalFsStoreScanner;
142
143impl MigrationScanner for LocalFsStoreScanner {
144    fn name(&self) -> &'static str {
145        "local-fs-store"
146    }
147
148    fn scan(&self, ctx: &ScanContext<'_>) -> Result<Vec<MigrationFinding>, OpError> {
149        let mut findings = Vec::new();
150        if !ctx.store.exists(ctx.from_env)? {
151            return Ok(findings);
152        }
153        let env_root = ctx.store.root().join(ctx.from_env.as_str());
154        match ctx.store.load(ctx.from_env) {
155            Ok(env) => {
156                if let Some(complex_reason) = classify_source(&env) {
157                    findings.push(MigrationFinding {
158                        kind: "legacy-env-complex",
159                        severity: FindingSeverity::Blocking,
160                        location: env_root.display().to_string(),
161                        message: format!(
162                            "source env `{}` contains {} — manual migration required; A4b's `--apply` only moves a simple env (host_config + pack bindings)",
163                            ctx.from_env, complex_reason
164                        ),
165                    });
166                } else {
167                    findings.push(MigrationFinding {
168                        kind: "legacy-env-simple",
169                        severity: FindingSeverity::Info,
170                        location: env_root.display().to_string(),
171                        message: format!(
172                            "source env `{}` is present and eligible for `--apply` migration to `{}`",
173                            ctx.from_env, ctx.to_env
174                        ),
175                    });
176                }
177            }
178            Err(err) => {
179                findings.push(MigrationFinding {
180                    kind: "legacy-env-unreadable",
181                    severity: FindingSeverity::Blocking,
182                    location: env_root.display().to_string(),
183                    message: format!(
184                        "source env `{}` exists on disk but failed to load ({err}); resolve before migrating",
185                        ctx.from_env
186                    ),
187                });
188            }
189        }
190        Ok(findings)
191    }
192}
193
194/// Reports a finding when `GREENTIC_ENV` is set to the legacy value in the
195/// process environment running the check. The migration tool can't *fix*
196/// this from inside the process (the var is set in the caller's shell), but
197/// flagging it informs the operator that their shell will still resolve
198/// `dev` after the on-disk migration.
199pub struct RuntimeConfigScanner;
200
201impl MigrationScanner for RuntimeConfigScanner {
202    fn name(&self) -> &'static str {
203        "runtime-config"
204    }
205
206    fn scan(&self, ctx: &ScanContext<'_>) -> Result<Vec<MigrationFinding>, OpError> {
207        let value = std::env::var("GREENTIC_ENV").ok();
208        Ok(classify_runtime_env_var(value.as_deref(), ctx.from_env)
209            .into_iter()
210            .collect())
211    }
212}
213
214/// Pure classification helper for [`RuntimeConfigScanner`]. Returns
215/// `Some(finding)` iff `value` equals the legacy env id. Extracted from the
216/// scanner so tests can exercise the logic without mutating the process
217/// environment — the crate is `#![forbid(unsafe_code)]` and Rust 2024
218/// requires `unsafe` to set/remove env vars.
219pub fn classify_runtime_env_var(value: Option<&str>, from: &EnvId) -> Option<MigrationFinding> {
220    let value = value?;
221    if value != from.as_str() {
222        return None;
223    }
224    Some(MigrationFinding {
225        kind: "runtime-env-var",
226        severity: FindingSeverity::Info,
227        location: "$GREENTIC_ENV".to_string(),
228        message: format!(
229            "shell env var `GREENTIC_ENV` is `{value}` — the dev→local compat alias (A4b PR2) will keep this working with a once-per-process warning until you unset or update it"
230        ),
231    })
232}
233
234/// Placeholder for the audit-log scanner. A7 adds an append-only audit log
235/// keyed by env; until then we report a single informational finding so
236/// operators don't expect this surface to silently catch missed references.
237pub struct AuditLogScanner;
238
239impl MigrationScanner for AuditLogScanner {
240    fn name(&self) -> &'static str {
241        "audit-log"
242    }
243
244    fn scan(&self, _ctx: &ScanContext<'_>) -> Result<Vec<MigrationFinding>, OpError> {
245        Ok(vec![MigrationFinding {
246            kind: "audit-log-scanner-deferred",
247            severity: FindingSeverity::Info,
248            location: "<audit-log>".to_string(),
249            message: "audit-log scanning ships with A7; no audit log exists today".to_string(),
250        }])
251    }
252}
253
254/// Placeholder for the secrets-store scanner. PR4 of A4b adds the real
255/// helper in `greentic-secrets-broker`; until then, no production
256/// code-paths enumerate `dev`-scoped keys, so this scanner reports a
257/// no-op informational finding.
258pub struct SecretsStoreScanner;
259
260impl MigrationScanner for SecretsStoreScanner {
261    fn name(&self) -> &'static str {
262        "secrets-store"
263    }
264
265    fn scan(&self, _ctx: &ScanContext<'_>) -> Result<Vec<MigrationFinding>, OpError> {
266        Ok(vec![MigrationFinding {
267            kind: "secrets-store-scanner-deferred",
268            severity: FindingSeverity::Info,
269            location: "<secrets-broker>".to_string(),
270            message: "secrets-store enumeration ships with A4b PR4 (greentic-secrets-broker)"
271                .to_string(),
272        }])
273    }
274}
275
276/// Permanent no-op scanner — bundles carry no embedded env hint in any
277/// shipped code (per the A4b inventory), so any future drift would surface
278/// as a new scanner rather than a finding here.
279pub struct BundleHintScanner;
280
281impl MigrationScanner for BundleHintScanner {
282    fn name(&self) -> &'static str {
283        "bundle-hint"
284    }
285
286    fn scan(&self, _ctx: &ScanContext<'_>) -> Result<Vec<MigrationFinding>, OpError> {
287        Ok(Vec::new())
288    }
289}
290
291/// Returns `Some(reason)` if the env carries any state A4b's `--apply` is
292/// unwilling to rewrite. Returns `None` for envs that hold only
293/// `host_config` + optional pack bindings.
294fn classify_source(env: &Environment) -> Option<&'static str> {
295    if env.credentials_ref.is_some() {
296        return Some("a `credentials_ref` pointing at env-scoped secrets");
297    }
298    if !env.bundles.is_empty() {
299        return Some("one or more `BundleDeployment`s");
300    }
301    if !env.revisions.is_empty() {
302        return Some("one or more `Revision`s");
303    }
304    if !env.traffic_splits.is_empty() {
305        return Some("one or more `TrafficSplit`s");
306    }
307    None
308}
309
310/// Run every scanner and produce the report.
311pub fn run_check(
312    store: &LocalFsStore,
313    from: &EnvId,
314    to: &EnvId,
315) -> Result<MigrateDevReport, OpError> {
316    let ctx = ScanContext {
317        store,
318        from_env: from,
319        to_env: to,
320    };
321    let mut findings = Vec::new();
322    for scanner in default_scanners() {
323        findings.extend(scanner.scan(&ctx)?);
324    }
325    let clean = !findings
326        .iter()
327        .any(|f| f.severity == FindingSeverity::Blocking);
328    Ok(MigrateDevReport {
329        from_env: from.as_str().to_string(),
330        to_env: to.as_str().to_string(),
331        clean,
332        findings,
333    })
334}
335
336/// `op env migrate-dev <target> --check`. Reports findings without touching
337/// state.
338pub fn check(store: &LocalFsStore, flags: &OpFlags, target: &str) -> Result<OpOutcome, OpError> {
339    if flags.schema_only {
340        return Ok(OpOutcome::new(NOUN, OP, schema()));
341    }
342    let (from, to) = resolve_endpoints(target)?;
343    let report = run_check(store, &from, &to)?;
344    Ok(OpOutcome::new(
345        NOUN,
346        OP,
347        serde_json::to_value(report).expect("MigrateDevReport is json-safe"),
348    ))
349}
350
351/// `op env migrate-dev <target> --apply`. Re-runs the check, refuses with
352/// [`OpError::Conflict`] if not clean, then merges the source env into the
353/// target under the target's transact lock and renames the source directory
354/// to a hidden `.dev-migrated-<ts>` sentinel.
355///
356/// Idempotent: if no source env exists, returns
357/// [`MigrateDevApplyOutcome`] with empty `merged_slots` and
358/// `legacy_dir_renamed_to: None`.
359pub fn apply(store: &LocalFsStore, flags: &OpFlags, target: &str) -> Result<OpOutcome, OpError> {
360    if flags.schema_only {
361        return Ok(OpOutcome::new(NOUN, OP, schema()));
362    }
363    let (from, to) = resolve_endpoints(target)?;
364    let ctx = AuditCtx {
365        env_id: to.clone(),
366        noun: NOUN,
367        verb: OP,
368        target: json!({
369            "from_env": from.as_str(),
370            "to_env": to.as_str(),
371        }),
372        idempotency_key: None,
373    };
374    audit_and_record(store, ctx, |_committed| {
375        let report = run_check(store, &from, &to)?;
376        if !report.clean {
377            return Err(OpError::Conflict(format!(
378                "migrate-dev refuses --apply: {} blocking finding(s); run `--check` for the full list",
379                report
380                    .findings
381                    .iter()
382                    .filter(|f| f.severity == FindingSeverity::Blocking)
383                    .count()
384            )));
385        }
386        if !store.exists(&from)? {
387            // Idempotent no-op: nothing to migrate.
388            let outcome = MigrateDevApplyOutcome {
389                from_env: from.as_str().to_string(),
390                to_env: to.as_str().to_string(),
391                merged_slots: Vec::new(),
392                merged_extensions: Vec::new(),
393                legacy_dir_renamed_to: None,
394            };
395            return Ok((
396                OpOutcome::new(
397                    NOUN,
398                    OP,
399                    serde_json::to_value(outcome).expect("apply outcome is json-safe"),
400                ),
401                super::AuditGens::NONE,
402            ));
403        }
404        let source = store.load(&from)?;
405        if classify_source(&source).is_some() {
406            // run_check should have flagged this, but guard against a scanner
407            // bypass.
408            return Err(OpError::Conflict(
409                "source env is not eligible for simple migration (see `--check`)".to_string(),
410            ));
411        }
412        // Destructure once: `source` is an owned `Environment` not read
413        // after this point, so the migrate payload moves its fields in
414        // rather than cloning each one.
415        let Environment {
416            packs,
417            extensions,
418            host_config,
419            revocation,
420            retention,
421            health,
422            ..
423        } = source;
424        let payload = MigrateMergePayload {
425            packs,
426            extensions,
427            seed_if_missing: Some(MigrateSeedPayload {
428                host_config,
429                revocation,
430                retention,
431                health,
432            }),
433        };
434        let (merged_slots, merged_extensions) = store
435            .migrate_merge_bindings(&to, payload)
436            .map_err(map_store_err_preserving_noun)?;
437        let renamed = rename_legacy_dir(store, &from)?;
438        let outcome = MigrateDevApplyOutcome {
439            from_env: from.as_str().to_string(),
440            to_env: to.as_str().to_string(),
441            merged_slots,
442            merged_extensions,
443            legacy_dir_renamed_to: Some(renamed.display().to_string()),
444        };
445        Ok((
446            OpOutcome::new(
447                NOUN,
448                OP,
449                serde_json::to_value(outcome).expect("apply outcome is json-safe"),
450            ),
451            super::AuditGens::NONE,
452        ))
453    })
454}
455
456fn resolve_endpoints(target: &str) -> Result<(EnvId, EnvId), OpError> {
457    let from = EnvId::try_from(LEGACY_ENV_ID)
458        .map_err(|e| OpError::InvalidArgument(format!("legacy env id `{LEGACY_ENV_ID}`: {e}")))?;
459    let to = EnvId::try_from(target)
460        .map_err(|e| OpError::InvalidArgument(format!("target env_id `{target}`: {e}")))?;
461    if to == from {
462        return Err(OpError::InvalidArgument(format!(
463            "migration target must differ from `{LEGACY_ENV_ID}`"
464        )));
465    }
466    Ok((from, to))
467}
468
469/// Rename `<root>/<from>/` to `<root>/.dev-migrated-<ts>/`. The leading dot
470/// guarantees [`EnvironmentStore::list`] silently skips the directory
471/// (`EnvId` rejects `.`-prefixed names) and lets the user verify state
472/// before manual cleanup.
473fn rename_legacy_dir(store: &LocalFsStore, from: &EnvId) -> Result<std::path::PathBuf, OpError> {
474    let src = store.root().join(from.as_str());
475    if !src.exists() {
476        return Ok(src);
477    }
478    let ts = Utc::now()
479        .to_rfc3339_opts(SecondsFormat::Nanos, true)
480        .replace([':', '.'], "-");
481    let dst_name = format!("{MIGRATED_PREFIX}{ts}");
482    let dst = store.root().join(dst_name);
483    std::fs::rename(&src, &dst).map_err(|source| OpError::Io { path: src, source })?;
484    Ok(dst)
485}
486
487fn schema() -> Value {
488    json!({
489        "$schema": "https://json-schema.org/draft/2020-12/schema",
490        "title": "MigrateDevPayload",
491        "description": "Inputs to `op env migrate-dev`: positional `<target>` env id, plus `--check` or `--apply`. There is no JSON payload to load from disk; use the CLI flags directly.",
492        "type": "object",
493        "additionalProperties": false,
494        "properties": {
495            "target_env_id": {"type": "string", "description": "Env id to migrate `dev` to — typically `local`."},
496            "mode": {"type": "string", "enum": ["check", "apply"]}
497        },
498        "required": ["target_env_id", "mode"],
499        "x-default-target": LOCAL_ENV_ID
500    })
501}
502
503#[cfg(test)]
504mod tests {
505    use super::*;
506    use crate::cli::tests_common::{
507        make_binding, make_bundle_deployment, make_env, make_revision, make_traffic_split,
508    };
509    use crate::defaults::{LOCAL_DEPLOYER_PACK, LOCAL_SECRETS_PACK, LOCAL_TELEMETRY_PACK};
510    use crate::environment::EnvironmentStore;
511    use greentic_deploy_spec::{CapabilitySlot, RevisionLifecycle};
512    use tempfile::tempdir;
513
514    #[test]
515    fn check_clean_when_store_empty() {
516        let dir = tempdir().unwrap();
517        let store = LocalFsStore::new(dir.path());
518        let outcome = check(&store, &OpFlags::default(), "local").unwrap();
519        assert_eq!(outcome.op, "migrate-dev");
520        assert_eq!(outcome.noun, "env");
521        assert_eq!(outcome.result["clean"], true);
522        assert_eq!(outcome.result["from_env"], "dev");
523        assert_eq!(outcome.result["to_env"], "local");
524    }
525
526    #[test]
527    fn check_flags_simple_dev_env_as_eligible() {
528        let dir = tempdir().unwrap();
529        let store = LocalFsStore::new(dir.path());
530        let mut env = make_env("dev");
531        env.packs
532            .push(make_binding(CapabilitySlot::Deployer, LOCAL_DEPLOYER_PACK));
533        store.save(&env).unwrap();
534        let outcome = check(&store, &OpFlags::default(), "local").unwrap();
535        assert_eq!(outcome.result["clean"], true);
536        let kinds: Vec<&str> = outcome.result["findings"]
537            .as_array()
538            .unwrap()
539            .iter()
540            .map(|f| f["kind"].as_str().unwrap())
541            .collect();
542        assert!(kinds.contains(&"legacy-env-simple"));
543    }
544
545    #[test]
546    fn check_blocks_dev_env_with_bundles() {
547        let dir = tempdir().unwrap();
548        let store = LocalFsStore::new(dir.path());
549        let mut env = make_env("dev");
550        let bundle = make_bundle_deployment("dev", "fast2flow");
551        env.bundles.push(bundle);
552        store.save(&env).unwrap();
553        let outcome = check(&store, &OpFlags::default(), "local").unwrap();
554        assert_eq!(outcome.result["clean"], false);
555        let blocking_findings: Vec<&serde_json::Value> = outcome.result["findings"]
556            .as_array()
557            .unwrap()
558            .iter()
559            .filter(|f| f["severity"] == "blocking")
560            .collect();
561        assert_eq!(blocking_findings.len(), 1);
562        assert_eq!(blocking_findings[0]["kind"], "legacy-env-complex");
563        let msg = blocking_findings[0]["message"].as_str().unwrap();
564        assert!(msg.contains("BundleDeployment"), "got: {msg}");
565    }
566
567    #[test]
568    fn check_blocks_dev_env_with_revisions() {
569        let dir = tempdir().unwrap();
570        let store = LocalFsStore::new(dir.path());
571        let mut env = make_env("dev");
572        // Build a deployment + matching revision so spec validate() passes
573        // before we save.
574        let bundle = make_bundle_deployment("dev", "fast2flow");
575        let rev = make_revision(
576            "dev",
577            "fast2flow",
578            &bundle.deployment_id,
579            1,
580            RevisionLifecycle::Staged,
581        );
582        env.bundles.push(bundle);
583        env.revisions.push(rev);
584        store.save(&env).unwrap();
585        let outcome = check(&store, &OpFlags::default(), "local").unwrap();
586        assert_eq!(outcome.result["clean"], false);
587    }
588
589    #[test]
590    fn check_blocks_dev_env_with_traffic_splits() {
591        let dir = tempdir().unwrap();
592        let store = LocalFsStore::new(dir.path());
593        let mut env = make_env("dev");
594        let bundle = make_bundle_deployment("dev", "fast2flow");
595        let rev = make_revision(
596            "dev",
597            "fast2flow",
598            &bundle.deployment_id,
599            1,
600            RevisionLifecycle::Staged,
601        );
602        let split = make_traffic_split(
603            "dev",
604            "fast2flow",
605            &bundle.deployment_id,
606            &rev.revision_id,
607            "init",
608        );
609        env.bundles.push(bundle);
610        env.revisions.push(rev);
611        env.traffic_splits.push(split);
612        store.save(&env).unwrap();
613        let outcome = check(&store, &OpFlags::default(), "local").unwrap();
614        assert_eq!(outcome.result["clean"], false);
615    }
616
617    #[test]
618    fn classify_runtime_env_var_matches_legacy_value() {
619        let from = EnvId::try_from("dev").unwrap();
620        let finding = classify_runtime_env_var(Some("dev"), &from).expect("matched");
621        assert_eq!(finding.kind, "runtime-env-var");
622        assert_eq!(finding.severity, FindingSeverity::Info);
623        assert_eq!(finding.location, "$GREENTIC_ENV");
624        assert!(finding.message.contains("`dev`"));
625    }
626
627    #[test]
628    fn classify_runtime_env_var_ignores_other_values() {
629        let from = EnvId::try_from("dev").unwrap();
630        assert!(classify_runtime_env_var(Some("local"), &from).is_none());
631        assert!(classify_runtime_env_var(Some(""), &from).is_none());
632        assert!(classify_runtime_env_var(None, &from).is_none());
633    }
634
635    #[test]
636    fn apply_refuses_when_not_clean() {
637        let dir = tempdir().unwrap();
638        let store = LocalFsStore::new(dir.path());
639        let mut env = make_env("dev");
640        env.bundles.push(make_bundle_deployment("dev", "fast2flow"));
641        store.save(&env).unwrap();
642        let err = apply(&store, &OpFlags::default(), "local").unwrap_err();
643        assert!(matches!(err, OpError::Conflict(_)), "got {err:?}");
644    }
645
646    #[test]
647    fn apply_is_idempotent_when_no_dev_env() {
648        let dir = tempdir().unwrap();
649        let store = LocalFsStore::new(dir.path());
650        let outcome = apply(&store, &OpFlags::default(), "local").unwrap();
651        assert_eq!(outcome.result["legacy_dir_renamed_to"], Value::Null);
652        assert_eq!(outcome.result["merged_slots"].as_array().unwrap().len(), 0);
653    }
654
655    #[test]
656    fn apply_migrates_simple_dev_into_fresh_local() {
657        let dir = tempdir().unwrap();
658        let store = LocalFsStore::new(dir.path());
659        let mut env = make_env("dev");
660        env.packs
661            .push(make_binding(CapabilitySlot::Deployer, LOCAL_DEPLOYER_PACK));
662        env.packs
663            .push(make_binding(CapabilitySlot::Secrets, LOCAL_SECRETS_PACK));
664        store.save(&env).unwrap();
665        let outcome = apply(&store, &OpFlags::default(), "local").unwrap();
666        assert!(
667            outcome.result["legacy_dir_renamed_to"]
668                .as_str()
669                .unwrap()
670                .contains(".dev-migrated-"),
671            "got: {}",
672            outcome.result["legacy_dir_renamed_to"]
673        );
674        let merged: Vec<&str> = outcome.result["merged_slots"]
675            .as_array()
676            .unwrap()
677            .iter()
678            .map(|v| v.as_str().unwrap())
679            .collect();
680        // Both slots were absent from local → both should be merged.
681        assert_eq!(merged.len(), 2);
682
683        // Target env exists with the expected bindings.
684        let local = store.load(&EnvId::try_from("local").unwrap()).unwrap();
685        assert_eq!(local.packs.len(), 2);
686        // Source dir no longer reachable via list.
687        let envs = store.list().unwrap();
688        assert!(envs.iter().any(|e| e.as_str() == "local"));
689        assert!(!envs.iter().any(|e| e.as_str() == "dev"));
690    }
691
692    fn make_extension(
693        descriptor: &str,
694        instance: Option<&str>,
695    ) -> greentic_deploy_spec::ExtensionBinding {
696        greentic_deploy_spec::ExtensionBinding {
697            kind: greentic_deploy_spec::PackDescriptor::try_new(descriptor).unwrap(),
698            pack_ref: greentic_deploy_spec::PackId::new("pack-ext"),
699            instance_id: instance.map(str::to_string),
700            answers_ref: None,
701            generation: 0,
702            previous_binding_ref: None,
703        }
704    }
705
706    #[test]
707    fn apply_migrates_dev_extensions_into_target() {
708        // Regression: a `dev` env carrying extension bindings (but no heavy
709        // state) was `--check`-clean yet lost its extensions on migrate. They
710        // must now ride along, keyed by `(path, instance_id)`.
711        let dir = tempdir().unwrap();
712        let store = LocalFsStore::new(dir.path());
713        let mut env = make_env("dev");
714        env.extensions
715            .push(make_extension("acme.oauth.auth0@1.0.0", None));
716        env.extensions
717            .push(make_extension("acme.oauth.auth0@1.0.0", Some("primary")));
718        store.save(&env).unwrap();
719
720        // A bare extension-bearing env is still eligible for simple migration.
721        let report = run_check(
722            &store,
723            &EnvId::try_from("dev").unwrap(),
724            &EnvId::try_from("local").unwrap(),
725        )
726        .unwrap();
727        assert!(report.clean, "extension-only dev env should be clean");
728
729        let outcome = apply(&store, &OpFlags::default(), "local").unwrap();
730        let merged: Vec<&str> = outcome.result["merged_extensions"]
731            .as_array()
732            .unwrap()
733            .iter()
734            .map(|v| v.as_str().unwrap())
735            .collect();
736        assert_eq!(merged.len(), 2, "got {merged:?}");
737        assert!(merged.contains(&"acme.oauth.auth0"));
738        assert!(merged.contains(&"acme.oauth.auth0/primary"));
739
740        // Target carries both bindings; none were dropped.
741        let local = store.load(&EnvId::try_from("local").unwrap()).unwrap();
742        assert_eq!(local.extensions.len(), 2);
743    }
744
745    #[test]
746    fn apply_preserves_target_extension_on_key_collision() {
747        // A binding already on the target (same path + instance) is preserved;
748        // the source's same-key binding does not overwrite it.
749        let dir = tempdir().unwrap();
750        let store = LocalFsStore::new(dir.path());
751
752        let mut local = make_env("local");
753        local
754            .extensions
755            .push(make_extension("acme.oauth.auth0@2.0.0", Some("primary")));
756        store.save(&local).unwrap();
757
758        let mut dev = make_env("dev");
759        dev.extensions
760            .push(make_extension("acme.oauth.auth0@1.0.0", Some("primary")));
761        dev.extensions
762            .push(make_extension("acme.other.tool@1.0.0", None));
763        store.save(&dev).unwrap();
764
765        let outcome = apply(&store, &OpFlags::default(), "local").unwrap();
766        let merged: Vec<&str> = outcome.result["merged_extensions"]
767            .as_array()
768            .unwrap()
769            .iter()
770            .map(|v| v.as_str().unwrap())
771            .collect();
772        // Only the new key is reported merged; the colliding one is skipped.
773        assert_eq!(merged, vec!["acme.other.tool"]);
774
775        let local = store.load(&EnvId::try_from("local").unwrap()).unwrap();
776        assert_eq!(local.extensions.len(), 2);
777        // The pre-existing target binding kept its @2.0.0 version.
778        let primary = local
779            .extensions
780            .iter()
781            .find(|e| e.instance_id.as_deref() == Some("primary"))
782            .unwrap();
783        assert_eq!(primary.kind.version().to_string(), "2.0.0");
784    }
785
786    #[test]
787    fn apply_merges_into_existing_local_preserving_user_bindings() {
788        let dir = tempdir().unwrap();
789        let store = LocalFsStore::new(dir.path());
790
791        // Pre-existing `local` env with a user-customized secrets binding.
792        let mut local = make_env("local");
793        local.packs.push(make_binding(
794            CapabilitySlot::Secrets,
795            "greentic.secrets.aws-secrets-manager@1.0.0",
796        ));
797        store.save(&local).unwrap();
798
799        // Legacy `dev` env with deployer + secrets defaults.
800        let mut dev = make_env("dev");
801        dev.packs
802            .push(make_binding(CapabilitySlot::Deployer, LOCAL_DEPLOYER_PACK));
803        dev.packs
804            .push(make_binding(CapabilitySlot::Secrets, LOCAL_SECRETS_PACK));
805        dev.packs.push(make_binding(
806            CapabilitySlot::Telemetry,
807            LOCAL_TELEMETRY_PACK,
808        ));
809        store.save(&dev).unwrap();
810
811        let outcome = apply(&store, &OpFlags::default(), "local").unwrap();
812        let merged: Vec<&str> = outcome.result["merged_slots"]
813            .as_array()
814            .unwrap()
815            .iter()
816            .map(|v| v.as_str().unwrap())
817            .collect();
818        // local already had secrets bound — only deployer + telemetry should
819        // be merged.
820        assert_eq!(merged.len(), 2, "got {merged:?}");
821        assert!(merged.contains(&"deployer"));
822        assert!(merged.contains(&"telemetry"));
823
824        // Verify the user's secrets descriptor was NOT overwritten.
825        let local = store.load(&EnvId::try_from("local").unwrap()).unwrap();
826        let secrets_binding = local
827            .packs
828            .iter()
829            .find(|b| b.slot == CapabilitySlot::Secrets)
830            .expect("secrets slot");
831        assert_eq!(
832            secrets_binding.kind.as_str(),
833            "greentic.secrets.aws-secrets-manager@1.0.0",
834            "user-customized secrets descriptor was overwritten"
835        );
836    }
837
838    #[test]
839    fn apply_after_apply_is_a_no_op() {
840        let dir = tempdir().unwrap();
841        let store = LocalFsStore::new(dir.path());
842        let mut env = make_env("dev");
843        env.packs
844            .push(make_binding(CapabilitySlot::Deployer, LOCAL_DEPLOYER_PACK));
845        store.save(&env).unwrap();
846        let _ = apply(&store, &OpFlags::default(), "local").unwrap();
847        // Second apply: dev is gone, local has the binding — no-op.
848        let outcome = apply(&store, &OpFlags::default(), "local").unwrap();
849        assert_eq!(outcome.result["legacy_dir_renamed_to"], Value::Null);
850        assert_eq!(outcome.result["merged_slots"].as_array().unwrap().len(), 0);
851    }
852
853    #[test]
854    fn check_rejects_self_target() {
855        let dir = tempdir().unwrap();
856        let store = LocalFsStore::new(dir.path());
857        let err = check(&store, &OpFlags::default(), "dev").unwrap_err();
858        assert!(matches!(err, OpError::InvalidArgument(_)), "got {err:?}");
859    }
860
861    #[test]
862    fn check_schema_only_returns_schema() {
863        let dir = tempdir().unwrap();
864        let store = LocalFsStore::new(dir.path());
865        let flags = OpFlags {
866            schema_only: true,
867            answers: None,
868        };
869        let outcome = check(&store, &flags, "local").unwrap();
870        assert_eq!(outcome.result["title"], "MigrateDevPayload");
871    }
872}