1use 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;
20pub use crate::environment::LocalEnvOutcome;
23use crate::environment::{EnsureLocalEnvironmentPayload, LocalFsStore, Locked};
24
25use super::{OpError, map_store_err_preserving_noun};
26
27pub 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 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
66fn refresh_local_runtime_stub(
76 locked: &Locked<'_>,
77 env: &Environment,
78 force_bump: bool,
79) -> Result<(), OpError> {
80 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 if let Some(ref rt) = existing
93 && rt.generated_by.path() != LocalProcessDeployerHandler::DESCRIPTOR_PATH
94 {
95 return Ok(());
96 }
97
98 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 use crate::environment::EnvironmentStore;
280 use greentic_deploy_spec::{EnvPackBinding, PackDescriptor, PackId};
281
282 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 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 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 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 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 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 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 ensure_local_environment(&store, None).expect("first bootstrap");
538 let env_id = EnvId::try_from(LOCAL_ENV_ID).unwrap();
539 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 ensure_local_environment(&store, None).expect("first bootstrap");
562 let env_id = EnvId::try_from(LOCAL_ENV_ID).unwrap();
563
564 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 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 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 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 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 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 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 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}