1use 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
45pub const LEGACY_ENV_ID: &str = "dev";
48
49const MIGRATED_PREFIX: &str = ".dev-migrated-";
53
54#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
56#[serde(rename_all = "kebab-case")]
57pub enum FindingSeverity {
58 Info,
60 Blocking,
63}
64
65#[derive(Debug, Clone, Serialize, Deserialize)]
67pub struct MigrationFinding {
68 pub kind: &'static str,
71 pub severity: FindingSeverity,
72 pub location: String,
75 pub message: String,
78}
79
80#[derive(Debug, Clone, Serialize)]
82pub struct MigrateDevReport {
83 pub from_env: String,
84 pub to_env: String,
85 pub clean: bool,
87 pub findings: Vec<MigrationFinding>,
88}
89
90#[derive(Debug, Clone, Serialize)]
92pub struct MigrateDevApplyOutcome {
93 pub from_env: String,
94 pub to_env: String,
95 pub merged_slots: Vec<String>,
98 pub merged_extensions: Vec<String>,
102 pub legacy_dir_renamed_to: Option<String>,
106}
107
108pub struct ScanContext<'a> {
110 pub store: &'a LocalFsStore,
111 pub from_env: &'a EnvId,
112 pub to_env: &'a EnvId,
113}
114
115pub trait MigrationScanner {
118 fn name(&self) -> &'static str;
121 fn scan(&self, ctx: &ScanContext<'_>) -> Result<Vec<MigrationFinding>, OpError>;
122}
123
124pub 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
137pub 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
194pub 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
214pub 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
234pub 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
254pub 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
276pub 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
291fn 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
310pub 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
336pub 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
351pub 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 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 return Err(OpError::Conflict(
409 "source env is not eligible for simple migration (see `--check`)".to_string(),
410 ));
411 }
412 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
469fn 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 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 assert_eq!(merged.len(), 2);
682
683 let local = store.load(&EnvId::try_from("local").unwrap()).unwrap();
685 assert_eq!(local.packs.len(), 2);
686 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 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 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 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 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 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 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 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 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 assert_eq!(merged.len(), 2, "got {merged:?}");
821 assert!(merged.contains(&"deployer"));
822 assert!(merged.contains(&"telemetry"));
823
824 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 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}