Skip to main content

greentic_deployer/cli/
bootstrap.rs

1//! `ensure_local_environment` (`A4` of `plans/next-gen-deployment.md`).
2//!
3//! Idempotent bootstrap of the `local` [`Environment`] with the five default
4//! [`greentic_deploy_spec::EnvPackBinding`]s (deployer/secrets/telemetry/sessions/state).
5//! Invoked by `gtc setup` and `gtc start` before any operator-state read so
6//! first-run installs always have an env to bind against.
7//!
8//! Heavy resolution of the descriptor strings to concrete handlers is the
9//! env-pack registry's job (A9); A4 only persists the binding intent.
10
11use chrono::Utc;
12use greentic_deploy_spec::{
13    CapabilitySlot, EnvId, Environment, EnvironmentRuntime, PackDescriptor, SchemaVersion,
14};
15use serde_json::Value;
16use std::collections::BTreeMap;
17
18use crate::defaults::{LOCAL_DEPLOYER_PACK, LOCAL_ENV_ID};
19use crate::env_packs::LocalProcessDeployerHandler;
20// Re-export so `cli::env` can reference `super::bootstrap::LocalEnvOutcome`
21// as before (the enum moved from this module to `environment::bootstrap`).
22pub use crate::environment::LocalEnvOutcome;
23use crate::environment::{EnsureLocalEnvironmentPayload, LocalFsStore, Locked};
24
25use super::{OpError, map_store_err_preserving_noun};
26
27/// Creates the `local` Environment with default env-pack bindings if absent.
28///
29/// Idempotent: callers may invoke this on every `gtc setup` / `gtc start`
30/// without checking first. Returns `(Environment, LocalEnvOutcome)` so
31/// consumers can log a one-line note on creation and stay silent otherwise.
32///
33/// `public_base_url`: when `Some`, persisted on the env's `host_config` ONLY
34/// during creation (so a `Created` outcome carries it). For `AlreadyExists`
35/// and `Healed` outcomes the existing URL is preserved — use
36/// `op env set-public-url` (or `op config set --public-url`) to overwrite it.
37/// The value is NOT validated here; callers (the `op env init` dispatcher)
38/// must run [`greentic_deploy_spec::validate_public_base_url`] before passing
39/// it in.
40///
41/// Delegates to [`LocalFsStore::ensure_local_environment`] for the atomic
42/// read-modify-write, then runs `refresh_local_runtime_stub` at the CLI
43/// layer.
44pub fn ensure_local_environment(
45    store: &LocalFsStore,
46    public_base_url: Option<String>,
47) -> Result<(Environment, LocalEnvOutcome), OpError> {
48    let env_id = EnvId::try_from(LOCAL_ENV_ID).map_err(|e| {
49        OpError::InvalidArgument(format!("default env id `{}`: {}", LOCAL_ENV_ID, e))
50    })?;
51    let payload = EnsureLocalEnvironmentPayload { public_base_url };
52    let (env, outcome) = store
53        .ensure_local_environment(&env_id, payload)
54        .map_err(map_store_err_preserving_noun)?;
55    // Runtime stub refresh runs outside the typed verb's flock. The tiny
56    // race window is acceptable — the stub is a derived projection that
57    // self-heals on every bootstrap call.
58    let force_bump = !matches!(outcome, LocalEnvOutcome::AlreadyExists);
59    store.transact(&env_id, |locked| -> Result<(), OpError> {
60        refresh_local_runtime_stub(locked, &env, force_bump)?;
61        Ok(())
62    })?;
63    Ok((env, outcome))
64}
65
66/// Refresh (or create) the local-process deployer's `runtime.json` stub.
67/// `force_bump = true` (Create/Heal): always write with a bumped generation.
68/// `force_bump = false` (AlreadyExists): write only when `listen_addr` is
69/// stale or the file is absent. Existing `discovered` keys are preserved;
70/// only `listen_addr` is upserted.
71///
72/// TODO(phase-d): replace with `EnvPackHandler::report_runtime_config()` on
73/// `LocalProcessDeployerHandler` once the trait method lands (see
74/// `plans/next-gen-deployment.md` line 1406).
75fn refresh_local_runtime_stub(
76    locked: &Locked<'_>,
77    env: &Environment,
78    force_bump: bool,
79) -> Result<(), OpError> {
80    // Gate 1: env Deployer binding must be local-process — otherwise this
81    // helper is not the authoritative producer.
82    let is_local = env
83        .pack_for_slot(CapabilitySlot::Deployer)
84        .is_some_and(|b| b.kind.path() == LocalProcessDeployerHandler::DESCRIPTOR_PATH);
85    if !is_local {
86        return Ok(());
87    }
88
89    let existing = locked.load_runtime()?;
90
91    // Gate 2: a foreign producer's runtime.json is left untouched.
92    if let Some(ref rt) = existing
93        && rt.generated_by.path() != LocalProcessDeployerHandler::DESCRIPTOR_PATH
94    {
95        return Ok(());
96    }
97
98    // No-op fast path for the dominant AlreadyExists case: check the one key
99    // we own BEFORE cloning the discovered map.
100    let new_addr_str = env.host_config.resolved_listen_addr().to_string();
101    if !force_bump
102        && let Some(ref rt) = existing
103        && rt.discovered.get("listen_addr") == Some(&Value::String(new_addr_str.clone()))
104    {
105        return Ok(());
106    }
107
108    let prev_generation = existing.as_ref().map(|r| r.generation).unwrap_or(0);
109    let mut desired_discovered = existing.map(|r| r.discovered).unwrap_or_default();
110    desired_discovered.insert("listen_addr".to_string(), Value::String(new_addr_str));
111
112    locked
113        .save_runtime(&build_local_runtime_stub(
114            &env.environment_id,
115            prev_generation + 1,
116            desired_discovered,
117        )?)
118        .map_err(Into::into)
119}
120
121fn build_local_runtime_stub(
122    env_id: &EnvId,
123    generation: u64,
124    discovered: BTreeMap<String, Value>,
125) -> Result<EnvironmentRuntime, OpError> {
126    let generated_by = PackDescriptor::try_new(LOCAL_DEPLOYER_PACK).map_err(|e| {
127        OpError::InvalidArgument(format!(
128            "local-process descriptor `{}`: {}",
129            LOCAL_DEPLOYER_PACK, e
130        ))
131    })?;
132    Ok(EnvironmentRuntime {
133        schema: SchemaVersion::new(SchemaVersion::ENVIRONMENT_RUNTIME_V1),
134        environment_id: env_id.clone(),
135        discovered,
136        generated_at: Utc::now(),
137        generated_by,
138        generation,
139    })
140}
141
142#[cfg(test)]
143mod tests {
144    use super::*;
145    use crate::defaults::{
146        LOCAL_DEPLOYER_PACK, LOCAL_SECRETS_PACK, LOCAL_SESSIONS_PACK, LOCAL_STATE_PACK,
147        LOCAL_TELEMETRY_PACK,
148    };
149    use greentic_deploy_spec::{CapabilitySlot, DEFAULT_LISTEN_ADDR, EnvironmentHostConfig};
150    use tempfile::TempDir;
151
152    fn store() -> (TempDir, LocalFsStore) {
153        let tmp = TempDir::new().expect("tempdir");
154        let store = LocalFsStore::new(tmp.path().to_path_buf());
155        (tmp, store)
156    }
157
158    #[test]
159    fn creates_local_env_when_missing() {
160        let (_tmp, store) = store();
161        let (env, outcome) = ensure_local_environment(&store, None).expect("bootstrap");
162        assert_eq!(outcome, LocalEnvOutcome::Created);
163        assert_eq!(env.environment_id.as_str(), LOCAL_ENV_ID);
164        assert_eq!(env.name, LOCAL_ENV_ID);
165        assert_eq!(env.packs.len(), 5);
166        env.validate().expect("env is spec-valid");
167    }
168
169    #[test]
170    fn returns_existing_env_on_second_call() {
171        let (_tmp, store) = store();
172        let (first, first_outcome) =
173            ensure_local_environment(&store, None).expect("first bootstrap");
174        assert_eq!(first_outcome, LocalEnvOutcome::Created);
175        let (second, second_outcome) =
176            ensure_local_environment(&store, None).expect("second bootstrap");
177        assert_eq!(second_outcome, LocalEnvOutcome::AlreadyExists);
178        assert_eq!(first, second);
179    }
180
181    #[test]
182    fn default_bindings_cover_expected_slots_and_descriptors() {
183        let (_tmp, store) = store();
184        let (env, _) = ensure_local_environment(&store, None).expect("bootstrap");
185        let by_slot: std::collections::BTreeMap<CapabilitySlot, &str> = env
186            .packs
187            .iter()
188            .map(|b| (b.slot, b.kind.as_str()))
189            .collect();
190        assert_eq!(
191            by_slot.get(&CapabilitySlot::Deployer).copied(),
192            Some(LOCAL_DEPLOYER_PACK)
193        );
194        assert_eq!(
195            by_slot.get(&CapabilitySlot::Secrets).copied(),
196            Some(LOCAL_SECRETS_PACK)
197        );
198        assert_eq!(
199            by_slot.get(&CapabilitySlot::Telemetry).copied(),
200            Some(LOCAL_TELEMETRY_PACK)
201        );
202        assert_eq!(
203            by_slot.get(&CapabilitySlot::Sessions).copied(),
204            Some(LOCAL_SESSIONS_PACK)
205        );
206        assert_eq!(
207            by_slot.get(&CapabilitySlot::State).copied(),
208            Some(LOCAL_STATE_PACK)
209        );
210        assert!(!by_slot.contains_key(&CapabilitySlot::Revocation));
211    }
212
213    #[test]
214    fn bootstrap_env_has_no_bundles_or_revisions_or_splits() {
215        let (_tmp, store) = store();
216        let (env, _) = ensure_local_environment(&store, None).expect("bootstrap");
217        assert!(env.bundles.is_empty());
218        assert!(env.revisions.is_empty());
219        assert!(env.traffic_splits.is_empty());
220        assert!(env.credentials_ref.is_none());
221    }
222
223    #[test]
224    fn bootstrap_env_host_config_has_no_region_or_org() {
225        let (_tmp, store) = store();
226        let (env, _) = ensure_local_environment(&store, None).expect("bootstrap");
227        assert_eq!(env.host_config.env_id, env.environment_id);
228        assert!(env.host_config.region.is_none());
229        assert!(env.host_config.tenant_org_id.is_none());
230    }
231
232    #[test]
233    fn bootstrap_env_writes_default_listen_addr_so_start_can_resolve_it() {
234        let (_tmp, store) = store();
235        let (env, outcome) = ensure_local_environment(&store, None).expect("bootstrap");
236        assert_eq!(outcome, LocalEnvOutcome::Created);
237        assert_eq!(
238            env.host_config.listen_addr,
239            Some(DEFAULT_LISTEN_ADDR),
240            "fresh env must carry the canonical loopback default so `gtc start` \
241             on an empty env has a deterministic bind address",
242        );
243        assert_eq!(env.host_config.resolved_listen_addr(), DEFAULT_LISTEN_ADDR);
244    }
245
246    #[test]
247    fn bootstrap_heal_path_preserves_user_set_listen_addr() {
248        use crate::environment::EnvironmentStore;
249        use std::net::{IpAddr, Ipv4Addr, SocketAddr};
250        let (_tmp, store) = store();
251        let (mut env, _) = ensure_local_environment(&store, None).expect("bootstrap");
252        let custom = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(0, 0, 0, 0)), 9090);
253        env.host_config.listen_addr = Some(custom);
254        store.save(&env).expect("user save");
255        let (reloaded, outcome) = ensure_local_environment(&store, None).expect("second bootstrap");
256        assert_eq!(outcome, LocalEnvOutcome::AlreadyExists);
257        assert_eq!(
258            reloaded.host_config.listen_addr,
259            Some(custom),
260            "user's custom bind must survive re-bootstrap",
261        );
262    }
263
264    #[test]
265    fn second_call_does_not_overwrite_user_mutations() {
266        use crate::environment::EnvironmentStore;
267        let (_tmp, store) = store();
268        let (mut env, _) = ensure_local_environment(&store, None).expect("bootstrap");
269        env.name = "user-renamed".to_string();
270        env.host_config.region = Some("eu-west-1".to_string());
271        store.save(&env).expect("user save");
272        let (reloaded, outcome) = ensure_local_environment(&store, None).expect("second bootstrap");
273        assert_eq!(outcome, LocalEnvOutcome::AlreadyExists);
274        assert_eq!(reloaded.name, "user-renamed");
275        assert_eq!(reloaded.host_config.region.as_deref(), Some("eu-west-1"));
276    }
277
278    // Helpers for the heal-path tests below.
279    use crate::environment::EnvironmentStore;
280    use greentic_deploy_spec::{EnvPackBinding, PackDescriptor, PackId};
281
282    /// Seed an empty `local` env (all 5 slots missing) — mimics the state a
283    /// user lands in after running `gtc op env create local` (A3), which
284    /// creates an env with `packs = Vec::new()`.
285    fn seed_empty_local_env(store: &LocalFsStore) -> Environment {
286        let env_id = EnvId::try_from(LOCAL_ENV_ID).unwrap();
287        let env = Environment {
288            schema: SchemaVersion::new(SchemaVersion::ENVIRONMENT_V1),
289            environment_id: env_id.clone(),
290            name: LOCAL_ENV_ID.to_string(),
291            host_config: EnvironmentHostConfig {
292                env_id,
293                region: None,
294                tenant_org_id: None,
295                listen_addr: None,
296                public_base_url: None,
297                gui_enabled: None,
298            },
299            packs: Vec::new(),
300            credentials_ref: None,
301            bundles: Vec::new(),
302            revisions: Vec::new(),
303            traffic_splits: Vec::new(),
304            messaging_endpoints: Vec::new(),
305            extensions: Vec::new(),
306            revocation: Default::default(),
307            retention: Default::default(),
308            health: Default::default(),
309        };
310        store.save(&env).expect("seed save");
311        env
312    }
313
314    fn custom_binding(slot: CapabilitySlot, descriptor: &str) -> EnvPackBinding {
315        EnvPackBinding {
316            slot,
317            kind: PackDescriptor::try_new(descriptor).expect("valid descriptor"),
318            pack_ref: PackId::new(descriptor),
319            answers_ref: None,
320            generation: 0,
321            previous_binding_ref: None,
322        }
323    }
324
325    #[test]
326    fn heals_existing_env_with_no_packs() {
327        let (_tmp, store) = store();
328        seed_empty_local_env(&store);
329        let (env, outcome) = ensure_local_environment(&store, None).expect("bootstrap heal");
330        match outcome {
331            LocalEnvOutcome::Healed { added_slots } => {
332                assert_eq!(
333                    added_slots,
334                    vec![
335                        CapabilitySlot::Deployer,
336                        CapabilitySlot::Secrets,
337                        CapabilitySlot::Telemetry,
338                        CapabilitySlot::Sessions,
339                        CapabilitySlot::State,
340                    ],
341                    "all 5 default slots should be reported as added"
342                );
343            }
344            other => panic!("expected Healed, got {other:?}"),
345        }
346        assert_eq!(env.packs.len(), 5);
347        env.validate().expect("env is spec-valid after heal");
348        // Persisted: second call sees a fully-bound env and reports AlreadyExists.
349        let (_again, outcome2) = ensure_local_environment(&store, None).expect("second bootstrap");
350        assert_eq!(outcome2, LocalEnvOutcome::AlreadyExists);
351    }
352
353    #[test]
354    fn heals_existing_env_with_partial_packs() {
355        let (_tmp, store) = store();
356        let mut env = seed_empty_local_env(&store);
357        // Pre-seed only the deployer slot; expect bootstrap to add the other 4.
358        env.packs.push(custom_binding(
359            CapabilitySlot::Deployer,
360            LOCAL_DEPLOYER_PACK,
361        ));
362        store.save(&env).expect("partial save");
363
364        let (env, outcome) = ensure_local_environment(&store, None).expect("bootstrap heal");
365        match outcome {
366            LocalEnvOutcome::Healed { added_slots } => {
367                assert_eq!(
368                    added_slots,
369                    vec![
370                        CapabilitySlot::Secrets,
371                        CapabilitySlot::Telemetry,
372                        CapabilitySlot::Sessions,
373                        CapabilitySlot::State,
374                    ],
375                    "only the 4 missing slots should be reported as added"
376                );
377            }
378            other => panic!("expected Healed, got {other:?}"),
379        }
380        assert_eq!(env.packs.len(), 5);
381        env.validate()
382            .expect("env is spec-valid after partial heal");
383    }
384
385    #[test]
386    fn heal_preserves_user_bound_non_default_descriptor() {
387        let (_tmp, store) = store();
388        let mut env = seed_empty_local_env(&store);
389        // User bound `secrets` to a custom backend; bootstrap must NOT overwrite.
390        let custom_secrets = "greentic.secrets.aws-secrets-manager@1.0.0";
391        env.packs
392            .push(custom_binding(CapabilitySlot::Secrets, custom_secrets));
393        store.save(&env).expect("custom-secrets save");
394
395        let (env, outcome) = ensure_local_environment(&store, None).expect("bootstrap heal");
396        match outcome {
397            LocalEnvOutcome::Healed { added_slots } => {
398                assert_eq!(
399                    added_slots,
400                    vec![
401                        CapabilitySlot::Deployer,
402                        CapabilitySlot::Telemetry,
403                        CapabilitySlot::Sessions,
404                        CapabilitySlot::State,
405                    ],
406                    "secrets must NOT be re-added; the 4 other defaults fill the gaps"
407                );
408            }
409            other => panic!("expected Healed, got {other:?}"),
410        }
411        let secrets_descriptor = env
412            .packs
413            .iter()
414            .find(|b| b.slot == CapabilitySlot::Secrets)
415            .map(|b| b.kind.as_str())
416            .expect("secrets slot present");
417        assert_eq!(
418            secrets_descriptor, custom_secrets,
419            "user's custom secrets descriptor must survive bootstrap"
420        );
421        assert_eq!(env.packs.len(), 5);
422    }
423
424    #[test]
425    fn fully_bound_env_yields_already_exists_with_no_healing() {
426        let (_tmp, store) = store();
427        // First call creates the env with all 5 defaults; second call must NOT
428        // report any healing — env is already in the required shape.
429        ensure_local_environment(&store, None).expect("first bootstrap");
430        let (_env, outcome) = ensure_local_environment(&store, None).expect("second bootstrap");
431        assert_eq!(outcome, LocalEnvOutcome::AlreadyExists);
432    }
433
434    #[test]
435    fn create_writes_runtime_stub_with_listen_addr() {
436        let (_tmp, store) = store();
437        let (env, _) = ensure_local_environment(&store, None).expect("bootstrap");
438        let runtime = store
439            .load_runtime(&env.environment_id)
440            .expect("load runtime")
441            .expect("runtime.json must exist after first bootstrap");
442        assert_eq!(runtime.environment_id, env.environment_id);
443        assert_eq!(
444            runtime.schema.as_str(),
445            SchemaVersion::ENVIRONMENT_RUNTIME_V1
446        );
447        assert_eq!(runtime.generation, 1);
448        assert_eq!(runtime.generated_by.as_str(), LOCAL_DEPLOYER_PACK);
449        let listen_addr = runtime
450            .discovered
451            .get("listen_addr")
452            .expect("discovered must seed listen_addr for runtime:// resolution");
453        assert_eq!(
454            listen_addr.as_str(),
455            Some(env.host_config.resolved_listen_addr().to_string().as_str()),
456        );
457    }
458
459    #[test]
460    fn already_exists_preserves_runtime_stub_and_skips_rewrite() {
461        let (_tmp, store) = store();
462        ensure_local_environment(&store, None).expect("first bootstrap");
463        let env_id = EnvId::try_from(LOCAL_ENV_ID).unwrap();
464        let first = store
465            .load_runtime(&env_id)
466            .expect("load runtime")
467            .expect("runtime.json exists");
468        ensure_local_environment(&store, None).expect("second bootstrap");
469        let second = store
470            .load_runtime(&env_id)
471            .expect("load runtime")
472            .expect("runtime.json exists");
473        // An idempotent re-bootstrap must NOT churn the runtime stub — the
474        // file is identical (same generation + same generated_at). This is
475        // what the C5 watcher relies on to avoid spurious reloads on every
476        // `gtc start`.
477        assert_eq!(
478            first.generation, second.generation,
479            "generation must not bump on AlreadyExists"
480        );
481        assert_eq!(
482            first.generated_at, second.generated_at,
483            "generated_at must not refresh on AlreadyExists"
484        );
485    }
486
487    #[test]
488    fn already_exists_writes_runtime_stub_when_absent() {
489        let (_tmp, store) = store();
490        let (_env, _) = ensure_local_environment(&store, None).expect("first bootstrap");
491        let env_id = EnvId::try_from(LOCAL_ENV_ID).unwrap();
492        // Simulate an env that was created before the C5 stub-producer landed
493        // (or where an operator manually deleted runtime.json): delete the
494        // file under the env-store layout and re-run bootstrap.
495        let runtime_path = store
496            .env_lock_path(&env_id)
497            .map(|p| {
498                p.parent()
499                    .expect("lock path has parent")
500                    .join("runtime.json")
501            })
502            .expect("runtime path");
503        std::fs::remove_file(&runtime_path).expect("remove runtime.json");
504        assert!(!runtime_path.exists());
505
506        let (_env, outcome) = ensure_local_environment(&store, None).expect("second bootstrap");
507        assert_eq!(outcome, LocalEnvOutcome::AlreadyExists);
508        let runtime = store
509            .load_runtime(&env_id)
510            .expect("load runtime")
511            .expect("runtime.json must be re-emitted by the AlreadyExists path");
512        assert_eq!(runtime.generation, 1);
513    }
514
515    #[test]
516    fn heal_writes_runtime_stub() {
517        let (_tmp, store) = store();
518        seed_empty_local_env(&store);
519        let env_id = EnvId::try_from(LOCAL_ENV_ID).unwrap();
520        assert!(
521            store.load_runtime(&env_id).expect("load runtime").is_none(),
522            "seeded env should have no runtime.json yet",
523        );
524        let (_env, outcome) = ensure_local_environment(&store, None).expect("bootstrap heal");
525        assert!(matches!(outcome, LocalEnvOutcome::Healed { .. }));
526        let runtime = store
527            .load_runtime(&env_id)
528            .expect("load runtime")
529            .expect("heal arm must emit runtime.json");
530        assert_eq!(runtime.generation, 1);
531    }
532
533    #[test]
534    fn heal_bumps_runtime_stub_generation_when_present() {
535        let (_tmp, store) = store();
536        // First bootstrap creates env + writes generation=1.
537        ensure_local_environment(&store, None).expect("first bootstrap");
538        let env_id = EnvId::try_from(LOCAL_ENV_ID).unwrap();
539        // Strip one of the default bindings so the next bootstrap call falls
540        // into the Heal arm and re-emits the stub.
541        let mut env = store.load(&env_id).expect("load");
542        env.packs.retain(|b| b.slot != CapabilitySlot::Telemetry);
543        store.save(&env).expect("user save");
544
545        let (_env, outcome) = ensure_local_environment(&store, None).expect("heal bootstrap");
546        assert!(matches!(outcome, LocalEnvOutcome::Healed { .. }));
547        let runtime = store
548            .load_runtime(&env_id)
549            .expect("load runtime")
550            .expect("runtime.json still present");
551        assert_eq!(
552            runtime.generation, 2,
553            "Heal arm must bump generation against the previous stub",
554        );
555    }
556
557    #[test]
558    fn heal_preserves_existing_discovered_keys() {
559        let (_tmp, store) = store();
560        // First bootstrap to create env + runtime stub (generation=1).
561        ensure_local_environment(&store, None).expect("first bootstrap");
562        let env_id = EnvId::try_from(LOCAL_ENV_ID).unwrap();
563
564        // Inject extra discovered keys into the existing runtime.json,
565        // simulating a Phase-D deployer that added `alb_dns`.
566        let mut runtime = store.load_runtime(&env_id).expect("load").expect("exists");
567        runtime
568            .discovered
569            .insert("alb_dns".to_string(), Value::String("ALB".to_string()));
570        store.save_runtime(&runtime).expect("save patched runtime");
571
572        // Strip one default binding so the next bootstrap falls into Heal.
573        let mut env = store.load(&env_id).expect("load env");
574        env.packs.retain(|b| b.slot != CapabilitySlot::Telemetry);
575        store.save(&env).expect("user save");
576
577        let (healed_env, outcome) = ensure_local_environment(&store, None).expect("heal bootstrap");
578        assert!(matches!(outcome, LocalEnvOutcome::Healed { .. }));
579
580        let refreshed = store.load_runtime(&env_id).expect("load").expect("exists");
581        assert_eq!(
582            refreshed.discovered.get("alb_dns").and_then(Value::as_str),
583            Some("ALB"),
584            "pre-existing discovered keys must survive Heal",
585        );
586        assert_eq!(
587            refreshed
588                .discovered
589                .get("listen_addr")
590                .and_then(Value::as_str),
591            Some(
592                healed_env
593                    .host_config
594                    .resolved_listen_addr()
595                    .to_string()
596                    .as_str()
597            ),
598        );
599        assert!(
600            refreshed.generation > runtime.generation,
601            "generation must bump on Heal",
602        );
603    }
604
605    #[test]
606    fn heal_skips_runtime_stub_when_deployer_binding_is_not_local_process() {
607        let (_tmp, store) = store();
608        let mut env = seed_empty_local_env(&store);
609        // Bind the Deployer slot to a non-local-process descriptor.
610        env.packs.push(custom_binding(
611            CapabilitySlot::Deployer,
612            "greentic.deployer.aws-ecs@1.0.0",
613        ));
614        store.save(&env).expect("save env with aws deployer");
615
616        // Pre-seed runtime.json with the AWS deployer as generated_by.
617        let aws_descriptor = PackDescriptor::try_new("greentic.deployer.aws-ecs@1.0.0").unwrap();
618        let mut aws_discovered = BTreeMap::new();
619        aws_discovered.insert("alb_dns".to_string(), Value::String("ALB".to_string()));
620        let aws_runtime = EnvironmentRuntime {
621            schema: SchemaVersion::new(SchemaVersion::ENVIRONMENT_RUNTIME_V1),
622            environment_id: env.environment_id.clone(),
623            discovered: aws_discovered,
624            generated_at: Utc::now(),
625            generated_by: aws_descriptor,
626            generation: 5,
627        };
628        store.save_runtime(&aws_runtime).expect("save aws runtime");
629
630        // Bootstrap — should heal missing slots but NOT touch runtime.json.
631        let (_env, outcome) = ensure_local_environment(&store, None).expect("heal");
632        assert!(matches!(outcome, LocalEnvOutcome::Healed { .. }));
633
634        let after = store
635            .load_runtime(&env.environment_id)
636            .expect("load")
637            .expect("exists");
638        assert_eq!(
639            after.generation, aws_runtime.generation,
640            "generation must not change — foreign producer owns the file",
641        );
642        assert_eq!(
643            after.generated_at, aws_runtime.generated_at,
644            "generated_at must not change",
645        );
646        assert_eq!(
647            after.discovered, aws_runtime.discovered,
648            "discovered must be untouched",
649        );
650    }
651
652    #[test]
653    fn already_exists_refreshes_stale_listen_addr() {
654        use std::net::{IpAddr, Ipv4Addr, SocketAddr};
655        let (_tmp, store) = store();
656        // First bootstrap writes env + runtime stub with default listen_addr.
657        ensure_local_environment(&store, None).expect("first bootstrap");
658        let env_id = EnvId::try_from(LOCAL_ENV_ID).unwrap();
659        let before = store.load_runtime(&env_id).expect("load").expect("exists");
660
661        // Mutate listen_addr on the persisted env (simulates `op config set`).
662        let mut env = store.load(&env_id).expect("load env");
663        let custom = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(0, 0, 0, 0)), 9090);
664        env.host_config.listen_addr = Some(custom);
665        store.save(&env).expect("user save");
666
667        // Second bootstrap → AlreadyExists, but listen_addr is now stale.
668        let (_env, outcome) = ensure_local_environment(&store, None).expect("second bootstrap");
669        assert_eq!(outcome, LocalEnvOutcome::AlreadyExists);
670
671        let after = store.load_runtime(&env_id).expect("load").expect("exists");
672        assert_eq!(
673            after.discovered.get("listen_addr").and_then(Value::as_str),
674            Some(custom.to_string().as_str()),
675            "listen_addr must be refreshed to the new address",
676        );
677        assert_eq!(
678            after.generation,
679            before.generation + 1,
680            "generation must bump by exactly 1",
681        );
682    }
683}