1use chrono::Utc;
7use greentic_deploy_spec::{
8 CapabilitySlot, EnvId, Environment, EnvironmentHostConfig, RevisionId, validate_public_base_url,
9};
10use serde::{Deserialize, Serialize};
11use serde_json::{Value, json};
12
13use crate::environment::{
14 EnvironmentReads, EnvironmentStore, FieldUpdate, LocalFsStore, UpdateEnvironmentPayload,
15};
16
17use super::{
18 AuditCtx, OpError, OpFlags, OpOutcome, audit_and_record, map_store_err_preserving_noun,
19};
20
21const NOUN: &str = "env";
22
23#[derive(Debug, Clone, Serialize, Deserialize)]
31pub struct EnvCreatePayload {
32 pub environment_id: String,
33 pub name: String,
34 #[serde(default, skip_serializing_if = "Option::is_none")]
35 pub region: Option<String>,
36 #[serde(default, skip_serializing_if = "Option::is_none")]
37 pub tenant_org_id: Option<String>,
38 #[serde(default, skip_serializing_if = "Option::is_none")]
44 pub listen_addr: Option<String>,
45 #[serde(default, skip_serializing_if = "Option::is_none")]
51 pub public_base_url: Option<String>,
52}
53
54#[derive(Debug, Clone, Serialize, Deserialize)]
56pub struct EnvSummary {
57 pub environment_id: String,
58 pub name: String,
59 pub region: Option<String>,
60 pub tenant_org_id: Option<String>,
61 #[serde(default, skip_serializing_if = "Option::is_none")]
65 pub listen_addr: Option<std::net::SocketAddr>,
66 #[serde(default, skip_serializing_if = "Option::is_none")]
67 pub public_base_url: Option<String>,
68 pub pack_count: usize,
69 pub bundle_count: usize,
70 pub revision_count: usize,
71}
72
73impl From<&Environment> for EnvSummary {
74 fn from(env: &Environment) -> Self {
75 Self {
76 environment_id: env.environment_id.as_str().to_string(),
77 name: env.name.clone(),
78 region: env.host_config.region.clone(),
79 tenant_org_id: env.host_config.tenant_org_id.clone(),
80 listen_addr: env.host_config.listen_addr,
81 public_base_url: env.host_config.public_base_url.clone(),
82 pack_count: env.packs.len(),
83 bundle_count: env.bundles.len(),
84 revision_count: env.revisions.len(),
85 }
86 }
87}
88
89pub fn create(
92 store: &LocalFsStore,
93 flags: &OpFlags,
94 payload: Option<EnvCreatePayload>,
95) -> Result<OpOutcome, OpError> {
96 if flags.schema_only {
97 return schema_outcome("create");
98 }
99 let payload = resolve_payload::<EnvCreatePayload>(flags, payload)?;
100 let env_id = EnvId::try_from(payload.environment_id.as_str())
101 .map_err(|e| OpError::InvalidArgument(format!("environment_id: {e}")))?;
102 let parsed_listen_addr = payload
105 .listen_addr
106 .as_deref()
107 .map(|raw| {
108 raw.parse::<std::net::SocketAddr>().map_err(|e| {
109 OpError::InvalidArgument(format!(
110 "listen_addr {raw:?} is not a valid socket address: {e}"
111 ))
112 })
113 })
114 .transpose()?;
115 let parsed_public_base_url = parse_optional_public_base_url(&payload.public_base_url)?;
116 let ctx = AuditCtx {
117 env_id: env_id.clone(),
118 noun: NOUN,
119 verb: "create",
120 target: json!({"environment_id": env_id.as_str()}),
121 idempotency_key: None,
122 };
123 audit_and_record(store, ctx, |_committed| {
124 let env = store
125 .create_environment(
126 &env_id,
127 payload.name,
128 EnvironmentHostConfig {
129 env_id: env_id.clone(),
130 region: payload.region,
131 tenant_org_id: payload.tenant_org_id,
132 listen_addr: parsed_listen_addr,
133 public_base_url: parsed_public_base_url,
134 gui_enabled: None,
135 },
136 )
137 .map_err(map_store_err_preserving_noun)?;
138 let outcome = OpOutcome::new(
139 NOUN,
140 "create",
141 serde_json::to_value(EnvSummary::from(&env)).expect("EnvSummary is json-safe"),
142 );
143 Ok((outcome, super::AuditGens::NONE))
144 })
145}
146
147pub fn update(
151 store: &LocalFsStore,
152 flags: &OpFlags,
153 payload: Option<EnvCreatePayload>,
154) -> Result<OpOutcome, OpError> {
155 if flags.schema_only {
156 return schema_outcome("update");
157 }
158 let payload = resolve_payload::<EnvCreatePayload>(flags, payload)?;
159 let env_id = EnvId::try_from(payload.environment_id.as_str())
160 .map_err(|e| OpError::InvalidArgument(format!("environment_id: {e}")))?;
161 let parsed_public_base_url = parse_optional_public_base_url(&payload.public_base_url)?;
162 let mut fields = Vec::new();
163 if payload.name != payload.environment_id {
164 fields.push("name");
165 }
166 if payload.region.is_some() {
167 fields.push("region");
168 }
169 if payload.tenant_org_id.is_some() {
170 fields.push("tenant_org_id");
171 }
172 if parsed_public_base_url.is_some() {
173 fields.push("public_base_url");
174 }
175 let ctx = AuditCtx {
176 env_id: env_id.clone(),
177 noun: NOUN,
178 verb: "update",
179 target: json!({"environment_id": env_id.as_str(), "fields": fields}),
180 idempotency_key: None,
181 };
182 audit_and_record(store, ctx, |_committed| {
183 let env = store
184 .update_environment(
185 &env_id,
186 UpdateEnvironmentPayload {
187 name: Some(payload.name),
188 region: FieldUpdate::from_option(payload.region),
189 tenant_org_id: FieldUpdate::from_option(payload.tenant_org_id),
190 listen_addr: FieldUpdate::Keep,
191 public_base_url: FieldUpdate::from_option(parsed_public_base_url),
192 gui_enabled: FieldUpdate::Keep,
193 },
194 )
195 .map_err(map_store_err_preserving_noun)?;
196 let outcome = OpOutcome::new(
197 NOUN,
198 "update",
199 serde_json::to_value(EnvSummary::from(&env)).expect("EnvSummary is json-safe"),
200 );
201 Ok((outcome, super::AuditGens::NONE))
202 })
203}
204
205pub fn list(store: &dyn EnvironmentReads, flags: &OpFlags) -> Result<OpOutcome, OpError> {
207 if flags.schema_only {
208 return Ok(OpOutcome::new(
210 NOUN,
211 "list",
212 json!({ "input_schema": "no input" }),
213 ));
214 }
215 let mut summaries = Vec::new();
216 for env_id in store.list_env_ids()? {
217 let env = store.load_env(&env_id)?;
218 summaries.push(EnvSummary::from(&env));
219 }
220 Ok(OpOutcome::new(
221 NOUN,
222 "list",
223 json!({ "environments": summaries }),
224 ))
225}
226
227pub fn show(
229 store: &dyn EnvironmentReads,
230 flags: &OpFlags,
231 env_id: &str,
232) -> Result<OpOutcome, OpError> {
233 if flags.schema_only {
234 return Ok(OpOutcome::new(
235 NOUN,
236 "show",
237 json!({ "input_schema": "env_id positional" }),
238 ));
239 }
240 let env_id =
241 EnvId::try_from(env_id).map_err(|e| OpError::InvalidArgument(format!("env_id: {e}")))?;
242 if !store.env_exists(&env_id)? {
243 return Err(OpError::NotFound(format!("environment `{env_id}`")));
244 }
245 let env = store.load_env(&env_id)?;
246 let runtime = store.read_runtime(&env_id)?;
247 Ok(OpOutcome::new(
248 NOUN,
249 "show",
250 json!({
251 "environment": env,
252 "runtime": runtime,
253 }),
254 ))
255}
256
257pub fn doctor(store: &LocalFsStore, flags: &OpFlags, env_id: &str) -> Result<OpOutcome, OpError> {
261 if flags.schema_only {
262 return Ok(OpOutcome::new(
263 NOUN,
264 "doctor",
265 json!({ "input_schema": "env_id positional" }),
266 ));
267 }
268 let env_id =
269 EnvId::try_from(env_id).map_err(|e| OpError::InvalidArgument(format!("env_id: {e}")))?;
270 if !store.exists(&env_id)? {
271 return Err(OpError::NotFound(format!("environment `{env_id}`")));
272 }
273 let env = store.load(&env_id)?;
274 let runtime = store.load_runtime(&env_id)?;
275 let validate_result = env.validate();
276 let bound_slots: Vec<String> = env.packs.iter().map(|b| b.slot.to_string()).collect();
277 let missing_slots: Vec<String> = greentic_deploy_spec::CapabilitySlot::ALL
282 .iter()
283 .copied()
284 .filter(|s| s.binds_in_packs())
285 .filter(|s| env.pack_for_slot(*s).is_none())
286 .map(|s| s.to_string())
287 .collect();
288 let registry = crate::env_packs::EnvPackRegistry::with_builtins();
293 let mut unknown_kinds: Vec<String> = Vec::new();
294 let mut slot_mismatches: Vec<Value> = Vec::new();
295 let mut version_skew: Vec<Value> = Vec::new();
296 for binding in &env.packs {
297 match registry.resolve_for_slot(binding.slot, &binding.kind) {
298 Ok(_) => {}
299 Err(crate::env_packs::RegistryError::Unknown(kind)) => unknown_kinds.push(kind),
300 Err(crate::env_packs::RegistryError::SlotMismatch {
301 kind,
302 expected,
303 actual,
304 }) => slot_mismatches.push(json!({
305 "kind": kind,
306 "bound_slot": expected.to_string(),
307 "handler_slot": actual.to_string(),
308 })),
309 Err(crate::env_packs::RegistryError::VersionUnsupported {
310 kind,
311 requested,
312 supported,
313 }) => version_skew.push(json!({
314 "kind": kind,
315 "requested": requested,
316 "supported": supported,
317 })),
318 Err(
322 err @ (crate::env_packs::RegistryError::DuplicateRegistration(_)
323 | crate::env_packs::RegistryError::DeployerMissingCredentials { .. }),
324 ) => {
325 unreachable!("resolve_for_slot never returns {err:?}")
326 }
327 }
328 }
329 let mut extension_report = ExtensionDoctor::default();
337 for ext in &env.extensions {
338 match registry.resolve_for_slot(greentic_deploy_spec::CapabilitySlot::Extension, &ext.kind)
339 {
340 Ok(_) => {}
341 Err(crate::env_packs::RegistryError::Unknown(kind)) => {
342 extension_report.unknown_kinds.push(kind)
343 }
344 Err(crate::env_packs::RegistryError::SlotMismatch { kind, actual, .. }) => {
345 extension_report.slot_mismatches.push(json!({
346 "kind": kind,
347 "handler_slot": actual.to_string(),
348 }))
349 }
350 Err(crate::env_packs::RegistryError::VersionUnsupported {
351 kind,
352 requested,
353 supported,
354 }) => extension_report.version_skew.push(json!({
355 "kind": kind,
356 "requested": requested,
357 "supported": supported,
358 })),
359 Err(
360 err @ (crate::env_packs::RegistryError::DuplicateRegistration(_)
361 | crate::env_packs::RegistryError::DeployerMissingCredentials { .. }),
362 ) => {
363 unreachable!("resolve_for_slot never returns {err:?}")
364 }
365 }
366 }
367 Ok(OpOutcome::new(
368 NOUN,
369 "doctor",
370 json!({
371 "environment_id": env.environment_id.as_str(),
372 "validate": match &validate_result {
373 Ok(()) => json!({"status": "ok"}),
374 Err(e) => json!({"status": "error", "message": e.to_string()}),
375 },
376 "bound_slots": bound_slots,
377 "missing_slots": missing_slots,
378 "unknown_kinds": unknown_kinds,
379 "slot_mismatches": slot_mismatches,
380 "version_skew": version_skew,
381 "extensions": {
382 "count": env.extensions.len(),
383 "unknown_kinds": extension_report.unknown_kinds,
384 "slot_mismatches": extension_report.slot_mismatches,
385 "version_skew": extension_report.version_skew,
386 },
387 "has_runtime": runtime.is_some(),
388 "checked_at": Utc::now(),
389 }),
390 ))
391}
392
393#[derive(Default)]
397struct ExtensionDoctor {
398 unknown_kinds: Vec<String>,
399 slot_mismatches: Vec<Value>,
400 version_skew: Vec<Value>,
401}
402
403pub fn tool_check(
414 store: &LocalFsStore,
415 flags: &OpFlags,
416 env_id: &str,
417) -> Result<OpOutcome, OpError> {
418 if flags.schema_only {
419 return Ok(OpOutcome::new(
420 NOUN,
421 "tool-check",
422 json!({ "input_schema": "env_id positional" }),
423 ));
424 }
425 let env_id =
426 EnvId::try_from(env_id).map_err(|e| OpError::InvalidArgument(format!("env_id: {e}")))?;
427 if !store.exists(&env_id)? {
428 return Err(OpError::NotFound(format!("environment `{env_id}`")));
429 }
430 let env = store.load(&env_id)?;
431 let registry = crate::env_packs::EnvPackRegistry::with_builtins();
432 let mut bindings: Vec<Value> = Vec::with_capacity(env.packs.len());
433 let mut unresolved_bindings: Vec<Value> = Vec::new();
434 let mut total_checks = 0usize;
435 let mut failed_checks = 0usize;
436 for binding in &env.packs {
437 match registry.resolve_for_slot(binding.slot, &binding.kind) {
438 Ok(handler) => {
439 let checks = handler.preflight();
440 total_checks += checks.len();
441 failed_checks += checks.iter().filter(|c| !c.outcome.is_ok()).count();
442 bindings.push(json!({
443 "slot": binding.slot.to_string(),
444 "kind": binding.kind.as_str(),
445 "checks": checks,
446 }));
447 }
448 Err(e) => unresolved_bindings.push(json!({
449 "slot": binding.slot.to_string(),
450 "kind": binding.kind.as_str(),
451 "error": e.to_string(),
452 })),
453 }
454 }
455 let mut extension_bindings: Vec<Value> = Vec::with_capacity(env.extensions.len());
459 let mut extension_unresolved: Vec<Value> = Vec::new();
460 for ext in &env.extensions {
461 match registry.resolve_for_slot(greentic_deploy_spec::CapabilitySlot::Extension, &ext.kind)
462 {
463 Ok(handler) => {
464 let checks = handler.preflight();
465 total_checks += checks.len();
466 failed_checks += checks.iter().filter(|c| !c.outcome.is_ok()).count();
467 extension_bindings.push(json!({
468 "kind": ext.kind.as_str(),
469 "instance_id": ext.instance_id,
470 "checks": checks,
471 }));
472 }
473 Err(e) => extension_unresolved.push(json!({
474 "kind": ext.kind.as_str(),
475 "instance_id": ext.instance_id,
476 "error": e.to_string(),
477 })),
478 }
479 }
480 Ok(OpOutcome::new(
481 NOUN,
482 "tool-check",
483 json!({
484 "environment_id": env.environment_id.as_str(),
485 "bindings": bindings,
486 "unresolved_bindings": unresolved_bindings,
487 "extension_bindings": extension_bindings,
488 "extension_unresolved_bindings": extension_unresolved,
489 "total_checks": total_checks,
490 "failed_checks": failed_checks,
491 "checked_at": Utc::now(),
492 }),
493 ))
494}
495
496pub fn render(
518 store: &LocalFsStore,
519 registry: &crate::env_packs::EnvPackRegistry,
520 flags: &OpFlags,
521 args: super::dispatch::EnvRenderArgs,
522) -> Result<OpOutcome, OpError> {
523 if flags.schema_only {
524 return Ok(OpOutcome::new(
525 NOUN,
526 "render",
527 json!({
528 "input_schema": "env_id positional; --kind <path[@version]> optional \
529 (defaults to the env's deployer binding); --output <dir> optional"
530 }),
531 ));
532 }
533 let env_id = EnvId::try_from(args.env_id.as_str())
534 .map_err(|e| OpError::InvalidArgument(format!("env_id: {e}")))?;
535 if !store.exists(&env_id)? {
536 return Err(OpError::NotFound(format!("environment `{env_id}`")));
537 }
538 let env = store.load(&env_id)?;
539 let descriptor = resolve_render_kind(&env, args.kind.as_deref())?;
540 let handler = registry
541 .resolve_for_slot(CapabilitySlot::Deployer, &descriptor)
542 .map_err(|e| OpError::Conflict(e.to_string()))?;
543 let renderer = handler.as_manifest_renderer().ok_or_else(|| {
544 OpError::Conflict(format!(
545 "env-pack kind `{}` does not support manifest rendering",
546 descriptor.path()
547 ))
548 })?;
549
550 let (answers, answers_ref_wire) = load_render_answers(store, &env, &descriptor)?;
554 use crate::env_packs::render::ManifestRenderer as _;
560 let objects = if descriptor.path() == crate::env_packs::k8s::K8sDeployerHandler::DESCRIPTOR_PATH
561 {
562 let secrets_backend = resolve_secrets_backend(store, &env)?;
563 crate::env_packs::k8s::K8sDeployerHandler::default()
564 .with_secrets_backend(secrets_backend)
565 .render_environment(&env, answers.as_ref())
566 .map_err(|e| OpError::Conflict(e.to_string()))?
567 } else {
568 renderer
569 .render_environment(&env, answers.as_ref())
570 .map_err(|e| OpError::Conflict(e.to_string()))?
571 };
572
573 let mut result = json!({
574 "environment_id": env.environment_id.as_str(),
575 "kind": descriptor.as_str(),
576 "object_count": objects.len(),
577 "answers_ref": answers_ref_wire,
578 });
579 match args.output {
580 Some(dir) => {
581 let write_result = write_rendered_objects(&dir, &objects)?;
582 result["output_dir"] = json!(dir);
583 result["files"] = json!(write_result.files);
584 result["removed_stale_files"] = json!(write_result.removed_stale_files);
585 result["unmanaged_files"] = json!(write_result.unmanaged_files);
586 }
587 None => result["manifests"] = Value::Array(objects),
588 }
589 Ok(OpOutcome::new(NOUN, "render", result))
590}
591
592pub fn reconcile(
607 store: &LocalFsStore,
608 registry: &crate::env_packs::EnvPackRegistry,
609 flags: &OpFlags,
610 args: super::dispatch::EnvReconcileArgs,
611) -> Result<OpOutcome, OpError> {
612 if flags.schema_only {
613 return Ok(OpOutcome::new(
614 NOUN,
615 "reconcile",
616 json!({
617 "input_schema": "env_id positional; --kind <path[@version]> optional \
618 (defaults to the env's deployer binding)"
619 }),
620 ));
621 }
622 let env_id = EnvId::try_from(args.env_id.as_str())
623 .map_err(|e| OpError::InvalidArgument(format!("env_id: {e}")))?;
624 if !store.exists(&env_id)? {
625 return Err(OpError::NotFound(format!("environment `{env_id}`")));
626 }
627 let env = store.load(&env_id)?;
628 let descriptor = resolve_live_deployer_kind(&env, args.kind.as_deref())?;
629
630 let k8s_path = crate::env_packs::k8s::K8sDeployerHandler::DESCRIPTOR_PATH;
632 if descriptor.path() != k8s_path {
633 return Err(OpError::Conflict(format!(
634 "env reconcile is only supported for the `{k8s_path}` deployer env-pack \
635 today; `{}` cannot be reconciled to a live cluster (the AWS-ECS reconcile \
636 path is a later Phase D slice)",
637 descriptor.path()
638 )));
639 }
640 let _handler = registry
642 .resolve_for_slot(CapabilitySlot::Deployer, &descriptor)
643 .map_err(|e| OpError::Conflict(e.to_string()))?;
644
645 let (answers, answers_ref_wire) = load_render_answers(store, &env, &descriptor)?;
646 let bound_token =
653 crate::env_packs::k8s::resolve_bound_identity(store, &env, &env_id, answers.as_ref())?;
654 let identity = if bound_token.is_some() {
655 "bound"
656 } else {
657 "ambient"
658 };
659 let dev_secrets = read_dev_secrets_b64(store, &env_id)?;
663 let secrets_backend = resolve_secrets_backend(store, &env)?;
667 let report = reconcile_k8s_cluster(
668 &env,
669 answers.as_ref(),
670 bound_token,
671 dev_secrets,
672 secrets_backend,
673 )?;
674
675 Ok(OpOutcome::new(
676 NOUN,
677 "reconcile",
678 json!({
679 "environment_id": env.environment_id.as_str(),
680 "kind": descriptor.as_str(),
681 "answers_ref": answers_ref_wire,
682 "identity": identity,
688 "applied_count": report.applied.len(),
689 "pruned_count": report.pruned.len(),
690 "applied": report.applied,
691 "pruned": report.pruned,
692 }),
693 ))
694}
695
696#[cfg(feature = "k8s-client")]
700pub(crate) fn reconcile_k8s_cluster(
701 env: &Environment,
702 answers: Option<&Value>,
703 bound_token: Option<String>,
704 dev_secrets: Option<String>,
705 secrets_backend: crate::env_packs::k8s::manifests::SecretsBackend,
706) -> Result<crate::env_packs::k8s::ReconcileReport, OpError> {
707 use crate::env_packs::k8s::async_bridge::run_k8s_async;
708 use crate::env_packs::k8s::kube_client::connect;
709 use crate::env_packs::k8s::manifests::kubeconfig_context_from_answers;
710 use crate::env_packs::k8s::{K8sDeployerHandler, KubeCluster};
711 use std::sync::Arc;
712
713 let kubeconfig_context = kubeconfig_context_from_answers(answers);
714 let manage_namespace = bound_token.is_none();
720 run_k8s_async(async move {
721 let client = connect(kubeconfig_context.as_deref(), bound_token.as_deref())
725 .await
726 .map_err(|e| OpError::Conflict(format!("cannot reach the cluster: {e}")))?;
727 let handler = K8sDeployerHandler::with_cluster_and_dev_secrets(
728 Arc::new(KubeCluster::new(client)),
729 dev_secrets,
730 )
731 .with_secrets_backend(secrets_backend);
732 handler
733 .reconcile(env, answers, manage_namespace)
734 .await
735 .map_err(|e| OpError::Conflict(e.to_string()))
736 })
737}
738
739#[cfg(not(feature = "k8s-client"))]
741pub(crate) fn reconcile_k8s_cluster(
742 _env: &Environment,
743 _answers: Option<&Value>,
744 _bound_token: Option<String>,
745 _dev_secrets: Option<String>,
746 _secrets_backend: crate::env_packs::k8s::manifests::SecretsBackend,
747) -> Result<crate::env_packs::k8s::ReconcileReport, OpError> {
748 Err(OpError::Conflict(
749 "this build was compiled without the `k8s-client` feature; \
750 `op env reconcile` needs it to connect to a cluster"
751 .to_string(),
752 ))
753}
754
755fn read_dev_secrets_b64(store: &LocalFsStore, env_id: &EnvId) -> Result<Option<String>, OpError> {
761 use base64::Engine as _;
762 let env_dir = store
763 .env_dir(env_id)
764 .map_err(|e| OpError::Conflict(format!("resolving env dir: {e}")))?;
765 let path = super::secrets::resolve_dev_store_path(&env_dir, None);
766 match std::fs::read(&path) {
767 Ok(bytes) => Ok(Some(
768 base64::engine::general_purpose::STANDARD.encode(bytes),
769 )),
770 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
771 Err(e) => Err(OpError::Conflict(format!(
772 "reading dev-store at {}: {e}",
773 path.display()
774 ))),
775 }
776}
777
778pub fn apply_revision(
805 store: &LocalFsStore,
806 registry: &crate::env_packs::EnvPackRegistry,
807 flags: &OpFlags,
808 args: super::dispatch::EnvApplyRevisionArgs,
809) -> Result<OpOutcome, OpError> {
810 if flags.schema_only {
811 return Ok(OpOutcome::new(
812 NOUN,
813 "apply-revision",
814 json!({
815 "input_schema": "env_id + revision_id positional; --kind <path[@version]> optional \
816 (defaults to the env's deployer binding)"
817 }),
818 ));
819 }
820 let env_id = EnvId::try_from(args.env_id.as_str())
821 .map_err(|e| OpError::InvalidArgument(format!("env_id: {e}")))?;
822 if !store.exists(&env_id)? {
823 return Err(OpError::NotFound(format!("environment `{env_id}`")));
824 }
825 let env = store.load(&env_id)?;
826
827 let descriptor = resolve_live_deployer_kind(&env, args.kind.as_deref())?;
828
829 let _handler = registry
831 .resolve_for_slot(CapabilitySlot::Deployer, &descriptor)
832 .map_err(|e| OpError::Conflict(e.to_string()))?;
833
834 let k8s_path = crate::env_packs::k8s::K8sDeployerHandler::DESCRIPTOR_PATH;
839 let is_k8s = descriptor.path() == k8s_path;
840 if !is_k8s && !is_aws_ecs_kind(&descriptor) {
841 return Err(unsupported_apply_kind(&descriptor));
842 }
843
844 let revision_id = {
845 use std::str::FromStr;
846 let ulid = ulid::Ulid::from_str(&args.revision_id)
847 .map_err(|e| OpError::InvalidArgument(format!("revision_id: {e}")))?;
848 RevisionId(ulid)
849 };
850 let revision = env
851 .revisions
852 .iter()
853 .find(|r| r.revision_id == revision_id)
854 .ok_or_else(|| {
855 OpError::NotFound(format!(
856 "revision `{revision_id}` not found in env `{env_id}`"
857 ))
858 })?;
859
860 let (answers, answers_ref_wire) = load_render_answers(store, &env, &descriptor)?;
861
862 let present = crate::env_packs::k8s::manifests::has_cluster_presence(revision.lifecycle);
866 let action = if present { "warmed" } else { "archived" };
867 let lifecycle = revision.lifecycle;
868
869 let (identity, worker_name): (&'static str, String) = if is_k8s {
875 let worker_name = crate::env_packs::k8s::manifests::worker_name(revision);
876 let bound_token =
877 crate::env_packs::k8s::resolve_bound_identity(store, &env, &env_id, answers.as_ref())?;
878 let identity = if bound_token.is_some() {
879 "bound"
880 } else {
881 "ambient"
882 };
883 let secrets_backend = resolve_secrets_backend(store, &env)?;
887 apply_revision_k8s_cluster(
888 &env,
889 revision_id,
890 present,
891 answers.as_ref(),
892 bound_token,
893 secrets_backend,
894 )?;
895 (identity, worker_name)
896 } else {
897 apply_revision_non_k8s(
898 store,
899 &env,
900 &env_id,
901 revision_id,
902 present,
903 answers.as_ref(),
904 &descriptor,
905 )?
906 };
907
908 Ok(OpOutcome::new(
909 NOUN,
910 "apply-revision",
911 json!({
912 "environment_id": env.environment_id.as_str(),
913 "kind": descriptor.as_str(),
914 "revision_id": revision_id.to_string(),
915 "lifecycle": lifecycle,
916 "action": action,
918 "worker_name": worker_name,
919 "answers_ref": answers_ref_wire,
920 "identity": identity,
922 }),
923 ))
924}
925
926#[cfg(feature = "k8s-client")]
930fn apply_revision_k8s_cluster(
931 env: &Environment,
932 revision_id: RevisionId,
933 present: bool,
934 answers: Option<&Value>,
935 bound_token: Option<String>,
936 secrets_backend: crate::env_packs::k8s::manifests::SecretsBackend,
937) -> Result<(), OpError> {
938 use crate::env_packs::deployer::Deployer;
939 use crate::env_packs::k8s::async_bridge::run_k8s_async;
940 use crate::env_packs::k8s::kube_client::connect;
941 use crate::env_packs::k8s::manifests::kubeconfig_context_from_answers;
942 use crate::env_packs::k8s::{K8sDeployerHandler, KubeCluster};
943 use std::sync::Arc;
944
945 let kubeconfig_context = kubeconfig_context_from_answers(answers);
946 run_k8s_async(async move {
947 let client = connect(kubeconfig_context.as_deref(), bound_token.as_deref())
950 .await
951 .map_err(|e| OpError::Conflict(format!("cannot reach the cluster: {e}")))?;
952 let handler = K8sDeployerHandler::with_cluster(Arc::new(KubeCluster::new(client)))
953 .with_secrets_backend(secrets_backend);
954 let result = if present {
955 handler
956 .warm_revision(env, revision_id, answers)
957 .await
958 .map(|_| ())
959 } else {
960 handler
961 .archive_revision(env, revision_id, answers)
962 .await
963 .map(|_| ())
964 };
965 result.map_err(|e| OpError::Conflict(e.to_string()))
966 })
967}
968
969#[cfg(not(feature = "k8s-client"))]
971fn apply_revision_k8s_cluster(
972 _env: &Environment,
973 _revision_id: RevisionId,
974 _present: bool,
975 _answers: Option<&Value>,
976 _bound_token: Option<String>,
977 _secrets_backend: crate::env_packs::k8s::manifests::SecretsBackend,
978) -> Result<(), OpError> {
979 Err(OpError::Conflict(
980 "this build was compiled without the `k8s-client` feature; \
981 `op env apply-revision` needs it to connect to a cluster"
982 .to_string(),
983 ))
984}
985
986#[cfg(feature = "creds-aws")]
990fn is_aws_ecs_kind(descriptor: &greentic_deploy_spec::PackDescriptor) -> bool {
991 descriptor.path() == crate::env_packs::aws::AwsEcsDeployerHandler::DESCRIPTOR_PATH
992}
993
994#[cfg(not(feature = "creds-aws"))]
995fn is_aws_ecs_kind(_descriptor: &greentic_deploy_spec::PackDescriptor) -> bool {
996 false
997}
998
999fn unsupported_apply_kind(descriptor: &greentic_deploy_spec::PackDescriptor) -> OpError {
1003 OpError::Conflict(format!(
1004 "env apply-revision is only supported for the `{}` (K8s) and \
1005 `greentic.deployer.aws-ecs` (AWS-ECS) deployer env-packs today; `{}` has no live \
1006 single-revision apply path",
1007 crate::env_packs::k8s::K8sDeployerHandler::DESCRIPTOR_PATH,
1008 descriptor.path()
1009 ))
1010}
1011
1012#[cfg(feature = "creds-aws")]
1017#[allow(clippy::too_many_arguments)]
1018fn apply_revision_non_k8s(
1019 store: &LocalFsStore,
1020 env: &Environment,
1021 env_id: &EnvId,
1022 revision_id: RevisionId,
1023 present: bool,
1024 answers: Option<&Value>,
1025 descriptor: &greentic_deploy_spec::PackDescriptor,
1026) -> Result<(&'static str, String), OpError> {
1027 if descriptor.path() != crate::env_packs::aws::AwsEcsDeployerHandler::DESCRIPTOR_PATH {
1028 return Err(unsupported_apply_kind(descriptor));
1029 }
1030 apply_revision_aws_ecs(store, env, env_id, revision_id, present, answers)
1031}
1032
1033#[cfg(not(feature = "creds-aws"))]
1034#[allow(clippy::too_many_arguments)]
1035fn apply_revision_non_k8s(
1036 _store: &LocalFsStore,
1037 _env: &Environment,
1038 _env_id: &EnvId,
1039 _revision_id: RevisionId,
1040 _present: bool,
1041 _answers: Option<&Value>,
1042 descriptor: &greentic_deploy_spec::PackDescriptor,
1043) -> Result<(&'static str, String), OpError> {
1044 Err(unsupported_apply_kind(descriptor))
1045}
1046
1047#[cfg(all(feature = "creds-aws", feature = "deploy-aws-ecs"))]
1055fn pinned_role_without_session(assume_role_arn: Option<&str>, session_present: bool) -> bool {
1056 assume_role_arn.is_some() && !session_present
1057}
1058
1059#[cfg(all(feature = "creds-aws", feature = "deploy-aws-ecs"))]
1069#[allow(clippy::type_complexity)]
1070fn aws_ecs_target_inputs(
1071 store: &LocalFsStore,
1072 env: &Environment,
1073 env_id: &EnvId,
1074 answers: Option<&Value>,
1075) -> Result<
1076 (
1077 &'static str,
1078 Option<crate::env_packs::aws::credentials::AssumedSession>,
1079 crate::env_packs::aws::real_target::FargateLaunchConfig,
1080 String,
1081 Vec<String>,
1082 ),
1083 OpError,
1084> {
1085 use crate::env_packs::aws::deployer::AwsEcsParams;
1086
1087 let session = crate::env_packs::aws::bound_session::resolve_bound_session(store, env, env_id)?;
1091 let identity = if session.is_some() {
1092 "bound"
1093 } else {
1094 "ambient"
1095 };
1096 let params = AwsEcsParams::from_answers(env, answers)
1097 .map_err(|e| OpError::Conflict(format!("invalid aws-ecs binding answers: {e}")))?;
1098 if pinned_role_without_session(params.assume_role_arn.as_deref(), session.is_some()) {
1099 return Err(OpError::Conflict(
1100 "the aws-ecs binding pins `assume_role_arn` (a deployer role to assume) but no bound \
1101 deployer session was found — refusing to run as the ambient AWS identity. Mint the \
1102 scoped session first with `op env bootstrap --bind` (or `op credentials rotate`)."
1103 .to_string(),
1104 ));
1105 }
1106 let launch = params.launch.ok_or_else(|| {
1107 OpError::Conflict(
1108 "the aws-ecs deployer binding has no Fargate launch config (needs execution_role_arn \
1109 + subnets + security_groups); re-run the binding wizard before applying"
1110 .to_string(),
1111 )
1112 })?;
1113 Ok((
1114 identity,
1115 session,
1116 launch,
1117 params.region,
1118 params.target_group_pool,
1119 ))
1120}
1121
1122#[cfg(all(feature = "creds-aws", feature = "deploy-aws-ecs"))]
1126async fn resolve_ecs_handler(
1127 region: &str,
1128 launch: crate::env_packs::aws::real_target::FargateLaunchConfig,
1129 pool: Vec<String>,
1130 session: Option<crate::env_packs::aws::credentials::AssumedSession>,
1131) -> Result<crate::env_packs::aws::AwsEcsDeployerHandler, OpError> {
1132 use crate::env_packs::aws::AwsEcsDeployerHandler;
1133 use crate::env_packs::aws::real_target::RealEcsTarget;
1134 use std::sync::Arc;
1135
1136 let target = RealEcsTarget::resolve(region, launch, pool, session)
1137 .await
1138 .map_err(|e| {
1139 OpError::Conflict(format!(
1140 "cannot initialize the AWS ECS deployer client: {e}"
1141 ))
1142 })?;
1143 Ok(AwsEcsDeployerHandler::with_target(Arc::new(target)))
1144}
1145
1146#[cfg(all(feature = "creds-aws", feature = "deploy-aws-ecs"))]
1151fn apply_revision_aws_ecs(
1152 store: &LocalFsStore,
1153 env: &Environment,
1154 env_id: &EnvId,
1155 revision_id: RevisionId,
1156 present: bool,
1157 answers: Option<&Value>,
1158) -> Result<(&'static str, String), OpError> {
1159 use crate::env_packs::aws::credentials::run_aws_async;
1160 use crate::env_packs::aws::real_target::service_name;
1161 use crate::env_packs::deployer::Deployer;
1162
1163 let revision = env
1164 .revisions
1165 .iter()
1166 .find(|r| r.revision_id == revision_id)
1167 .expect("revision presence checked by the caller");
1168 let worker_name = service_name(&revision.deployment_id);
1169 let (identity, session, launch, region, pool) =
1170 aws_ecs_target_inputs(store, env, env_id, answers)?;
1171
1172 run_aws_async(async move {
1173 let handler = resolve_ecs_handler(®ion, launch, pool, session).await?;
1174 if present {
1175 handler
1176 .warm_revision(env, revision_id, answers)
1177 .await
1178 .map_err(|e| OpError::Conflict(e.to_string()))?;
1179 } else {
1180 handler
1181 .archive_revision(env, revision_id, answers)
1182 .await
1183 .map_err(|e| OpError::Conflict(e.to_string()))?;
1184 }
1185 Ok::<(), OpError>(())
1186 })?;
1187 Ok((identity, worker_name))
1188}
1189
1190#[cfg(all(feature = "creds-aws", not(feature = "deploy-aws-ecs")))]
1191fn apply_revision_aws_ecs(
1192 _store: &LocalFsStore,
1193 _env: &Environment,
1194 _env_id: &EnvId,
1195 _revision_id: RevisionId,
1196 _present: bool,
1197 _answers: Option<&Value>,
1198) -> Result<(&'static str, String), OpError> {
1199 Err(OpError::Conflict(
1200 "this build was compiled without the `deploy-aws-ecs` feature; \
1201 `op env apply-revision` for an aws-ecs env needs it to talk to AWS"
1202 .to_string(),
1203 ))
1204}
1205
1206pub fn apply_traffic(
1215 store: &LocalFsStore,
1216 registry: &crate::env_packs::EnvPackRegistry,
1217 flags: &OpFlags,
1218 args: super::dispatch::EnvApplyTrafficArgs,
1219) -> Result<OpOutcome, OpError> {
1220 if flags.schema_only {
1221 return Ok(OpOutcome::new(
1222 NOUN,
1223 "apply-traffic",
1224 json!({
1225 "input_schema": "env_id + deployment_id positional; --kind <path[@version]> \
1226 optional (defaults to the env's deployer binding)"
1227 }),
1228 ));
1229 }
1230 let env_id = EnvId::try_from(args.env_id.as_str())
1231 .map_err(|e| OpError::InvalidArgument(format!("env_id: {e}")))?;
1232 if !store.exists(&env_id)? {
1233 return Err(OpError::NotFound(format!("environment `{env_id}`")));
1234 }
1235 let env = store.load(&env_id)?;
1236 let descriptor = resolve_live_deployer_kind(&env, args.kind.as_deref())?;
1237
1238 if !is_aws_ecs_kind(&descriptor) {
1244 return Err(OpError::Conflict(format!(
1245 "env apply-traffic is only supported for the `greentic.deployer.aws-ecs` (AWS-ECS) \
1246 deployer env-pack; `{}` serves traffic splits from its runtime router — record the \
1247 split with `op traffic set` and the runtime applies it",
1248 descriptor.path()
1249 )));
1250 }
1251 let _handler = registry
1253 .resolve_for_slot(CapabilitySlot::Deployer, &descriptor)
1254 .map_err(|e| OpError::Conflict(e.to_string()))?;
1255
1256 let deployment_id = {
1257 use std::str::FromStr;
1258 let ulid = ulid::Ulid::from_str(&args.deployment_id)
1259 .map_err(|e| OpError::InvalidArgument(format!("deployment_id: {e}")))?;
1260 greentic_deploy_spec::DeploymentId(ulid)
1261 };
1262 let (answers, _answers_ref_wire) = load_render_answers(store, &env, &descriptor)?;
1263
1264 let (identity, outcome) =
1271 apply_traffic_aws_ecs(store, &env, &env_id, deployment_id, answers.as_ref())?;
1272
1273 Ok(OpOutcome::new(
1274 NOUN,
1275 "apply-traffic",
1276 json!({
1277 "environment_id": env.environment_id.as_str(),
1278 "kind": descriptor.as_str(),
1279 "deployment_id": deployment_id.to_string(),
1280 "identity": identity,
1282 "applied_entries": outcome
1284 .applied_entries
1285 .iter()
1286 .map(|e| {
1287 json!({
1288 "revision_id": e.revision_id.to_string(),
1289 "weight_bps": e.weight_bps,
1290 })
1291 })
1292 .collect::<Vec<_>>(),
1293 }),
1294 ))
1295}
1296
1297#[cfg(all(feature = "creds-aws", feature = "deploy-aws-ecs"))]
1303fn apply_traffic_aws_ecs(
1304 store: &LocalFsStore,
1305 env: &Environment,
1306 env_id: &EnvId,
1307 deployment_id: greentic_deploy_spec::DeploymentId,
1308 answers: Option<&Value>,
1309) -> Result<
1310 (
1311 &'static str,
1312 crate::env_packs::deployer::TrafficSplitOutcome,
1313 ),
1314 OpError,
1315> {
1316 use crate::env_packs::aws::credentials::run_aws_async;
1317 use crate::env_packs::deployer::Deployer;
1318
1319 let (identity, session, launch, region, pool) =
1320 aws_ecs_target_inputs(store, env, env_id, answers)?;
1321
1322 let outcome = run_aws_async(async move {
1323 let handler = resolve_ecs_handler(®ion, launch, pool, session).await?;
1324 handler
1325 .apply_traffic_split(env, deployment_id, answers)
1326 .await
1327 .map_err(|e| OpError::Conflict(e.to_string()))
1328 })?;
1329 Ok((identity, outcome))
1330}
1331
1332#[cfg(not(all(feature = "creds-aws", feature = "deploy-aws-ecs")))]
1333fn apply_traffic_aws_ecs(
1334 _store: &LocalFsStore,
1335 _env: &Environment,
1336 _env_id: &EnvId,
1337 _deployment_id: greentic_deploy_spec::DeploymentId,
1338 _answers: Option<&Value>,
1339) -> Result<
1340 (
1341 &'static str,
1342 crate::env_packs::deployer::TrafficSplitOutcome,
1343 ),
1344 OpError,
1345> {
1346 Err(OpError::Conflict(
1347 "this build was compiled without the `deploy-aws-ecs` feature; \
1348 `op env apply-traffic` needs it to talk to AWS"
1349 .to_string(),
1350 ))
1351}
1352
1353pub(crate) fn load_render_answers(
1366 store: &LocalFsStore,
1367 env: &greentic_deploy_spec::Environment,
1368 descriptor: &greentic_deploy_spec::PackDescriptor,
1369) -> Result<(Option<Value>, Value), OpError> {
1370 let binding = env.pack_for_slot(CapabilitySlot::Deployer);
1371 let answers_ref = match binding {
1372 Some(b) if b.kind.path() == descriptor.path() => b.answers_ref.as_ref(),
1373 _ => None,
1374 };
1375 let Some(rel_path) = answers_ref else {
1376 return Ok((None, Value::Null));
1377 };
1378 let answers = read_binding_answers(store, env, rel_path)?;
1379 let wire = json!(rel_path.to_string_lossy());
1380 Ok((Some(answers), wire))
1381}
1382
1383fn read_binding_answers(
1388 store: &LocalFsStore,
1389 env: &greentic_deploy_spec::Environment,
1390 rel_path: &std::path::Path,
1391) -> Result<Value, OpError> {
1392 let env_dir = store.env_dir(&env.environment_id)?;
1393 let abs_path = match crate::path_safety::normalize_under_root(&env_dir, rel_path) {
1398 Ok(canon) => canon,
1399 Err(e) => {
1400 let missing =
1401 !rel_path.is_absolute() && env_dir.join(rel_path).symlink_metadata().is_err();
1402 return Err(if missing {
1403 OpError::Conflict(format!(
1404 "binding records answers_ref `{}` but the file does not exist \
1405 — re-run the binding wizard or fix the binding",
1406 rel_path.display()
1407 ))
1408 } else {
1409 OpError::Conflict(format!(
1410 "answers_ref `{}` escapes env directory: {e}",
1411 rel_path.display()
1412 ))
1413 });
1414 }
1415 };
1416 let raw = std::fs::read_to_string(&abs_path).map_err(|e| OpError::Io {
1417 path: abs_path.clone(),
1418 source: e,
1419 })?;
1420 serde_json::from_str(&raw).map_err(|e| {
1421 OpError::Conflict(format!(
1422 "answers_ref `{}` contains invalid JSON: {e}",
1423 rel_path.display()
1424 ))
1425 })
1426}
1427
1428fn load_secrets_answers(
1432 store: &LocalFsStore,
1433 env: &greentic_deploy_spec::Environment,
1434) -> Result<Option<Value>, OpError> {
1435 let Some(binding) = env.pack_for_slot(CapabilitySlot::Secrets) else {
1436 return Ok(None);
1437 };
1438 let Some(rel_path) = binding.answers_ref.as_ref() else {
1439 return Ok(None);
1440 };
1441 Ok(Some(read_binding_answers(store, env, rel_path)?))
1442}
1443
1444pub(crate) fn resolve_secrets_backend(
1450 store: &LocalFsStore,
1451 env: &greentic_deploy_spec::Environment,
1452) -> Result<crate::env_packs::k8s::manifests::SecretsBackend, OpError> {
1453 use crate::env_packs::k8s::manifests::SecretsBackend;
1454 let Some(binding) = env.pack_for_slot(CapabilitySlot::Secrets) else {
1455 return Ok(SecretsBackend::DevStore);
1456 };
1457 let path = binding.kind.path();
1458 if path == crate::defaults::DEV_STORE_SECRETS_PATH {
1459 return Ok(SecretsBackend::DevStore);
1460 }
1461 if path != crate::defaults::VAULT_SECRETS_PATH {
1462 return Err(OpError::Conflict(format!(
1463 "unknown secrets backend kind `{path}`; expected `{}` or `{}`",
1464 crate::defaults::DEV_STORE_SECRETS_PATH,
1465 crate::defaults::VAULT_SECRETS_PATH
1466 )));
1467 }
1468 secrets_backend_from_vault_answers(load_secrets_answers(store, env)?.as_ref())
1469}
1470
1471pub(crate) fn secrets_backend_is_dev_store(env: &greentic_deploy_spec::Environment) -> bool {
1479 match env.pack_for_slot(CapabilitySlot::Secrets) {
1480 None => true,
1481 Some(binding) => binding.kind.path() == crate::defaults::DEV_STORE_SECRETS_PATH,
1482 }
1483}
1484
1485pub(crate) fn secrets_backend_from_vault_answers(
1490 answers: Option<&Value>,
1491) -> Result<crate::env_packs::k8s::manifests::SecretsBackend, OpError> {
1492 use crate::env_packs::k8s::manifests::{
1493 SecretsBackend, VAULT_DEFAULT_AUTH_MOUNT, VAULT_DEFAULT_KV_MOUNT, VAULT_DEFAULT_KV_PREFIX,
1494 VAULT_DEFAULT_TRANSIT_KEY, VAULT_DEFAULT_TRANSIT_MOUNT, VaultBackend,
1495 };
1496 let empty = serde_json::Map::new();
1497 let obj = match answers {
1498 Some(v) => v.as_object().ok_or_else(|| {
1499 OpError::Conflict("vault secrets binding answers must be a JSON object".to_string())
1500 })?,
1501 None => &empty,
1502 };
1503 let required = |key: &str| -> Result<String, OpError> {
1504 obj.get(key)
1505 .and_then(Value::as_str)
1506 .map(str::trim)
1507 .filter(|s| !s.is_empty())
1508 .map(str::to_string)
1509 .ok_or_else(|| {
1510 OpError::Conflict(format!(
1511 "vault secrets backend requires a non-empty `{key}` answer"
1512 ))
1513 })
1514 };
1515 let optional = |key: &str, default: &str| -> String {
1516 obj.get(key)
1517 .and_then(Value::as_str)
1518 .map(str::trim)
1519 .filter(|s| !s.is_empty())
1520 .map(str::to_string)
1521 .unwrap_or_else(|| default.to_string())
1522 };
1523 Ok(SecretsBackend::Vault(VaultBackend {
1524 addr: required("addr")?,
1525 k8s_role: required("role")?,
1526 kv_mount: optional("kv_mount", VAULT_DEFAULT_KV_MOUNT),
1527 kv_prefix: optional("kv_prefix", VAULT_DEFAULT_KV_PREFIX),
1528 auth_mount: optional("auth_mount", VAULT_DEFAULT_AUTH_MOUNT),
1529 transit_mount: optional("transit_mount", VAULT_DEFAULT_TRANSIT_MOUNT),
1530 transit_key: optional("transit_key", VAULT_DEFAULT_TRANSIT_KEY),
1531 namespace: obj
1532 .get("namespace")
1533 .and_then(Value::as_str)
1534 .map(str::trim)
1535 .filter(|s| !s.is_empty())
1536 .map(str::to_string),
1537 }))
1538}
1539
1540fn resolve_render_kind(
1550 env: &Environment,
1551 kind: Option<&str>,
1552) -> Result<greentic_deploy_spec::PackDescriptor, OpError> {
1553 let binding = env.pack_for_slot(CapabilitySlot::Deployer);
1554 match kind {
1555 None => binding.map(|b| b.kind.clone()).ok_or_else(|| {
1556 OpError::Conflict(
1557 "env has no deployer binding; pass --kind <path>@<version>".to_string(),
1558 )
1559 }),
1560 Some(k) if k.contains('@') => greentic_deploy_spec::PackDescriptor::try_new(k)
1561 .map_err(|e| OpError::InvalidArgument(format!("--kind: {e}"))),
1562 Some(path) => match binding {
1563 Some(b) if b.kind.path() == path => Ok(b.kind.clone()),
1564 _ => Err(OpError::InvalidArgument(format!(
1565 "--kind `{path}` carries no version and does not match the env's \
1566 deployer binding; pass a full `<path>@<version>`"
1567 ))),
1568 },
1569 }
1570}
1571
1572pub(crate) fn resolve_live_deployer_kind(
1585 env: &Environment,
1586 kind: Option<&str>,
1587) -> Result<greentic_deploy_spec::PackDescriptor, OpError> {
1588 let descriptor = resolve_render_kind(env, kind)?;
1589 let bound = env.pack_for_slot(CapabilitySlot::Deployer).ok_or_else(|| {
1590 OpError::Conflict(
1591 "env has no deployer binding; a live apply must target a bound deployer".to_string(),
1592 )
1593 })?;
1594 if descriptor.path() != bound.kind.path() {
1595 return Err(OpError::Conflict(format!(
1596 "env is bound to deployer `{}`; a live apply cannot use `{}` — it is not the env's \
1597 bound deployer (a full `--kind` may override the version, not switch deployers)",
1598 bound.kind.path(),
1599 descriptor.path()
1600 )));
1601 }
1602 Ok(descriptor)
1603}
1604
1605struct WriteResult {
1607 files: Vec<String>,
1609 removed_stale_files: Vec<String>,
1612 unmanaged_files: Vec<String>,
1615}
1616
1617fn write_rendered_objects(
1626 dir: &std::path::Path,
1627 objects: &[Value],
1628) -> Result<WriteResult, OpError> {
1629 let io_err = |source: std::io::Error| OpError::Io {
1630 path: dir.to_path_buf(),
1631 source,
1632 };
1633
1634 let mut pairs: Vec<(String, String)> = Vec::with_capacity(objects.len());
1636 for (index, object) in objects.iter().enumerate() {
1637 let file_name = rendered_object_file_name(index, object);
1638 let yaml = serde_yaml_bw::to_string(object).map_err(|e| OpError::Io {
1639 path: dir.join(&file_name),
1640 source: std::io::Error::other(format!("manifest YAML serialization: {e}")),
1641 })?;
1642 pairs.push((file_name, yaml));
1643 }
1644
1645 std::fs::create_dir_all(dir).map_err(io_err)?;
1647 let mut files = Vec::with_capacity(pairs.len());
1648 for (file_name, yaml) in &pairs {
1649 let path = dir.join(file_name);
1650 std::fs::write(&path, yaml).map_err(|source| OpError::Io { path, source })?;
1651 files.push(file_name.clone());
1652 }
1653
1654 let mut removed_stale_files = Vec::new();
1656 let mut unmanaged_files = Vec::new();
1657 for entry in std::fs::read_dir(dir).map_err(io_err)? {
1658 let name = entry.map_err(io_err)?.file_name();
1659 let name = name.to_string_lossy().into_owned();
1660 if files.contains(&name) {
1661 continue;
1662 }
1663 if name.ends_with(".yaml") && is_render_managed_name(&name) {
1664 let path = dir.join(&name);
1665 std::fs::remove_file(&path).map_err(|source| OpError::Io { path, source })?;
1666 removed_stale_files.push(name);
1667 } else if name.ends_with(".yaml") {
1668 unmanaged_files.push(name);
1669 }
1670 }
1672 removed_stale_files.sort();
1673 unmanaged_files.sort();
1674
1675 Ok(WriteResult {
1676 files,
1677 removed_stale_files,
1678 unmanaged_files,
1679 })
1680}
1681
1682fn is_render_managed_name(name: &str) -> bool {
1687 let bytes = name.as_bytes();
1688 if !name.ends_with(".yaml") {
1690 return false;
1691 }
1692 let digit_end = bytes.iter().position(|b| !b.is_ascii_digit()).unwrap_or(0);
1694 digit_end > 0 && bytes.get(digit_end) == Some(&b'-')
1696}
1697
1698fn rendered_object_file_name(index: usize, object: &Value) -> String {
1701 let sanitize = |s: &str| -> String {
1702 s.to_ascii_lowercase()
1703 .chars()
1704 .map(|c| {
1705 if c.is_ascii_lowercase() || c.is_ascii_digit() || c == '.' || c == '-' {
1706 c
1707 } else {
1708 '-'
1709 }
1710 })
1711 .collect()
1712 };
1713 let kind = object
1714 .get("kind")
1715 .and_then(Value::as_str)
1716 .unwrap_or("object");
1717 let name = object
1718 .pointer("/metadata/name")
1719 .and_then(Value::as_str)
1720 .unwrap_or("unnamed");
1721 format!("{index:02}-{}-{}.yaml", sanitize(kind), sanitize(name))
1722}
1723
1724pub fn init(
1736 store: &LocalFsStore,
1737 flags: &OpFlags,
1738 payload: EnvInitPayload,
1739) -> Result<OpOutcome, OpError> {
1740 if flags.schema_only {
1741 return Ok(OpOutcome::new(
1742 NOUN,
1743 "init",
1744 json!({ "input_schema": "optional --public-url" }),
1745 ));
1746 }
1747 let env_id = EnvId::try_from(crate::defaults::LOCAL_ENV_ID).map_err(|e| {
1748 OpError::InvalidArgument(format!(
1749 "default env id `{}`: {}",
1750 crate::defaults::LOCAL_ENV_ID,
1751 e
1752 ))
1753 })?;
1754 let validated_public_base_url = parse_optional_public_base_url(&payload.public_base_url)?;
1759 let ctx = AuditCtx {
1760 env_id: env_id.clone(),
1761 noun: NOUN,
1762 verb: "init",
1763 target: json!({
1764 "environment_id": env_id.as_str(),
1765 "public_base_url_applied": validated_public_base_url.is_some(),
1766 }),
1767 idempotency_key: None,
1768 };
1769 audit_and_record(store, ctx, |committed| {
1770 let (env, outcome) =
1771 super::bootstrap::ensure_local_environment(store, validated_public_base_url.clone())?;
1772 committed.mark_committed();
1777 let trust_root_seed = store
1780 .seed_trust_root_if_absent(&env.environment_id)
1781 .map_err(super::map_store_err_preserving_noun)?;
1782 let trust_root = super::trust_root::trust_root_seed_to_wire_opt(
1783 &env.environment_id,
1784 trust_root_seed.as_ref(),
1785 );
1786 let bound_slots: Vec<String> = env.packs.iter().map(|b| b.slot.to_string()).collect();
1787 let mut payload = json!({
1788 "environment_id": env.environment_id.as_str(),
1789 "bound_slots": bound_slots,
1790 "pack_count": env.packs.len(),
1791 "public_base_url": env.host_config.public_base_url,
1792 "trust_root": trust_root,
1793 });
1794 let payload_obj = payload
1795 .as_object_mut()
1796 .expect("payload constructed as object");
1797 match outcome {
1798 super::bootstrap::LocalEnvOutcome::Created => {
1799 payload_obj.insert("outcome".into(), json!("created"));
1800 }
1801 super::bootstrap::LocalEnvOutcome::AlreadyExists => {
1802 payload_obj.insert("outcome".into(), json!("untouched"));
1803 }
1804 super::bootstrap::LocalEnvOutcome::Healed { added_slots } => {
1805 payload_obj.insert("outcome".into(), json!("healed"));
1806 payload_obj.insert(
1807 "added_slots".into(),
1808 json!(
1809 added_slots
1810 .iter()
1811 .map(ToString::to_string)
1812 .collect::<Vec<_>>()
1813 ),
1814 );
1815 }
1816 }
1817 let outcome = OpOutcome::new(NOUN, "init", payload);
1818 Ok((outcome, super::AuditGens::NONE))
1819 })
1820}
1821
1822pub fn destroy(
1828 store: &LocalFsStore,
1829 flags: &OpFlags,
1830 env_id: &str,
1831 confirm: bool,
1832) -> Result<OpOutcome, OpError> {
1833 if flags.schema_only {
1834 return Ok(OpOutcome::new(
1835 NOUN,
1836 "destroy",
1837 json!({ "input_schema": "env_id positional + confirm flag" }),
1838 ));
1839 }
1840 if !confirm {
1841 return Err(OpError::InvalidArgument(
1842 "destroy requires --confirm".to_string(),
1843 ));
1844 }
1845 let env_id =
1846 EnvId::try_from(env_id).map_err(|e| OpError::InvalidArgument(format!("env_id: {e}")))?;
1847 let ctx = AuditCtx {
1848 env_id: env_id.clone(),
1849 noun: NOUN,
1850 verb: "destroy",
1851 target: json!({"environment_id": env_id.as_str(), "confirm": confirm}),
1852 idempotency_key: None,
1853 };
1854 audit_and_record(store, ctx, |_committed| {
1855 if !store.exists(&env_id)? {
1856 return Err(OpError::NotFound(format!("environment `{env_id}`")));
1857 }
1858 Err(OpError::NotYetImplemented(
1862 "`op env destroy` requires the retention path (B-phase); use the LocalFsStore root path returned by `op env show` for manual cleanup".to_string(),
1863 ))
1864 })
1865}
1866
1867fn resolve_payload<T: serde::de::DeserializeOwned>(
1868 flags: &OpFlags,
1869 payload: Option<T>,
1870) -> Result<T, OpError> {
1871 if let Some(p) = payload {
1872 return Ok(p);
1873 }
1874 if let Some(path) = &flags.answers {
1875 return super::load_answers::<T>(path);
1876 }
1877 Err(OpError::InvalidArgument(
1878 "no payload provided: pass --answers <path> or supply the payload directly".to_string(),
1879 ))
1880}
1881
1882fn schema_outcome(op: &'static str) -> Result<OpOutcome, OpError> {
1883 Ok(OpOutcome::new(NOUN, op, env_create_payload_schema()))
1884}
1885
1886pub fn env_create_payload_schema() -> Value {
1890 json!({
1891 "$schema": "https://json-schema.org/draft/2020-12/schema",
1892 "title": "EnvCreatePayload",
1893 "type": "object",
1894 "required": ["environment_id", "name"],
1895 "additionalProperties": false,
1896 "properties": {
1897 "environment_id": {"type": "string", "description": "EnvId — kebab-friendly env identifier."},
1898 "name": {"type": "string"},
1899 "region": {"type": ["string", "null"]},
1900 "tenant_org_id": {"type": ["string", "null"]},
1901 "listen_addr": {"type": ["string", "null"]},
1902 "public_base_url": {"type": ["string", "null"], "description": "origin-only URL (https://host[:port])"}
1903 }
1904 })
1905}
1906
1907#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1911pub struct EnvInitPayload {
1912 #[serde(default, skip_serializing_if = "Option::is_none")]
1913 pub public_base_url: Option<String>,
1914}
1915
1916impl super::dispatch::EnvInitArgs {
1917 pub fn into_payload(self, _flags: &OpFlags) -> Result<EnvInitPayload, OpError> {
1921 Ok(EnvInitPayload {
1922 public_base_url: self.public_url,
1923 })
1924 }
1925}
1926
1927pub(crate) fn parse_public_base_url(raw: &str) -> Result<String, OpError> {
1933 validate_public_base_url(raw)
1934 .map_err(|e| OpError::InvalidArgument(format!("public_base_url: {e}")))
1935}
1936
1937pub(crate) fn parse_optional_public_base_url(
1943 raw: &Option<String>,
1944) -> Result<Option<String>, OpError> {
1945 raw.as_deref().map(parse_public_base_url).transpose()
1946}
1947
1948fn reject_inline_plus_answers(
1957 has_inline: bool,
1958 flags: &OpFlags,
1959 verb: &'static str,
1960) -> Result<(), OpError> {
1961 if has_inline && flags.answers.is_some() {
1962 return Err(OpError::InvalidArgument(format!(
1963 "env {verb}: inline flags and --answers are mutually exclusive; use one or the other"
1964 )));
1965 }
1966 Ok(())
1967}
1968
1969fn partial_inline_error(verb: &'static str, missing: &[&str]) -> OpError {
1970 OpError::InvalidArgument(format!(
1971 "env {verb}: inline-flag form requires --environment-id and --name; missing: {}",
1972 missing.join(", ")
1973 ))
1974}
1975
1976fn require_inline_env_id_and_name(
1982 has_inline: bool,
1983 env_id: Option<String>,
1984 name: Option<String>,
1985 verb: &'static str,
1986 flags: &OpFlags,
1987) -> Result<Option<(String, String)>, OpError> {
1988 reject_inline_plus_answers(has_inline, flags, verb)?;
1989 if !has_inline {
1990 return Ok(None);
1991 }
1992 let mut missing: Vec<&'static str> = Vec::new();
1993 if env_id.is_none() {
1994 missing.push("--environment-id");
1995 }
1996 if name.is_none() {
1997 missing.push("--name");
1998 }
1999 if !missing.is_empty() {
2000 return Err(partial_inline_error(verb, &missing));
2001 }
2002 Ok(Some((
2003 env_id.expect("checked above"),
2004 name.expect("checked above"),
2005 )))
2006}
2007
2008impl super::dispatch::EnvCreateArgs {
2009 fn has_inline_input(&self) -> bool {
2010 self.environment_id.is_some()
2011 || self.name.is_some()
2012 || self.region.is_some()
2013 || self.tenant_org_id.is_some()
2014 || self.listen_addr.is_some()
2015 || self.public_url.is_some()
2016 }
2017
2018 pub fn into_payload(
2029 self,
2030 verb: &'static str,
2031 flags: &OpFlags,
2032 ) -> Result<Option<EnvCreatePayload>, OpError> {
2033 let has_inline = self.has_inline_input();
2034 let Some((environment_id, name)) = require_inline_env_id_and_name(
2035 has_inline,
2036 self.environment_id,
2037 self.name,
2038 verb,
2039 flags,
2040 )?
2041 else {
2042 return Ok(None);
2043 };
2044 Ok(Some(EnvCreatePayload {
2045 environment_id,
2046 name,
2047 region: self.region,
2048 tenant_org_id: self.tenant_org_id,
2049 listen_addr: self.listen_addr,
2050 public_base_url: self.public_url,
2051 }))
2052 }
2053}
2054
2055impl super::dispatch::EnvUpdateArgs {
2056 fn has_inline_input(&self) -> bool {
2057 self.environment_id.is_some()
2058 || self.name.is_some()
2059 || self.region.is_some()
2060 || self.tenant_org_id.is_some()
2061 }
2062
2063 pub fn into_payload(
2077 self,
2078 verb: &'static str,
2079 flags: &OpFlags,
2080 ) -> Result<Option<EnvCreatePayload>, OpError> {
2081 let has_inline = self.has_inline_input();
2082 let Some((environment_id, name)) = require_inline_env_id_and_name(
2083 has_inline,
2084 self.environment_id,
2085 self.name,
2086 verb,
2087 flags,
2088 )?
2089 else {
2090 return Ok(None);
2091 };
2092 Ok(Some(EnvCreatePayload {
2093 environment_id,
2094 name,
2095 region: self.region,
2096 tenant_org_id: self.tenant_org_id,
2097 listen_addr: None,
2098 public_base_url: None,
2099 }))
2100 }
2101}
2102
2103pub fn set_public_url(
2108 store: &LocalFsStore,
2109 flags: &OpFlags,
2110 env_id: &str,
2111 url: &str,
2112) -> Result<OpOutcome, OpError> {
2113 if flags.schema_only {
2114 return Ok(OpOutcome::new(
2115 NOUN,
2116 "set-public-url",
2117 json!({ "input_schema": "<env_id> <url> positional" }),
2118 ));
2119 }
2120 let env_id =
2121 EnvId::try_from(env_id).map_err(|e| OpError::InvalidArgument(format!("env_id: {e}")))?;
2122 let validated = parse_public_base_url(url)?;
2123 let ctx = AuditCtx {
2124 env_id: env_id.clone(),
2125 noun: NOUN,
2126 verb: "set-public-url",
2127 target: json!({"environment_id": env_id.as_str()}),
2128 idempotency_key: None,
2129 };
2130 audit_and_record(store, ctx, |_committed| {
2131 let env = store
2132 .update_environment(
2133 &env_id,
2134 UpdateEnvironmentPayload {
2135 public_base_url: FieldUpdate::Set(validated),
2136 ..Default::default()
2137 },
2138 )
2139 .map_err(map_store_err_preserving_noun)?;
2140 let outcome = OpOutcome::new(
2141 NOUN,
2142 "set-public-url",
2143 json!({
2144 "environment_id": env_id.as_str(),
2145 "host_config": env.host_config,
2146 }),
2147 );
2148 Ok((outcome, super::AuditGens::NONE))
2149 })
2150}
2151
2152#[cfg(test)]
2153mod tests {
2154 use super::*;
2155 use crate::cli::tests_common::make_env;
2156 use crate::environment::LocalFsStore;
2157 use tempfile::tempdir;
2158
2159 #[test]
2160 fn create_then_show_roundtrip() {
2161 let dir = tempdir().unwrap();
2162 let store = LocalFsStore::new(dir.path());
2163 let flags = OpFlags::default();
2164 let outcome = create(
2165 &store,
2166 &flags,
2167 Some(EnvCreatePayload {
2168 environment_id: "local".to_string(),
2169 name: "local".to_string(),
2170 region: None,
2171 tenant_org_id: None,
2172 listen_addr: None,
2173 public_base_url: None,
2174 }),
2175 )
2176 .unwrap();
2177 assert_eq!(outcome.op, "create");
2178 assert_eq!(outcome.noun, "env");
2179 let show_outcome = show(&store, &flags, "local").unwrap();
2180 assert_eq!(show_outcome.op, "show");
2181 let env_val = show_outcome
2182 .result
2183 .get("environment")
2184 .expect("environment field");
2185 assert_eq!(env_val.get("name").and_then(|v| v.as_str()), Some("local"));
2186 }
2187
2188 #[test]
2189 fn create_rejects_duplicate() {
2190 let dir = tempdir().unwrap();
2191 let store = LocalFsStore::new(dir.path());
2192 let env = make_env("local");
2193 store.save(&env).unwrap();
2194 let err = create(
2195 &store,
2196 &OpFlags::default(),
2197 Some(EnvCreatePayload {
2198 environment_id: "local".to_string(),
2199 name: "again".to_string(),
2200 region: None,
2201 tenant_org_id: None,
2202 listen_addr: None,
2203 public_base_url: None,
2204 }),
2205 )
2206 .unwrap_err();
2207 assert!(matches!(err, OpError::Conflict(_)), "got {err:?}");
2208 }
2209
2210 #[test]
2211 fn update_rewrites_name_and_region() {
2212 let dir = tempdir().unwrap();
2213 let store = LocalFsStore::new(dir.path());
2214 let env = make_env("local");
2215 store.save(&env).unwrap();
2216 let outcome = update(
2217 &store,
2218 &OpFlags::default(),
2219 Some(EnvCreatePayload {
2220 environment_id: "local".to_string(),
2221 name: "renamed".to_string(),
2222 region: Some("eu-west-1".to_string()),
2223 tenant_org_id: None,
2224 listen_addr: None,
2225 public_base_url: None,
2226 }),
2227 )
2228 .unwrap();
2229 assert_eq!(
2230 outcome.result.get("name").and_then(|v| v.as_str()),
2231 Some("renamed")
2232 );
2233 let env = store.load(&EnvId::try_from("local").unwrap()).unwrap();
2234 assert_eq!(env.name, "renamed");
2235 assert_eq!(env.host_config.region.as_deref(), Some("eu-west-1"));
2236 }
2237
2238 #[test]
2239 fn create_persists_explicit_listen_addr_and_surfaces_it_in_summary() {
2240 use std::net::{IpAddr, Ipv4Addr, SocketAddr};
2241 let dir = tempdir().unwrap();
2242 let store = LocalFsStore::new(dir.path());
2243 let outcome = create(
2244 &store,
2245 &OpFlags::default(),
2246 Some(EnvCreatePayload {
2247 environment_id: "local".to_string(),
2248 name: "local".to_string(),
2249 region: None,
2250 tenant_org_id: None,
2251 listen_addr: Some("0.0.0.0:9090".to_string()),
2252 public_base_url: None,
2253 }),
2254 )
2255 .unwrap();
2256 let listen = outcome
2259 .result
2260 .get("listen_addr")
2261 .and_then(|v| v.as_str())
2262 .expect("EnvSummary must expose listen_addr");
2263 assert_eq!(listen, "0.0.0.0:9090");
2264 let env = store.load(&EnvId::try_from("local").unwrap()).unwrap();
2266 let expected = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(0, 0, 0, 0)), 9090);
2267 assert_eq!(env.host_config.listen_addr, Some(expected));
2268 }
2269
2270 #[test]
2271 fn create_rejects_malformed_listen_addr_before_touching_store() {
2272 let dir = tempdir().unwrap();
2273 let store = LocalFsStore::new(dir.path());
2274 let err = create(
2275 &store,
2276 &OpFlags::default(),
2277 Some(EnvCreatePayload {
2278 environment_id: "local".to_string(),
2279 name: "local".to_string(),
2280 region: None,
2281 tenant_org_id: None,
2282 listen_addr: Some("not-a-socket-addr".to_string()),
2283 public_base_url: None,
2284 }),
2285 )
2286 .expect_err("malformed listen_addr must be rejected");
2287 let msg = err.to_string();
2288 assert!(
2289 msg.contains("listen_addr") && msg.contains("not-a-socket-addr"),
2290 "error must name the offending field + value, got: {msg}"
2291 );
2292 assert!(store.list().unwrap().is_empty());
2294 }
2295
2296 #[test]
2297 fn create_preserves_corrupt_environment_json_instead_of_overwriting() {
2298 let dir = tempdir().unwrap();
2303 let store = LocalFsStore::new(dir.path());
2304 let env_dir = dir.path().join("local");
2305 std::fs::create_dir_all(&env_dir).unwrap();
2306 let corrupt_path = env_dir.join("environment.json");
2307 let corrupt_bytes = b"{ this is not valid json";
2308 std::fs::write(&corrupt_path, corrupt_bytes).unwrap();
2309 let err = create(
2310 &store,
2311 &OpFlags::default(),
2312 Some(EnvCreatePayload {
2313 environment_id: "local".to_string(),
2314 name: "local".to_string(),
2315 region: None,
2316 tenant_org_id: None,
2317 listen_addr: None,
2318 public_base_url: None,
2319 }),
2320 )
2321 .expect_err("create over a corrupt environment.json must fail");
2322 assert!(
2325 !matches!(err, OpError::Conflict(_)),
2326 "expected store/json error, got Conflict (overwrote the file?): {err:?}"
2327 );
2328 let on_disk = std::fs::read(&corrupt_path).unwrap();
2330 assert_eq!(
2331 on_disk, corrupt_bytes,
2332 "create must not have rewritten environment.json"
2333 );
2334 }
2335
2336 #[test]
2337 fn create_with_no_listen_addr_persists_none_so_runtime_falls_back_to_default() {
2338 let dir = tempdir().unwrap();
2339 let store = LocalFsStore::new(dir.path());
2340 create(
2341 &store,
2342 &OpFlags::default(),
2343 Some(EnvCreatePayload {
2344 environment_id: "local".to_string(),
2345 name: "local".to_string(),
2346 region: None,
2347 tenant_org_id: None,
2348 listen_addr: None,
2349 public_base_url: None,
2350 }),
2351 )
2352 .unwrap();
2353 let env = store.load(&EnvId::try_from("local").unwrap()).unwrap();
2354 assert_eq!(env.host_config.listen_addr, None);
2358 assert_eq!(
2359 env.host_config.resolved_listen_addr(),
2360 greentic_deploy_spec::DEFAULT_LISTEN_ADDR,
2361 );
2362 }
2363
2364 #[test]
2365 fn update_preserves_existing_fields_when_payload_omits_them() {
2366 let dir = tempdir().unwrap();
2372 let store = LocalFsStore::new(dir.path());
2373 let mut env = make_env("local");
2374 env.host_config.region = Some("eu-west-1".to_string());
2375 env.host_config.tenant_org_id = Some("acme".to_string());
2376 env.host_config.public_base_url = Some("https://existing.example.com".to_string());
2377 store.save(&env).unwrap();
2378 update(
2379 &store,
2380 &OpFlags::default(),
2381 Some(EnvCreatePayload {
2382 environment_id: "local".to_string(),
2383 name: "renamed".to_string(),
2384 region: None,
2385 tenant_org_id: None,
2386 listen_addr: None,
2387 public_base_url: None,
2388 }),
2389 )
2390 .unwrap();
2391 let env = store.load(&EnvId::try_from("local").unwrap()).unwrap();
2392 assert_eq!(env.name, "renamed");
2393 assert_eq!(
2394 env.host_config.region.as_deref(),
2395 Some("eu-west-1"),
2396 "region must NOT be wiped when payload omits it"
2397 );
2398 assert_eq!(
2399 env.host_config.tenant_org_id.as_deref(),
2400 Some("acme"),
2401 "tenant_org_id must NOT be wiped when payload omits it"
2402 );
2403 assert_eq!(
2404 env.host_config.public_base_url.as_deref(),
2405 Some("https://existing.example.com"),
2406 "public_base_url must NOT be wiped when payload omits it"
2407 );
2408 }
2409
2410 #[test]
2411 fn update_environment_clear_resets_optional_fields_to_none() {
2412 let dir = tempdir().unwrap();
2416 let store = LocalFsStore::new(dir.path());
2417 let mut env = make_env("local");
2418 env.host_config.region = Some("eu-west-1".to_string());
2419 env.host_config.tenant_org_id = Some("acme".to_string());
2420 env.host_config.listen_addr = Some("0.0.0.0:9090".parse().unwrap());
2421 env.host_config.public_base_url = Some("https://example.com".to_string());
2422 store.save(&env).unwrap();
2423
2424 let env_id = EnvId::try_from("local").unwrap();
2425 let env = store
2426 .update_environment(
2427 &env_id,
2428 UpdateEnvironmentPayload {
2429 name: None,
2430 region: FieldUpdate::Clear,
2431 tenant_org_id: FieldUpdate::Clear,
2432 listen_addr: FieldUpdate::Clear,
2433 public_base_url: FieldUpdate::Clear,
2434 gui_enabled: FieldUpdate::Keep,
2435 },
2436 )
2437 .unwrap();
2438
2439 assert_eq!(env.host_config.region, None, "region must be cleared");
2440 assert_eq!(
2441 env.host_config.tenant_org_id, None,
2442 "tenant_org_id must be cleared"
2443 );
2444 assert_eq!(
2445 env.host_config.listen_addr, None,
2446 "listen_addr must be cleared"
2447 );
2448 assert_eq!(
2449 env.host_config.public_base_url, None,
2450 "public_base_url must be cleared"
2451 );
2452 assert_eq!(env.name, "local");
2454 }
2455
2456 #[test]
2457 fn update_environment_keep_preserves_set_does_not_collide_with_clear() {
2458 let dir = tempdir().unwrap();
2461 let store = LocalFsStore::new(dir.path());
2462 let mut env = make_env("local");
2463 env.host_config.region = Some("us-east-1".to_string());
2464 env.host_config.tenant_org_id = Some("old-org".to_string());
2465 env.host_config.listen_addr = Some("127.0.0.1:8080".parse().unwrap());
2466 env.host_config.public_base_url = Some("https://old.example.com".to_string());
2467 store.save(&env).unwrap();
2468
2469 let env_id = EnvId::try_from("local").unwrap();
2470 let env = store
2471 .update_environment(
2472 &env_id,
2473 UpdateEnvironmentPayload {
2474 name: Some("renamed".to_string()),
2475 region: FieldUpdate::Keep, tenant_org_id: FieldUpdate::Set("new-org".to_string()), listen_addr: FieldUpdate::Clear, public_base_url: FieldUpdate::Set("https://new.example.com".to_string()),
2479 gui_enabled: FieldUpdate::Keep,
2480 },
2481 )
2482 .unwrap();
2483
2484 assert_eq!(env.name, "renamed");
2485 assert_eq!(
2486 env.host_config.region.as_deref(),
2487 Some("us-east-1"),
2488 "Keep must preserve existing value"
2489 );
2490 assert_eq!(
2491 env.host_config.tenant_org_id.as_deref(),
2492 Some("new-org"),
2493 "Set must overwrite"
2494 );
2495 assert_eq!(
2496 env.host_config.listen_addr, None,
2497 "Clear must reset to None"
2498 );
2499 assert_eq!(
2500 env.host_config.public_base_url.as_deref(),
2501 Some("https://new.example.com"),
2502 "Set must overwrite"
2503 );
2504 }
2505
2506 #[test]
2507 fn update_environment_clear_on_already_none_is_idempotent() {
2508 let dir = tempdir().unwrap();
2511 let store = LocalFsStore::new(dir.path());
2512 let env = make_env("local");
2513 assert!(env.host_config.region.is_none());
2515 store.save(&env).unwrap();
2516
2517 let env_id = EnvId::try_from("local").unwrap();
2518 let env = store
2519 .update_environment(
2520 &env_id,
2521 UpdateEnvironmentPayload {
2522 name: None,
2523 region: FieldUpdate::Clear,
2524 tenant_org_id: FieldUpdate::Clear,
2525 listen_addr: FieldUpdate::Clear,
2526 public_base_url: FieldUpdate::Clear,
2527 gui_enabled: FieldUpdate::Keep,
2528 },
2529 )
2530 .unwrap();
2531
2532 assert_eq!(env.host_config.region, None);
2533 assert_eq!(env.host_config.tenant_org_id, None);
2534 assert_eq!(env.host_config.listen_addr, None);
2535 assert_eq!(env.host_config.public_base_url, None);
2536 }
2537
2538 #[test]
2539 fn update_environment_default_payload_is_all_keep_noop() {
2540 let dir = tempdir().unwrap();
2543 let store = LocalFsStore::new(dir.path());
2544 let mut env = make_env("local");
2545 env.host_config.region = Some("eu-west-1".to_string());
2546 env.host_config.tenant_org_id = Some("acme".to_string());
2547 env.host_config.listen_addr = Some("0.0.0.0:9090".parse().unwrap());
2548 env.host_config.public_base_url = Some("https://example.com".to_string());
2549 store.save(&env).unwrap();
2550
2551 let env_id = EnvId::try_from("local").unwrap();
2552 let env = store
2553 .update_environment(&env_id, UpdateEnvironmentPayload::default())
2554 .unwrap();
2555
2556 assert_eq!(env.host_config.region.as_deref(), Some("eu-west-1"));
2557 assert_eq!(env.host_config.tenant_org_id.as_deref(), Some("acme"));
2558 assert_eq!(
2559 env.host_config.listen_addr,
2560 Some("0.0.0.0:9090".parse().unwrap())
2561 );
2562 assert_eq!(
2563 env.host_config.public_base_url.as_deref(),
2564 Some("https://example.com")
2565 );
2566 }
2567
2568 #[test]
2569 fn update_rejects_missing_env() {
2570 let dir = tempdir().unwrap();
2571 let store = LocalFsStore::new(dir.path());
2572 let err = update(
2575 &store,
2576 &OpFlags::default(),
2577 Some(EnvCreatePayload {
2578 environment_id: "local".to_string(),
2579 name: "x".to_string(),
2580 region: None,
2581 tenant_org_id: None,
2582 listen_addr: None,
2583 public_base_url: None,
2584 }),
2585 )
2586 .unwrap_err();
2587 assert!(matches!(err, OpError::NotFound(_)), "got {err:?}");
2588 }
2589
2590 #[test]
2591 fn list_returns_sorted_envs() {
2592 let dir = tempdir().unwrap();
2593 let store = LocalFsStore::new(dir.path());
2594 store.save(&make_env("alpha")).unwrap();
2595 store.save(&make_env("beta")).unwrap();
2596 store.save(&make_env("gamma")).unwrap();
2597 let outcome = list(&store, &OpFlags::default()).unwrap();
2598 let envs = outcome
2599 .result
2600 .get("environments")
2601 .and_then(|v| v.as_array())
2602 .expect("environments array");
2603 let names: Vec<&str> = envs
2604 .iter()
2605 .filter_map(|e| e.get("environment_id").and_then(|v| v.as_str()))
2606 .collect();
2607 assert_eq!(names, vec!["alpha", "beta", "gamma"]);
2608 }
2609
2610 #[test]
2611 fn init_creates_local_env_when_missing() {
2612 let dir = tempdir().unwrap();
2613 let store = LocalFsStore::new(dir.path());
2614 let outcome = init(&store, &OpFlags::default(), EnvInitPayload::default()).unwrap();
2615 assert_eq!(outcome.op, "init");
2616 assert_eq!(outcome.noun, "env");
2617 assert_eq!(
2618 outcome.result.get("outcome").and_then(|v| v.as_str()),
2619 Some("created")
2620 );
2621 assert_eq!(
2622 outcome.result.get("pack_count").and_then(|v| v.as_u64()),
2623 Some(5)
2624 );
2625 assert!(outcome.result.get("added_slots").is_none());
2627 let env = store.load(&EnvId::try_from("local").unwrap()).unwrap();
2628 assert_eq!(env.packs.len(), 5);
2629 }
2630
2631 #[test]
2632 fn init_heals_partially_bound_env() {
2633 use crate::defaults::LOCAL_DEPLOYER_PACK;
2634 use greentic_deploy_spec::{CapabilitySlot, EnvPackBinding, PackDescriptor, PackId};
2635
2636 let dir = tempdir().unwrap();
2637 let store = LocalFsStore::new(dir.path());
2638 let mut env = make_env("local");
2641 env.packs = vec![EnvPackBinding {
2642 slot: CapabilitySlot::Deployer,
2643 kind: PackDescriptor::try_new(LOCAL_DEPLOYER_PACK).unwrap(),
2644 pack_ref: PackId::new(LOCAL_DEPLOYER_PACK),
2645 answers_ref: None,
2646 generation: 0,
2647 previous_binding_ref: None,
2648 }];
2649 store.save(&env).unwrap();
2650
2651 let outcome = init(&store, &OpFlags::default(), EnvInitPayload::default()).unwrap();
2652 assert_eq!(
2653 outcome.result.get("outcome").and_then(|v| v.as_str()),
2654 Some("healed")
2655 );
2656 let added: Vec<String> = outcome
2657 .result
2658 .get("added_slots")
2659 .and_then(|v| v.as_array())
2660 .expect("added_slots present on healed")
2661 .iter()
2662 .filter_map(|v| v.as_str().map(String::from))
2663 .collect();
2664 assert_eq!(
2665 added,
2666 vec!["secrets", "telemetry", "sessions", "state"],
2667 "only the 4 missing slots are reported as added"
2668 );
2669 let env = store.load(&EnvId::try_from("local").unwrap()).unwrap();
2670 assert_eq!(env.packs.len(), 5);
2671 }
2672
2673 #[test]
2674 fn init_is_idempotent_and_reports_untouched_on_second_call() {
2675 let dir = tempdir().unwrap();
2676 let store = LocalFsStore::new(dir.path());
2677 init(&store, &OpFlags::default(), EnvInitPayload::default()).unwrap();
2678 let outcome = init(&store, &OpFlags::default(), EnvInitPayload::default()).unwrap();
2679 assert_eq!(
2680 outcome.result.get("outcome").and_then(|v| v.as_str()),
2681 Some("untouched")
2682 );
2683 assert!(outcome.result.get("added_slots").is_none());
2684 }
2685
2686 #[test]
2687 fn init_seeds_operator_key_into_env_trust_root_on_first_run() {
2688 let dir = tempdir().unwrap();
2693 let store = LocalFsStore::new(dir.path());
2694 let outcome = init(&store, &OpFlags::default(), EnvInitPayload::default()).unwrap();
2695 let trust_root = outcome
2696 .result
2697 .get("trust_root")
2698 .expect("init outcome carries `trust_root`");
2699 assert!(
2700 trust_root.is_object(),
2701 "first init must seed and surface the summary, got {trust_root:?}"
2702 );
2703 assert_eq!(
2704 trust_root.get("environment_id").and_then(|v| v.as_str()),
2705 Some("local")
2706 );
2707 let key_id = trust_root
2708 .get("operator_key_id")
2709 .and_then(|v| v.as_str())
2710 .expect("operator_key_id present");
2711 assert!(!key_id.is_empty(), "operator_key_id must not be empty");
2712 assert!(
2713 trust_root
2714 .get("operator_public_key_pem")
2715 .and_then(|v| v.as_str())
2716 .is_some_and(|pem| pem.starts_with("-----BEGIN PUBLIC KEY-----"))
2717 );
2718 assert_eq!(
2719 trust_root.get("trusted_key_count").and_then(|v| v.as_u64()),
2720 Some(1),
2721 "first init seeds exactly one operator key"
2722 );
2723
2724 let listed = super::super::trust_root::list(&store, &OpFlags::default(), "local").unwrap();
2727 let keys = listed.result["keys"].as_array().expect("keys array");
2728 assert_eq!(keys.len(), 1);
2729 assert_eq!(keys[0]["key_id"].as_str(), Some(key_id));
2730 }
2731
2732 #[test]
2733 fn second_init_does_not_re_touch_trust_root() {
2734 let dir = tempdir().unwrap();
2738 let store = LocalFsStore::new(dir.path());
2739 let first = init(&store, &OpFlags::default(), EnvInitPayload::default()).unwrap();
2740 let first_key_id = first.result["trust_root"]["operator_key_id"]
2741 .as_str()
2742 .expect("first init seeded a key")
2743 .to_string();
2744 let second = init(&store, &OpFlags::default(), EnvInitPayload::default()).unwrap();
2745 let tr = second
2749 .result
2750 .as_object()
2751 .expect("outcome is a JSON object")
2752 .get("trust_root");
2753 assert!(
2754 tr.is_some_and(|v| v.is_null()),
2755 "second init must report `trust_root: null` (got {tr:?})"
2756 );
2757 let listed = super::super::trust_root::list(&store, &OpFlags::default(), "local").unwrap();
2758 let keys = listed.result["keys"].as_array().unwrap();
2759 assert_eq!(keys.len(), 1, "second init must not duplicate the key");
2760 assert_eq!(keys[0]["key_id"].as_str(), Some(first_key_id.as_str()));
2761 }
2762
2763 #[test]
2764 fn init_does_not_re_seed_after_operator_key_was_removed() {
2765 let dir = tempdir().unwrap();
2772 let store = LocalFsStore::new(dir.path());
2773
2774 let first = init(&store, &OpFlags::default(), EnvInitPayload::default()).unwrap();
2776 let key_id = first.result["trust_root"]["operator_key_id"]
2777 .as_str()
2778 .expect("first init seeded a key")
2779 .to_string();
2780
2781 super::super::trust_root::remove(
2783 &store,
2784 &OpFlags::default(),
2785 Some(super::super::trust_root::TrustRootRemovePayload {
2786 environment_id: "local".into(),
2787 key_id: key_id.clone(),
2788 }),
2789 )
2790 .unwrap();
2791 let listed = super::super::trust_root::list(&store, &OpFlags::default(), "local").unwrap();
2792 assert_eq!(
2793 listed.result["keys"].as_array().unwrap().len(),
2794 0,
2795 "precondition: remove must clear the trust root"
2796 );
2797
2798 let second = init(&store, &OpFlags::default(), EnvInitPayload::default()).unwrap();
2801 let tr = second
2802 .result
2803 .as_object()
2804 .expect("outcome is a JSON object")
2805 .get("trust_root");
2806 assert!(
2807 tr.is_some_and(|v| v.is_null()),
2808 "init must not re-grant trust on a revoked key (got {tr:?})"
2809 );
2810 let listed = super::super::trust_root::list(&store, &OpFlags::default(), "local").unwrap();
2811 assert_eq!(
2812 listed.result["keys"].as_array().unwrap().len(),
2813 0,
2814 "revoked key must STAY absent across subsequent init runs"
2815 );
2816 }
2817
2818 #[test]
2819 fn doctor_reports_missing_slots() {
2820 let dir = tempdir().unwrap();
2821 let store = LocalFsStore::new(dir.path());
2822 store.save(&make_env("local")).unwrap();
2823 let outcome = doctor(&store, &OpFlags::default(), "local").unwrap();
2824 let missing = outcome
2825 .result
2826 .get("missing_slots")
2827 .and_then(|v| v.as_array())
2828 .expect("missing_slots array");
2829 let core_slots = greentic_deploy_spec::CapabilitySlot::ALL
2833 .iter()
2834 .filter(|s| s.binds_in_packs())
2835 .count();
2836 assert_eq!(missing.len(), core_slots);
2837 assert!(
2838 missing
2839 .iter()
2840 .all(|s| s.as_str() != Some("messaging") && s.as_str() != Some("extension")),
2841 "N-per-env slots must not appear in missing_slots"
2842 );
2843 }
2844
2845 #[test]
2846 fn doctor_reports_extensions_separately() {
2847 let dir = tempdir().unwrap();
2848 let store = LocalFsStore::new(dir.path());
2849 let mut env = make_env("local");
2850 env.extensions.push(greentic_deploy_spec::ExtensionBinding {
2854 kind: greentic_deploy_spec::PackDescriptor::try_new("acme.oauth.auth0@1.0.0").unwrap(),
2855 pack_ref: greentic_deploy_spec::PackId::new("pack-ext"),
2856 instance_id: Some("primary".to_string()),
2857 answers_ref: None,
2858 generation: 0,
2859 previous_binding_ref: None,
2860 });
2861 store.save(&env).unwrap();
2862 let outcome = doctor(&store, &OpFlags::default(), "local").unwrap();
2863 let ext = outcome
2864 .result
2865 .get("extensions")
2866 .expect("extensions report block");
2867 assert_eq!(ext.get("count").and_then(|v| v.as_u64()), Some(1));
2868 let unknown = ext
2869 .get("unknown_kinds")
2870 .and_then(|v| v.as_array())
2871 .expect("extension unknown_kinds array");
2872 assert_eq!(unknown.len(), 1);
2873 assert!(unknown[0].as_str().unwrap().contains("acme.oauth.auth0"));
2874 let core_unknown = outcome
2877 .result
2878 .get("unknown_kinds")
2879 .and_then(|v| v.as_array())
2880 .expect("core unknown_kinds array");
2881 assert!(core_unknown.is_empty());
2882 }
2883
2884 #[test]
2885 fn doctor_flags_unknown_kind_and_slot_mismatch() {
2886 use crate::cli::tests_common::make_binding;
2887 let dir = tempdir().unwrap();
2888 let store = LocalFsStore::new(dir.path());
2889 let mut env = make_env("local");
2890 env.packs.push(make_binding(
2892 greentic_deploy_spec::CapabilitySlot::Secrets,
2893 "greentic.secrets.acme-vault@1.0.0",
2894 ));
2895 env.packs.push(make_binding(
2897 greentic_deploy_spec::CapabilitySlot::State,
2898 "greentic.deployer.local-process@0.1.0",
2899 ));
2900 store.save(&env).unwrap();
2901 let outcome = doctor(&store, &OpFlags::default(), "local").unwrap();
2902 let unknown = outcome
2903 .result
2904 .get("unknown_kinds")
2905 .and_then(|v| v.as_array())
2906 .expect("unknown_kinds array");
2907 assert_eq!(unknown.len(), 1);
2908 assert!(unknown[0].as_str().unwrap().contains("acme-vault"));
2909 let mismatches = outcome
2910 .result
2911 .get("slot_mismatches")
2912 .and_then(|v| v.as_array())
2913 .expect("slot_mismatches array");
2914 assert_eq!(mismatches.len(), 1);
2915 assert_eq!(
2916 mismatches[0].get("handler_slot").and_then(|v| v.as_str()),
2917 Some("deployer")
2918 );
2919 assert_eq!(
2920 mismatches[0].get("bound_slot").and_then(|v| v.as_str()),
2921 Some("state")
2922 );
2923 }
2924
2925 #[test]
2926 fn doctor_accepts_built_in_bindings() {
2927 use crate::cli::tests_common::make_binding;
2928 let dir = tempdir().unwrap();
2929 let store = LocalFsStore::new(dir.path());
2930 let mut env = make_env("local");
2931 env.packs.push(make_binding(
2932 greentic_deploy_spec::CapabilitySlot::Secrets,
2933 "greentic.secrets.dev-store@0.1.0",
2934 ));
2935 store.save(&env).unwrap();
2936 let outcome = doctor(&store, &OpFlags::default(), "local").unwrap();
2937 for field in ["unknown_kinds", "slot_mismatches", "version_skew"] {
2938 assert!(
2939 outcome
2940 .result
2941 .get(field)
2942 .and_then(|v| v.as_array())
2943 .unwrap()
2944 .is_empty(),
2945 "{field} should be empty for a built-in binding at its supported version"
2946 );
2947 }
2948 }
2949
2950 #[test]
2951 fn doctor_flags_unsupported_version() {
2952 use crate::cli::tests_common::make_binding;
2953 let dir = tempdir().unwrap();
2954 let store = LocalFsStore::new(dir.path());
2955 let mut env = make_env("local");
2956 env.packs.push(make_binding(
2958 greentic_deploy_spec::CapabilitySlot::Secrets,
2959 "greentic.secrets.dev-store@9.9.9",
2960 ));
2961 store.save(&env).unwrap();
2962 let outcome = doctor(&store, &OpFlags::default(), "local").unwrap();
2963 let skew = outcome
2964 .result
2965 .get("version_skew")
2966 .and_then(|v| v.as_array())
2967 .expect("version_skew array");
2968 assert_eq!(skew.len(), 1);
2969 assert_eq!(
2970 skew[0].get("requested").and_then(|v| v.as_str()),
2971 Some("9.9.9")
2972 );
2973 assert_eq!(
2974 skew[0].get("supported").and_then(|v| v.as_str()),
2975 Some("^0.1.0")
2976 );
2977 assert!(
2979 outcome
2980 .result
2981 .get("unknown_kinds")
2982 .and_then(|v| v.as_array())
2983 .unwrap()
2984 .is_empty()
2985 );
2986 }
2987
2988 #[test]
2989 fn tool_check_returns_empty_per_binding_for_local_builtins() {
2990 use crate::cli::tests_common::make_binding;
2991 let dir = tempdir().unwrap();
2992 let store = LocalFsStore::new(dir.path());
2993 let mut env = make_env("local");
2994 for (slot, descriptor) in crate::defaults::LOCAL_DEFAULT_BINDINGS {
2995 env.packs.push(make_binding(*slot, descriptor));
2996 }
2997 store.save(&env).unwrap();
2998 let outcome = tool_check(&store, &OpFlags::default(), "local").unwrap();
2999 let bindings = outcome
3000 .result
3001 .get("bindings")
3002 .and_then(|v| v.as_array())
3003 .expect("bindings array");
3004 assert_eq!(
3005 bindings.len(),
3006 crate::defaults::LOCAL_DEFAULT_BINDINGS.len()
3007 );
3008 for entry in bindings {
3009 let checks = entry
3010 .get("checks")
3011 .and_then(|v| v.as_array())
3012 .expect("checks array on binding");
3013 assert!(
3014 checks.is_empty(),
3015 "Phase A built-in handler should report no external tool checks"
3016 );
3017 }
3018 assert_eq!(
3019 outcome.result.get("total_checks").and_then(|v| v.as_u64()),
3020 Some(0)
3021 );
3022 assert_eq!(
3023 outcome.result.get("failed_checks").and_then(|v| v.as_u64()),
3024 Some(0)
3025 );
3026 assert!(
3027 outcome
3028 .result
3029 .get("unresolved_bindings")
3030 .and_then(|v| v.as_array())
3031 .unwrap()
3032 .is_empty()
3033 );
3034 }
3035
3036 #[test]
3037 fn tool_check_surfaces_unresolved_bindings_alongside_resolved() {
3038 use crate::cli::tests_common::make_binding;
3039 let dir = tempdir().unwrap();
3040 let store = LocalFsStore::new(dir.path());
3041 let mut env = make_env("local");
3042 env.packs.push(make_binding(
3045 greentic_deploy_spec::CapabilitySlot::Secrets,
3046 "greentic.secrets.dev-store@0.1.0",
3047 ));
3048 env.packs.push(make_binding(
3049 greentic_deploy_spec::CapabilitySlot::Deployer,
3050 "greentic.deployer.does-not-exist@0.1.0",
3051 ));
3052 store.save(&env).unwrap();
3053 let outcome = tool_check(&store, &OpFlags::default(), "local").unwrap();
3054 let bindings = outcome
3055 .result
3056 .get("bindings")
3057 .and_then(|v| v.as_array())
3058 .expect("bindings array");
3059 assert_eq!(bindings.len(), 1, "only the resolvable binding is reported");
3060 let unresolved = outcome
3061 .result
3062 .get("unresolved_bindings")
3063 .and_then(|v| v.as_array())
3064 .expect("unresolved_bindings array");
3065 assert_eq!(unresolved.len(), 1);
3066 assert_eq!(
3067 unresolved[0].get("kind").and_then(|v| v.as_str()),
3068 Some("greentic.deployer.does-not-exist@0.1.0")
3069 );
3070 assert!(
3071 unresolved[0]
3072 .get("error")
3073 .and_then(|v| v.as_str())
3074 .map(|s| !s.is_empty())
3075 .unwrap_or(false)
3076 );
3077 }
3078
3079 #[test]
3080 fn tool_check_schema_only_returns_input_schema() {
3081 let dir = tempdir().unwrap();
3082 let store = LocalFsStore::new(dir.path());
3083 let flags = OpFlags {
3084 schema_only: true,
3085 ..Default::default()
3086 };
3087 let outcome = tool_check(&store, &flags, "local").unwrap();
3088 assert_eq!(outcome.op, "tool-check");
3089 assert!(outcome.result.get("input_schema").is_some());
3090 }
3091
3092 #[test]
3093 fn tool_check_missing_env_errors_not_found() {
3094 let dir = tempdir().unwrap();
3095 let store = LocalFsStore::new(dir.path());
3096 let err = tool_check(&store, &OpFlags::default(), "local").unwrap_err();
3097 assert!(matches!(err, OpError::NotFound(_)), "got {err:?}");
3098 }
3099
3100 #[test]
3101 fn destroy_without_confirm_errors() {
3102 let dir = tempdir().unwrap();
3103 let store = LocalFsStore::new(dir.path());
3104 store.save(&make_env("local")).unwrap();
3105 let err = destroy(&store, &OpFlags::default(), "local", false).unwrap_err();
3106 assert!(matches!(err, OpError::InvalidArgument(_)), "got {err:?}");
3107 }
3108
3109 #[test]
3110 fn destroy_with_confirm_returns_not_yet_implemented() {
3111 let dir = tempdir().unwrap();
3112 let store = LocalFsStore::new(dir.path());
3113 store.save(&make_env("local")).unwrap();
3114 let err = destroy(&store, &OpFlags::default(), "local", true).unwrap_err();
3115 assert!(matches!(err, OpError::NotYetImplemented(_)), "got {err:?}");
3116 }
3117
3118 #[test]
3119 fn create_non_local_env_succeeds_and_audits_under_local_owner() {
3120 let dir = tempdir().unwrap();
3125 let store = LocalFsStore::new(dir.path());
3126 create(
3127 &store,
3128 &OpFlags::default(),
3129 Some(EnvCreatePayload {
3130 environment_id: "prod".to_string(),
3131 name: "prod".to_string(),
3132 region: None,
3133 tenant_org_id: None,
3134 listen_addr: None,
3135 public_base_url: None,
3136 }),
3137 )
3138 .expect("named env create must succeed on the local store");
3139 let env_json = dir.path().join("prod").join("environment.json");
3141 assert!(env_json.exists(), "create must persist environment.json");
3142 let log = dir.path().join("prod").join("audit").join("events.jsonl");
3144 let raw = std::fs::read_to_string(&log).expect("audit log must exist after create");
3145 let event: crate::environment::AuditEvent = serde_json::from_str(raw.trim_end()).unwrap();
3146 assert_eq!(event.env_id, "prod");
3147 assert_eq!(event.noun, "env");
3148 assert_eq!(event.verb, "create");
3149 match event.authorization {
3150 crate::environment::AuditDecision::Allow { policy, .. } => {
3151 assert_eq!(policy, crate::environment::POLICY_LOCAL_OWNER);
3152 }
3153 other => panic!("expected Allow, got {other:?}"),
3154 }
3155 assert!(matches!(event.result, crate::environment::AuditResult::Ok));
3156 }
3157
3158 #[test]
3159 fn create_local_env_writes_ok_audit_event() {
3160 let dir = tempdir().unwrap();
3161 let store = LocalFsStore::new(dir.path());
3162 create(
3163 &store,
3164 &OpFlags::default(),
3165 Some(EnvCreatePayload {
3166 environment_id: "local".to_string(),
3167 name: "local".to_string(),
3168 region: None,
3169 tenant_org_id: None,
3170 listen_addr: None,
3171 public_base_url: None,
3172 }),
3173 )
3174 .unwrap();
3175 let log = dir.path().join("local").join("audit").join("events.jsonl");
3176 let raw = std::fs::read_to_string(&log).unwrap();
3177 let event: crate::environment::AuditEvent = serde_json::from_str(raw.trim_end()).unwrap();
3178 assert_eq!(event.noun, "env");
3179 assert_eq!(event.verb, "create");
3180 matches!(
3181 event.authorization,
3182 crate::environment::AuditDecision::Allow { .. }
3183 );
3184 matches!(event.result, crate::environment::AuditResult::Ok);
3185 }
3186
3187 use super::super::dispatch::{EnvCreateArgs, EnvInitArgs, EnvUpdateArgs};
3190
3191 fn args_with_url(url: &str) -> EnvCreateArgs {
3192 EnvCreateArgs {
3193 environment_id: Some("local".into()),
3194 name: Some("local".into()),
3195 region: None,
3196 tenant_org_id: None,
3197 listen_addr: None,
3198 public_url: Some(url.into()),
3199 }
3200 }
3201
3202 #[test]
3203 fn env_create_args_into_payload_returns_none_when_no_inline_flags() {
3204 let args = EnvCreateArgs {
3206 environment_id: None,
3207 name: None,
3208 region: None,
3209 tenant_org_id: None,
3210 listen_addr: None,
3211 public_url: None,
3212 };
3213 assert!(
3214 args.into_payload("create", &OpFlags::default())
3215 .unwrap()
3216 .is_none()
3217 );
3218 }
3219
3220 #[test]
3221 fn env_create_args_into_payload_with_public_url_round_trips() {
3222 let p = args_with_url("https://chat.example.com")
3223 .into_payload("create", &OpFlags::default())
3224 .unwrap()
3225 .expect("inline path");
3226 assert_eq!(p.environment_id, "local");
3227 assert_eq!(
3228 p.public_base_url.as_deref(),
3229 Some("https://chat.example.com")
3230 );
3231 }
3232
3233 #[test]
3234 fn env_create_args_partial_inline_is_rejected_not_silent_fall_through() {
3235 let args = EnvCreateArgs {
3240 environment_id: None, name: Some("local".into()),
3242 region: None,
3243 tenant_org_id: None,
3244 listen_addr: None,
3245 public_url: Some("https://chat.example.com".into()),
3246 };
3247 let err = args
3248 .into_payload("create", &OpFlags::default())
3249 .expect_err("should reject");
3250 assert!(matches!(err, OpError::InvalidArgument(_)));
3251 }
3252
3253 #[test]
3254 fn env_create_args_inline_plus_answers_rejected() {
3255 let flags = OpFlags {
3258 answers: Some(std::path::PathBuf::from("/tmp/x.json")),
3259 ..Default::default()
3260 };
3261 let err = args_with_url("https://chat.example.com")
3262 .into_payload("create", &flags)
3263 .expect_err("should reject");
3264 assert!(matches!(err, OpError::InvalidArgument(_)));
3265 }
3266
3267 #[test]
3268 fn env_create_with_public_url_persists_and_validates() {
3269 let dir = tempdir().unwrap();
3270 let store = LocalFsStore::new(dir.path());
3271 create(
3272 &store,
3273 &OpFlags::default(),
3274 Some(EnvCreatePayload {
3275 environment_id: "local".to_string(),
3276 name: "local".to_string(),
3277 region: None,
3278 tenant_org_id: None,
3279 listen_addr: None,
3280 public_base_url: Some("https://chat.example.com".to_string()),
3281 }),
3282 )
3283 .unwrap();
3284 let env = store.load(&EnvId::try_from("local").unwrap()).unwrap();
3285 assert_eq!(
3286 env.host_config.public_base_url.as_deref(),
3287 Some("https://chat.example.com")
3288 );
3289 }
3290
3291 #[test]
3292 fn env_create_rejects_invalid_public_url_before_touching_disk() {
3293 let dir = tempdir().unwrap();
3294 let store = LocalFsStore::new(dir.path());
3295 let err = create(
3296 &store,
3297 &OpFlags::default(),
3298 Some(EnvCreatePayload {
3299 environment_id: "local".to_string(),
3300 name: "local".to_string(),
3301 region: None,
3302 tenant_org_id: None,
3303 listen_addr: None,
3304 public_base_url: Some("https://chat.example.com/path?x=1".to_string()),
3305 }),
3306 )
3307 .expect_err("invalid URL must fail");
3308 assert!(matches!(err, OpError::InvalidArgument(_)));
3309 assert!(!store.exists(&EnvId::try_from("local").unwrap()).unwrap());
3311 }
3312
3313 #[test]
3314 fn env_init_with_public_url_sets_field_on_creation() {
3315 let dir = tempdir().unwrap();
3316 let store = LocalFsStore::new(dir.path());
3317 let args = EnvInitArgs {
3318 public_url: Some("https://demo.greentic.ai".into()),
3319 };
3320 let payload = args.into_payload(&OpFlags::default()).unwrap();
3321 init(&store, &OpFlags::default(), payload).unwrap();
3322 let env = store
3323 .load(&EnvId::try_from(crate::defaults::LOCAL_ENV_ID).unwrap())
3324 .unwrap();
3325 assert_eq!(
3326 env.host_config.public_base_url.as_deref(),
3327 Some("https://demo.greentic.ai")
3328 );
3329 }
3330
3331 #[test]
3332 fn env_init_rejects_public_url_when_env_already_exists() {
3333 let dir = tempdir().unwrap();
3336 let store = LocalFsStore::new(dir.path());
3337 init(
3338 &store,
3339 &OpFlags::default(),
3340 EnvInitPayload {
3341 public_base_url: Some("https://first.example.com".into()),
3342 },
3343 )
3344 .unwrap();
3345 let err = init(
3346 &store,
3347 &OpFlags::default(),
3348 EnvInitPayload {
3349 public_base_url: Some("https://second.example.com".into()),
3350 },
3351 )
3352 .expect_err("second init with --public-url must error");
3353 assert!(matches!(err, OpError::InvalidArgument(_)), "got {err:?}");
3354 let env = store
3356 .load(&EnvId::try_from(crate::defaults::LOCAL_ENV_ID).unwrap())
3357 .unwrap();
3358 assert_eq!(
3359 env.host_config.public_base_url.as_deref(),
3360 Some("https://first.example.com"),
3361 "existing URL must be preserved"
3362 );
3363 }
3364
3365 #[test]
3366 fn env_init_without_public_url_stays_idempotent_on_existing_env() {
3367 let dir = tempdir().unwrap();
3370 let store = LocalFsStore::new(dir.path());
3371 init(
3372 &store,
3373 &OpFlags::default(),
3374 EnvInitPayload {
3375 public_base_url: Some("https://first.example.com".into()),
3376 },
3377 )
3378 .unwrap();
3379 let outcome = init(&store, &OpFlags::default(), EnvInitPayload::default()).unwrap();
3380 assert_eq!(
3381 outcome.result.get("outcome").and_then(|v| v.as_str()),
3382 Some("untouched")
3383 );
3384 }
3385
3386 #[test]
3387 fn env_init_includes_persisted_public_url_in_outcome() {
3388 let dir = tempdir().unwrap();
3389 let store = LocalFsStore::new(dir.path());
3390 let outcome = init(
3391 &store,
3392 &OpFlags::default(),
3393 EnvInitPayload {
3394 public_base_url: Some("https://demo.greentic.ai".into()),
3395 },
3396 )
3397 .unwrap();
3398 assert_eq!(
3399 outcome
3400 .result
3401 .get("public_base_url")
3402 .and_then(|v| v.as_str()),
3403 Some("https://demo.greentic.ai"),
3404 "outcome must surface the persisted public_base_url"
3405 );
3406 }
3407
3408 #[test]
3409 fn env_init_rejects_invalid_public_url_up_front() {
3410 let dir = tempdir().unwrap();
3411 let store = LocalFsStore::new(dir.path());
3412 let err = init(
3413 &store,
3414 &OpFlags::default(),
3415 EnvInitPayload {
3416 public_base_url: Some("ftp://nope.example.com".into()),
3417 },
3418 )
3419 .expect_err("non-http scheme must fail");
3420 assert!(matches!(err, OpError::InvalidArgument(_)));
3421 assert!(
3423 !store
3424 .exists(&EnvId::try_from(crate::defaults::LOCAL_ENV_ID).unwrap())
3425 .unwrap()
3426 );
3427 }
3428
3429 #[test]
3430 fn set_public_url_updates_existing_env() {
3431 let dir = tempdir().unwrap();
3432 let store = LocalFsStore::new(dir.path());
3433 store.save(&make_env("local")).unwrap();
3434 set_public_url(
3435 &store,
3436 &OpFlags::default(),
3437 "local",
3438 "https://chat.example.com",
3439 )
3440 .unwrap();
3441 let env = store.load(&EnvId::try_from("local").unwrap()).unwrap();
3442 assert_eq!(
3443 env.host_config.public_base_url.as_deref(),
3444 Some("https://chat.example.com")
3445 );
3446 }
3447
3448 #[test]
3449 fn set_public_url_strips_trailing_slash() {
3450 let dir = tempdir().unwrap();
3451 let store = LocalFsStore::new(dir.path());
3452 store.save(&make_env("local")).unwrap();
3453 set_public_url(
3454 &store,
3455 &OpFlags::default(),
3456 "local",
3457 "https://chat.example.com/",
3458 )
3459 .unwrap();
3460 let env = store.load(&EnvId::try_from("local").unwrap()).unwrap();
3461 assert_eq!(
3464 env.host_config.public_base_url.as_deref(),
3465 Some("https://chat.example.com")
3466 );
3467 }
3468
3469 #[test]
3470 fn set_public_url_rejects_invalid_origin() {
3471 let dir = tempdir().unwrap();
3472 let store = LocalFsStore::new(dir.path());
3473 store.save(&make_env("local")).unwrap();
3474 let err = set_public_url(
3475 &store,
3476 &OpFlags::default(),
3477 "local",
3478 "https://chat.example.com/path",
3479 )
3480 .expect_err("path-bearing URL must fail");
3481 assert!(matches!(err, OpError::InvalidArgument(_)));
3482 }
3483
3484 #[test]
3485 fn set_public_url_unknown_env_errors_and_no_state_written() {
3486 let dir = tempdir().unwrap();
3487 let store = LocalFsStore::new(dir.path());
3488 let err = set_public_url(
3489 &store,
3490 &OpFlags::default(),
3491 "missing",
3492 "https://chat.example.com",
3493 )
3494 .expect_err("missing env must fail");
3495 let _ = err;
3499 assert!(!store.exists(&EnvId::try_from("missing").unwrap()).unwrap());
3500 }
3501
3502 #[test]
3505 fn env_update_args_does_not_expose_public_url_inline() {
3506 let args = EnvUpdateArgs {
3509 environment_id: Some("local".into()),
3510 name: Some("local".into()),
3511 region: Some("eu-west-1".into()),
3512 tenant_org_id: None,
3513 };
3514 let payload = args
3515 .into_payload("update", &OpFlags::default())
3516 .unwrap()
3517 .expect("inline path");
3518 assert_eq!(payload.public_base_url, None);
3519 assert_eq!(payload.listen_addr, None);
3520 assert_eq!(payload.region.as_deref(), Some("eu-west-1"));
3521 }
3522
3523 #[test]
3524 fn env_update_args_rejects_partial_inline() {
3525 let args = EnvUpdateArgs {
3527 environment_id: None, name: Some("local".into()),
3529 region: None,
3530 tenant_org_id: None,
3531 };
3532 let err = args
3533 .into_payload("update", &OpFlags::default())
3534 .expect_err("should reject");
3535 assert!(matches!(err, OpError::InvalidArgument(_)));
3536 }
3537
3538 use crate::cli::dispatch::EnvRenderArgs;
3541 use std::path::PathBuf;
3542
3543 const K8S_ENV_LEVEL_OBJECT_COUNT: usize = 12;
3549
3550 fn render_args(env_id: &str, kind: Option<&str>, output: Option<PathBuf>) -> EnvRenderArgs {
3551 EnvRenderArgs {
3552 env_id: env_id.to_string(),
3553 kind: kind.map(str::to_string),
3554 output,
3555 }
3556 }
3557
3558 fn builtins() -> crate::env_packs::EnvPackRegistry {
3559 crate::env_packs::EnvPackRegistry::with_builtins()
3560 }
3561
3562 fn store_with_k8s_env(dir: &std::path::Path) -> LocalFsStore {
3563 use crate::cli::tests_common::make_binding;
3564 let store = LocalFsStore::new(dir);
3565 let mut env = make_env("zain");
3566 env.packs.push(make_binding(
3567 CapabilitySlot::Deployer,
3568 "greentic.deployer.k8s@1.0.0",
3569 ));
3570 store.save(&env).unwrap();
3571 store
3572 }
3573
3574 #[test]
3575 fn render_embeds_manifests_without_output() {
3576 let dir = tempdir().unwrap();
3577 let store = store_with_k8s_env(dir.path());
3578 let reg = builtins();
3579 let outcome = render(
3580 &store,
3581 ®,
3582 &OpFlags::default(),
3583 render_args("zain", None, None),
3584 )
3585 .unwrap();
3586 assert_eq!(outcome.op, "render");
3587 assert_eq!(
3588 outcome.result.get("kind").and_then(Value::as_str),
3589 Some("greentic.deployer.k8s@1.0.0"),
3590 "kind defaults to the env's deployer binding"
3591 );
3592 let manifests = outcome
3593 .result
3594 .get("manifests")
3595 .and_then(Value::as_array)
3596 .expect("manifests embedded when --output is absent");
3597 assert_eq!(manifests.len(), K8S_ENV_LEVEL_OBJECT_COUNT);
3598 assert_eq!(
3599 outcome.result.get("object_count").and_then(Value::as_u64),
3600 Some(K8S_ENV_LEVEL_OBJECT_COUNT as u64)
3601 );
3602 assert_eq!(
3603 manifests[0].get("kind").and_then(Value::as_str),
3604 Some("Namespace"),
3605 "apply order starts with the Namespace"
3606 );
3607 }
3608
3609 #[test]
3610 fn render_writes_apply_ordered_yaml_files() {
3611 let dir = tempdir().unwrap();
3612 let store = store_with_k8s_env(dir.path());
3613 let reg = builtins();
3614 let out = dir.path().join("rendered");
3615 let outcome = render(
3616 &store,
3617 ®,
3618 &OpFlags::default(),
3619 render_args("zain", None, Some(out.clone())),
3620 )
3621 .unwrap();
3622 assert!(outcome.result.get("manifests").is_none());
3623 let files: Vec<String> = outcome
3624 .result
3625 .get("files")
3626 .and_then(Value::as_array)
3627 .expect("files list")
3628 .iter()
3629 .map(|v| v.as_str().unwrap().to_string())
3630 .collect();
3631 assert_eq!(files.len(), K8S_ENV_LEVEL_OBJECT_COUNT);
3632 assert_eq!(files[0], "00-namespace-gtc-zain.yaml");
3633 assert_eq!(
3634 outcome
3635 .result
3636 .get("removed_stale_files")
3637 .and_then(Value::as_array)
3638 .map(Vec::len),
3639 Some(0)
3640 );
3641 for name in &files {
3642 let raw = std::fs::read_to_string(out.join(name)).expect("rendered file exists");
3643 let _: Value = serde_yaml_bw::from_str(&raw).expect("file parses back as YAML");
3644 }
3645 }
3646
3647 #[test]
3648 fn render_removes_stale_managed_files_and_reports_unmanaged() {
3649 let dir = tempdir().unwrap();
3650 let store = store_with_k8s_env(dir.path());
3651 let reg = builtins();
3652 let out = dir.path().join("rendered");
3653 std::fs::create_dir_all(&out).unwrap();
3654 std::fs::write(
3657 out.join("99-deployment-gtc-worker-old.yaml"),
3658 "kind: Deployment\n",
3659 )
3660 .unwrap();
3661 std::fs::write(
3663 out.join("kustomization.yaml"),
3664 "apiVersion: kustomize.config.k8s.io/v1beta1\n",
3665 )
3666 .unwrap();
3667 std::fs::write(out.join("notes.txt"), "not a manifest\n").unwrap();
3669 let outcome = render(
3670 &store,
3671 ®,
3672 &OpFlags::default(),
3673 render_args("zain", None, Some(out.clone())),
3674 )
3675 .unwrap();
3676 assert!(
3678 !out.join("99-deployment-gtc-worker-old.yaml").exists(),
3679 "stale managed file must be deleted from disk"
3680 );
3681 assert_eq!(
3682 outcome.result.get("removed_stale_files"),
3683 Some(&json!(["99-deployment-gtc-worker-old.yaml"])),
3684 );
3685 assert!(out.join("kustomization.yaml").exists());
3687 assert_eq!(
3688 outcome.result.get("unmanaged_files"),
3689 Some(&json!(["kustomization.yaml"])),
3690 );
3691 let outcome2 = render(
3693 &store,
3694 ®,
3695 &OpFlags::default(),
3696 render_args("zain", None, Some(out)),
3697 )
3698 .unwrap();
3699 assert_eq!(
3700 outcome2
3701 .result
3702 .get("removed_stale_files")
3703 .and_then(Value::as_array)
3704 .map(Vec::len),
3705 Some(0),
3706 "idempotent re-render must report empty removed_stale_files"
3707 );
3708 }
3709
3710 #[test]
3711 fn render_bare_path_kind_must_match_the_binding() {
3712 let dir = tempdir().unwrap();
3713 let store = store_with_k8s_env(dir.path());
3714 let reg = builtins();
3715 let outcome = render(
3717 &store,
3718 ®,
3719 &OpFlags::default(),
3720 render_args("zain", Some("greentic.deployer.k8s"), None),
3721 )
3722 .unwrap();
3723 assert_eq!(
3724 outcome.result.get("kind").and_then(Value::as_str),
3725 Some("greentic.deployer.k8s@1.0.0")
3726 );
3727 let err = render(
3729 &store,
3730 ®,
3731 &OpFlags::default(),
3732 render_args("zain", Some("greentic.deployer.other"), None),
3733 )
3734 .unwrap_err();
3735 assert!(matches!(err, OpError::InvalidArgument(_)), "{err}");
3736 }
3737
3738 #[test]
3739 fn render_without_deployer_binding_requires_kind() {
3740 let dir = tempdir().unwrap();
3741 let store = LocalFsStore::new(dir.path());
3742 let reg = builtins();
3743 store.save(&make_env("bare")).unwrap();
3744 let err = render(
3745 &store,
3746 ®,
3747 &OpFlags::default(),
3748 render_args("bare", None, None),
3749 )
3750 .unwrap_err();
3751 assert!(matches!(err, OpError::Conflict(_)), "{err}");
3752 let outcome = render(
3754 &store,
3755 ®,
3756 &OpFlags::default(),
3757 render_args("bare", Some("greentic.deployer.k8s@1.0.0"), None),
3758 )
3759 .unwrap();
3760 assert_eq!(
3761 outcome.result.get("object_count").and_then(Value::as_u64),
3762 Some(K8S_ENV_LEVEL_OBJECT_COUNT as u64)
3763 );
3764 }
3765
3766 #[test]
3767 fn render_rejects_kind_without_a_renderer() {
3768 use crate::cli::tests_common::make_binding;
3769 let dir = tempdir().unwrap();
3770 let store = LocalFsStore::new(dir.path());
3771 let reg = builtins();
3772 let mut env = make_env("local");
3773 env.packs.push(make_binding(
3774 CapabilitySlot::Deployer,
3775 crate::defaults::LOCAL_DEPLOYER_PACK,
3776 ));
3777 store.save(&env).unwrap();
3778 let err = render(
3779 &store,
3780 ®,
3781 &OpFlags::default(),
3782 render_args("local", None, None),
3783 )
3784 .unwrap_err();
3785 match err {
3786 OpError::Conflict(msg) => {
3787 assert!(msg.contains("does not support manifest rendering"), "{msg}")
3788 }
3789 other => panic!("expected Conflict, got {other}"),
3790 }
3791 }
3792
3793 #[test]
3794 fn render_missing_env_is_not_found() {
3795 let dir = tempdir().unwrap();
3796 let store = LocalFsStore::new(dir.path());
3797 let reg = builtins();
3798 let err = render(
3799 &store,
3800 ®,
3801 &OpFlags::default(),
3802 render_args("ghost", None, None),
3803 )
3804 .unwrap_err();
3805 assert!(matches!(err, OpError::NotFound(_)), "{err}");
3806 }
3807
3808 #[test]
3809 fn render_schema_only_returns_input_schema() {
3810 let dir = tempdir().unwrap();
3811 let store = LocalFsStore::new(dir.path());
3812 let reg = builtins();
3813 let flags = OpFlags {
3814 schema_only: true,
3815 ..OpFlags::default()
3816 };
3817 let outcome = render(&store, ®, &flags, render_args("zain", None, None)).unwrap();
3818 assert!(outcome.result.get("input_schema").is_some());
3819 }
3820
3821 use crate::cli::dispatch::EnvReconcileArgs;
3824
3825 fn reconcile_args(env_id: &str, kind: Option<&str>) -> EnvReconcileArgs {
3826 EnvReconcileArgs {
3827 env_id: env_id.to_string(),
3828 kind: kind.map(str::to_string),
3829 }
3830 }
3831
3832 #[test]
3836 fn reconcile_rejects_non_k8s_deployer_kind() {
3837 use crate::cli::tests_common::make_binding;
3838 let dir = tempdir().unwrap();
3839 let store = LocalFsStore::new(dir.path());
3840 let reg = builtins();
3841 let mut env = make_env("local");
3842 env.packs.push(make_binding(
3843 CapabilitySlot::Deployer,
3844 crate::defaults::LOCAL_DEPLOYER_PACK,
3845 ));
3846 store.save(&env).unwrap();
3847 let err = reconcile(
3848 &store,
3849 ®,
3850 &OpFlags::default(),
3851 reconcile_args("local", None),
3852 )
3853 .unwrap_err();
3854 match err {
3855 OpError::Conflict(msg) => assert!(msg.contains("only supported for"), "{msg}"),
3856 other => panic!("expected Conflict, got {other}"),
3857 }
3858 }
3859
3860 #[test]
3861 fn reconcile_missing_env_is_not_found() {
3862 let dir = tempdir().unwrap();
3863 let store = LocalFsStore::new(dir.path());
3864 let reg = builtins();
3865 let err = reconcile(
3866 &store,
3867 ®,
3868 &OpFlags::default(),
3869 reconcile_args("ghost", None),
3870 )
3871 .unwrap_err();
3872 assert!(matches!(err, OpError::NotFound(_)), "{err}");
3873 }
3874
3875 #[test]
3876 fn reconcile_schema_only_returns_input_schema() {
3877 let dir = tempdir().unwrap();
3878 let store = LocalFsStore::new(dir.path());
3879 let reg = builtins();
3880 let flags = OpFlags {
3881 schema_only: true,
3882 ..OpFlags::default()
3883 };
3884 let outcome = reconcile(&store, ®, &flags, reconcile_args("zain", None)).unwrap();
3885 assert_eq!(outcome.op, "reconcile");
3886 assert!(outcome.result.get("input_schema").is_some());
3887 }
3888
3889 use crate::cli::dispatch::EnvApplyRevisionArgs;
3892
3893 fn apply_revision_args(
3894 env_id: &str,
3895 revision_id: &str,
3896 kind: Option<&str>,
3897 ) -> EnvApplyRevisionArgs {
3898 EnvApplyRevisionArgs {
3899 env_id: env_id.to_string(),
3900 revision_id: revision_id.to_string(),
3901 kind: kind.map(str::to_string),
3902 }
3903 }
3904
3905 #[test]
3909 fn apply_revision_rejects_unsupported_deployer_kind() {
3910 use crate::cli::tests_common::make_binding;
3911 let dir = tempdir().unwrap();
3912 let store = LocalFsStore::new(dir.path());
3913 let reg = builtins();
3914 let mut env = make_env("local");
3915 env.packs.push(make_binding(
3916 CapabilitySlot::Deployer,
3917 crate::defaults::LOCAL_DEPLOYER_PACK,
3918 ));
3919 store.save(&env).unwrap();
3920 let err = apply_revision(
3921 &store,
3922 ®,
3923 &OpFlags::default(),
3924 apply_revision_args("local", "00000000000000000000000000", None),
3925 )
3926 .unwrap_err();
3927 match err {
3928 OpError::Conflict(msg) => assert!(msg.contains("only supported for"), "{msg}"),
3929 other => panic!("expected Conflict, got {other}"),
3930 }
3931 }
3932
3933 #[test]
3940 fn apply_revision_admits_aws_ecs_kind() {
3941 use crate::cli::tests_common::make_binding;
3942 let dir = tempdir().unwrap();
3943 let store = LocalFsStore::new(dir.path());
3944 let reg = builtins();
3945 let mut env = make_env("local");
3946 env.packs.push(make_binding(
3947 CapabilitySlot::Deployer,
3948 "greentic.deployer.aws-ecs@1.0.0",
3949 ));
3950 store.save(&env).unwrap();
3951 let err = apply_revision(
3952 &store,
3953 ®,
3954 &OpFlags::default(),
3955 apply_revision_args("local", "00000000000000000000000000", None),
3956 )
3957 .unwrap_err();
3958 match err {
3959 OpError::NotFound(msg) => assert!(msg.contains("revision"), "{msg}"),
3960 other => panic!("expected NotFound(revision), got {other}"),
3961 }
3962 }
3963
3964 use crate::cli::dispatch::EnvApplyTrafficArgs;
3967
3968 fn apply_traffic_args(
3969 env_id: &str,
3970 deployment_id: &str,
3971 kind: Option<&str>,
3972 ) -> EnvApplyTrafficArgs {
3973 EnvApplyTrafficArgs {
3974 env_id: env_id.to_string(),
3975 deployment_id: deployment_id.to_string(),
3976 kind: kind.map(str::to_string),
3977 }
3978 }
3979
3980 #[test]
3981 fn apply_traffic_schema_only_returns_input_schema() {
3982 let dir = tempdir().unwrap();
3983 let store = LocalFsStore::new(dir.path());
3984 let reg = builtins();
3985 let flags = OpFlags {
3986 schema_only: true,
3987 ..OpFlags::default()
3988 };
3989 let outcome = apply_traffic(
3990 &store,
3991 ®,
3992 &flags,
3993 apply_traffic_args("zain", "00000000000000000000000000", None),
3994 )
3995 .unwrap();
3996 assert_eq!(outcome.op, "apply-traffic");
3997 assert!(outcome.result.get("input_schema").is_some());
3998 }
3999
4000 #[test]
4004 fn apply_traffic_rejects_non_aws_deployer_kind() {
4005 use crate::cli::tests_common::make_binding;
4006 let dir = tempdir().unwrap();
4007 let store = LocalFsStore::new(dir.path());
4008 let reg = builtins();
4009 let mut env = make_env("local");
4010 env.packs.push(make_binding(
4011 CapabilitySlot::Deployer,
4012 crate::defaults::LOCAL_DEPLOYER_PACK,
4013 ));
4014 store.save(&env).unwrap();
4015 let err = apply_traffic(
4016 &store,
4017 ®,
4018 &OpFlags::default(),
4019 apply_traffic_args("local", "00000000000000000000000000", None),
4020 )
4021 .unwrap_err();
4022 match err {
4023 OpError::Conflict(msg) => assert!(msg.contains("only supported for"), "{msg}"),
4024 other => panic!("expected Conflict, got {other}"),
4025 }
4026 }
4027
4028 #[cfg(feature = "deploy-aws-ecs")]
4032 #[test]
4033 fn apply_traffic_admits_aws_ecs_but_requires_launch_config() {
4034 use crate::cli::tests_common::make_binding;
4035 let dir = tempdir().unwrap();
4036 let store = LocalFsStore::new(dir.path());
4037 let reg = builtins();
4038 let mut env = make_env("local");
4039 env.packs.push(make_binding(
4040 CapabilitySlot::Deployer,
4041 "greentic.deployer.aws-ecs@1.0.0",
4042 ));
4043 store.save(&env).unwrap();
4044 let err = apply_traffic(
4045 &store,
4046 ®,
4047 &OpFlags::default(),
4048 apply_traffic_args("local", "00000000000000000000000000", None),
4049 )
4050 .unwrap_err();
4051 match err {
4052 OpError::Conflict(msg) => assert!(msg.contains("Fargate launch config"), "{msg}"),
4053 other => panic!("expected Conflict(launch config), got {other}"),
4054 }
4055 }
4056
4057 #[cfg(all(feature = "creds-aws", feature = "deploy-aws-ecs"))]
4062 #[test]
4063 fn pinned_role_without_session_refuses_only_role_without_session() {
4064 assert!(pinned_role_without_session(
4066 Some("arn:aws:iam::1:role/dep"),
4067 false
4068 ));
4069 assert!(!pinned_role_without_session(
4071 Some("arn:aws:iam::1:role/dep"),
4072 true
4073 ));
4074 assert!(!pinned_role_without_session(None, false));
4076 assert!(!pinned_role_without_session(None, true));
4077 }
4078
4079 #[test]
4080 fn apply_revision_missing_env_is_not_found() {
4081 let dir = tempdir().unwrap();
4082 let store = LocalFsStore::new(dir.path());
4083 let reg = builtins();
4084 let err = apply_revision(
4085 &store,
4086 ®,
4087 &OpFlags::default(),
4088 apply_revision_args("ghost", "00000000000000000000000000", None),
4089 )
4090 .unwrap_err();
4091 assert!(matches!(err, OpError::NotFound(_)), "{err}");
4092 }
4093
4094 #[test]
4097 fn apply_revision_missing_revision_is_not_found() {
4098 let dir = tempdir().unwrap();
4099 let store = store_with_k8s_env(dir.path());
4100 let reg = builtins();
4101 let err = apply_revision(
4102 &store,
4103 ®,
4104 &OpFlags::default(),
4105 apply_revision_args("zain", "00000000000000000000000000", None),
4106 )
4107 .unwrap_err();
4108 match err {
4109 OpError::NotFound(msg) => assert!(msg.contains("revision"), "{msg}"),
4110 other => panic!("expected NotFound(revision), got {other}"),
4111 }
4112 }
4113
4114 #[test]
4117 fn apply_revision_bad_revision_id_is_invalid_argument() {
4118 let dir = tempdir().unwrap();
4119 let store = store_with_k8s_env(dir.path());
4120 let reg = builtins();
4121 let err = apply_revision(
4122 &store,
4123 ®,
4124 &OpFlags::default(),
4125 apply_revision_args("zain", "not-a-ulid", None),
4126 )
4127 .unwrap_err();
4128 assert!(matches!(err, OpError::InvalidArgument(_)), "{err}");
4129 }
4130
4131 #[test]
4132 fn apply_revision_schema_only_returns_input_schema() {
4133 let dir = tempdir().unwrap();
4134 let store = LocalFsStore::new(dir.path());
4135 let reg = builtins();
4136 let flags = OpFlags {
4137 schema_only: true,
4138 ..OpFlags::default()
4139 };
4140 let outcome = apply_revision(
4141 &store,
4142 ®,
4143 &flags,
4144 apply_revision_args("zain", "00000000000000000000000000", None),
4145 )
4146 .unwrap();
4147 assert_eq!(outcome.op, "apply-revision");
4148 assert!(outcome.result.get("input_schema").is_some());
4149 }
4150
4151 #[test]
4156 fn apply_revision_rejects_unbound_deployer_kind_override() {
4157 use crate::cli::tests_common::make_binding;
4158 let dir = tempdir().unwrap();
4159 let store = LocalFsStore::new(dir.path());
4160 let reg = builtins();
4161 let mut env = make_env("local");
4162 env.packs.push(make_binding(
4163 CapabilitySlot::Deployer,
4164 crate::defaults::LOCAL_DEPLOYER_PACK,
4165 ));
4166 store.save(&env).unwrap();
4167 let err = apply_revision(
4168 &store,
4169 ®,
4170 &OpFlags::default(),
4171 apply_revision_args(
4172 "local",
4173 "00000000000000000000000000",
4174 Some("greentic.deployer.k8s@1.0.0"),
4175 ),
4176 )
4177 .unwrap_err();
4178 match err {
4179 OpError::Conflict(msg) => assert!(
4180 msg.contains("bound to deployer") && msg.contains("not the env's"),
4181 "{msg}"
4182 ),
4183 other => panic!("expected Conflict (unbound deployer), got {other}"),
4184 }
4185 }
4186
4187 #[test]
4188 fn reconcile_rejects_unbound_deployer_kind_override() {
4189 use crate::cli::tests_common::make_binding;
4190 let dir = tempdir().unwrap();
4191 let store = LocalFsStore::new(dir.path());
4192 let reg = builtins();
4193 let mut env = make_env("local");
4194 env.packs.push(make_binding(
4195 CapabilitySlot::Deployer,
4196 crate::defaults::LOCAL_DEPLOYER_PACK,
4197 ));
4198 store.save(&env).unwrap();
4199 let err = reconcile(
4200 &store,
4201 ®,
4202 &OpFlags::default(),
4203 reconcile_args("local", Some("greentic.deployer.k8s@1.0.0")),
4204 )
4205 .unwrap_err();
4206 match err {
4207 OpError::Conflict(msg) => assert!(msg.contains("bound to deployer"), "{msg}"),
4208 other => panic!("expected Conflict (unbound deployer), got {other}"),
4209 }
4210 }
4211
4212 #[test]
4215 fn render_with_answers_overrides_namespace() {
4216 use crate::cli::tests_common::make_binding;
4217 let dir = tempdir().unwrap();
4218 let store = LocalFsStore::new(dir.path());
4219 let reg = builtins();
4220 let mut env = make_env("zain");
4221 let mut binding = make_binding(CapabilitySlot::Deployer, "greentic.deployer.k8s@1.0.0");
4223 binding.answers_ref = Some(PathBuf::from("env-packs/deployer/answers.json"));
4224 env.packs.push(binding);
4225 store.save(&env).unwrap();
4226 let env_dir = store.env_dir(&EnvId::try_from("zain").unwrap()).unwrap();
4228 let answers_dir = env_dir.join("env-packs/deployer");
4229 std::fs::create_dir_all(&answers_dir).unwrap();
4230 std::fs::write(
4231 answers_dir.join("answers.json"),
4232 r#"{"namespace": "custom-ns"}"#,
4233 )
4234 .unwrap();
4235 let outcome = render(
4236 &store,
4237 ®,
4238 &OpFlags::default(),
4239 render_args("zain", None, None),
4240 )
4241 .unwrap();
4242 let manifests = outcome
4244 .result
4245 .get("manifests")
4246 .and_then(Value::as_array)
4247 .expect("manifests");
4248 assert_eq!(
4249 manifests[0]["metadata"]["name"].as_str(),
4250 Some("custom-ns"),
4251 "Namespace object uses the answer override"
4252 );
4253 assert_eq!(
4255 outcome.result.get("answers_ref").and_then(Value::as_str),
4256 Some("env-packs/deployer/answers.json"),
4257 );
4258 }
4259
4260 #[test]
4261 fn render_answers_ref_missing_file_is_conflict() {
4262 use crate::cli::tests_common::make_binding;
4263 let dir = tempdir().unwrap();
4264 let store = LocalFsStore::new(dir.path());
4265 let reg = builtins();
4266 let mut env = make_env("zain");
4267 let mut binding = make_binding(CapabilitySlot::Deployer, "greentic.deployer.k8s@1.0.0");
4268 binding.answers_ref = Some(PathBuf::from("env-packs/deployer/answers.json"));
4269 env.packs.push(binding);
4270 store.save(&env).unwrap();
4271 let err = render(
4273 &store,
4274 ®,
4275 &OpFlags::default(),
4276 render_args("zain", None, None),
4277 )
4278 .unwrap_err();
4279 match err {
4282 OpError::Conflict(msg) => {
4283 assert!(msg.contains("does not exist"), "got: {msg}")
4284 }
4285 other => panic!("expected Conflict, got {other}"),
4286 }
4287 }
4288
4289 #[test]
4290 fn render_answers_ref_escaping_env_dir_is_conflict() {
4291 use crate::cli::tests_common::make_binding;
4292 let dir = tempdir().unwrap();
4293 let store = LocalFsStore::new(dir.path());
4294 let reg = builtins();
4295 let mut env = make_env("zain");
4296 let mut binding = make_binding(CapabilitySlot::Deployer, "greentic.deployer.k8s@1.0.0");
4297 binding.answers_ref = Some(PathBuf::from("../outside-answers.json"));
4300 env.packs.push(binding);
4301 store.save(&env).unwrap();
4302 let env_dir = store.env_dir(&EnvId::try_from("zain").unwrap()).unwrap();
4303 std::fs::write(
4304 env_dir.parent().unwrap().join("outside-answers.json"),
4305 r#"{"namespace": "evil-ns"}"#,
4306 )
4307 .unwrap();
4308 let err = render(
4309 &store,
4310 ®,
4311 &OpFlags::default(),
4312 render_args("zain", None, None),
4313 )
4314 .unwrap_err();
4315 match err {
4316 OpError::Conflict(msg) => assert!(msg.contains("escapes"), "got: {msg}"),
4317 other => panic!("expected Conflict, got {other}"),
4318 }
4319 }
4320
4321 #[test]
4322 fn render_answers_ref_invalid_json_is_conflict() {
4323 use crate::cli::tests_common::make_binding;
4324 let dir = tempdir().unwrap();
4325 let store = LocalFsStore::new(dir.path());
4326 let reg = builtins();
4327 let mut env = make_env("zain");
4328 let mut binding = make_binding(CapabilitySlot::Deployer, "greentic.deployer.k8s@1.0.0");
4329 binding.answers_ref = Some(PathBuf::from("env-packs/deployer/answers.json"));
4330 env.packs.push(binding);
4331 store.save(&env).unwrap();
4332 let env_dir = store.env_dir(&EnvId::try_from("zain").unwrap()).unwrap();
4333 let answers_dir = env_dir.join("env-packs/deployer");
4334 std::fs::create_dir_all(&answers_dir).unwrap();
4335 std::fs::write(answers_dir.join("answers.json"), "not json{").unwrap();
4336 let err = render(
4337 &store,
4338 ®,
4339 &OpFlags::default(),
4340 render_args("zain", None, None),
4341 )
4342 .unwrap_err();
4343 assert!(matches!(err, OpError::Conflict(_)), "got {err:?}");
4344 }
4345
4346 #[test]
4347 fn render_answers_ref_invalid_replicas_is_conflict() {
4348 use crate::cli::tests_common::make_binding;
4349 let dir = tempdir().unwrap();
4350 let store = LocalFsStore::new(dir.path());
4351 let reg = builtins();
4352 let mut env = make_env("zain");
4353 let mut binding = make_binding(CapabilitySlot::Deployer, "greentic.deployer.k8s@1.0.0");
4354 binding.answers_ref = Some(PathBuf::from("env-packs/deployer/answers.json"));
4355 env.packs.push(binding);
4356 store.save(&env).unwrap();
4357 let env_dir = store.env_dir(&EnvId::try_from("zain").unwrap()).unwrap();
4358 let answers_dir = env_dir.join("env-packs/deployer");
4359 std::fs::create_dir_all(&answers_dir).unwrap();
4360 std::fs::write(
4361 answers_dir.join("answers.json"),
4362 r#"{"router_replicas": "1"}"#,
4363 )
4364 .unwrap();
4365 let err = render(
4366 &store,
4367 ®,
4368 &OpFlags::default(),
4369 render_args("zain", None, None),
4370 )
4371 .unwrap_err();
4372 assert!(matches!(err, OpError::Conflict(_)), "got {err:?}");
4373 }
4374
4375 #[test]
4378 fn render_uses_caller_provided_registry() {
4379 use crate::cli::tests_common::make_binding;
4380 use crate::env_packs::render::{ManifestRenderer, RenderError};
4381 use crate::env_packs::slot::EnvPackHandler;
4382
4383 #[derive(Debug)]
4385 struct FakeDeployerHandler;
4386
4387 impl EnvPackHandler for FakeDeployerHandler {
4388 fn slot(&self) -> CapabilitySlot {
4389 CapabilitySlot::Deployer
4390 }
4391 fn descriptor_path(&self) -> &str {
4392 "test.deployer.fake"
4393 }
4394 fn supported_versions(&self) -> semver::VersionReq {
4395 "^1.0.0".parse().unwrap()
4396 }
4397 fn deployer_credentials(&self) -> Option<&dyn crate::credentials::DeployerCredentials> {
4398 static CREDS: std::sync::LazyLock<
4400 crate::env_packs::local_process::LocalProcessCredentials,
4401 > = std::sync::LazyLock::new(
4402 crate::env_packs::local_process::LocalProcessCredentials::default,
4403 );
4404 Some(&*CREDS)
4405 }
4406 fn as_manifest_renderer(&self) -> Option<&dyn ManifestRenderer> {
4407 Some(self)
4408 }
4409 }
4410
4411 impl ManifestRenderer for FakeDeployerHandler {
4412 fn render_environment(
4413 &self,
4414 _env: &greentic_deploy_spec::Environment,
4415 _answers: Option<&serde_json::Value>,
4416 ) -> Result<Vec<Value>, RenderError> {
4417 Ok(vec![json!({
4418 "apiVersion": "v1",
4419 "kind": "ConfigMap",
4420 "metadata": {"name": "fake-rendered"},
4421 })])
4422 }
4423 }
4424
4425 let dir = tempdir().unwrap();
4426 let store = LocalFsStore::new(dir.path());
4427 let mut env = make_env("plug");
4428 env.packs.push(make_binding(
4429 CapabilitySlot::Deployer,
4430 "test.deployer.fake@1.0.0",
4431 ));
4432 store.save(&env).unwrap();
4433
4434 let mut reg = crate::env_packs::EnvPackRegistry::with_builtins();
4435 reg.register(Box::new(FakeDeployerHandler)).unwrap();
4436
4437 let outcome = render(
4438 &store,
4439 ®,
4440 &OpFlags::default(),
4441 render_args("plug", None, None),
4442 )
4443 .unwrap();
4444 let manifests = outcome
4445 .result
4446 .get("manifests")
4447 .and_then(Value::as_array)
4448 .expect("manifests");
4449 assert_eq!(manifests.len(), 1);
4450 assert_eq!(
4451 manifests[0]["metadata"]["name"].as_str(),
4452 Some("fake-rendered"),
4453 "the custom renderer's objects must come back"
4454 );
4455 }
4456
4457 use crate::cli::tests_common::make_binding;
4460 use crate::env_packs::k8s::manifests::{SecretsBackend, VaultBackend};
4461
4462 #[test]
4463 fn resolve_secrets_backend_defaults_to_dev_store() {
4464 let dir = tempdir().unwrap();
4465 let store = LocalFsStore::new(dir.path());
4466 let env = make_env("local");
4468 assert!(matches!(
4469 resolve_secrets_backend(&store, &env).unwrap(),
4470 SecretsBackend::DevStore
4471 ));
4472 let mut env = make_env("local");
4474 env.packs.push(make_binding(
4475 CapabilitySlot::Secrets,
4476 crate::defaults::LOCAL_SECRETS_PACK,
4477 ));
4478 assert!(matches!(
4479 resolve_secrets_backend(&store, &env).unwrap(),
4480 SecretsBackend::DevStore
4481 ));
4482 }
4483
4484 #[test]
4485 fn secrets_backend_is_dev_store_classifies_by_kind() {
4486 assert!(secrets_backend_is_dev_store(&make_env("local")));
4488
4489 let mut env = make_env("local");
4491 env.packs.push(make_binding(
4492 CapabilitySlot::Secrets,
4493 crate::defaults::LOCAL_SECRETS_PACK,
4494 ));
4495 assert!(secrets_backend_is_dev_store(&env));
4496
4497 let mut env = make_env("local");
4501 env.packs.push(make_binding(
4502 CapabilitySlot::Secrets,
4503 crate::defaults::VAULT_SECRETS_PACK,
4504 ));
4505 assert!(!secrets_backend_is_dev_store(&env));
4506 }
4507
4508 #[test]
4509 fn resolve_secrets_backend_rejects_unknown_kind() {
4510 let dir = tempdir().unwrap();
4511 let store = LocalFsStore::new(dir.path());
4512 let mut env = make_env("local");
4513 env.packs.push(make_binding(
4514 CapabilitySlot::Secrets,
4515 "greentic.secrets.bogus@1.0.0",
4516 ));
4517 let OpError::Conflict(msg) = resolve_secrets_backend(&store, &env).unwrap_err() else {
4518 panic!("expected Conflict");
4519 };
4520 assert!(msg.contains("unknown secrets backend kind"), "got: {msg}");
4521 }
4522
4523 #[test]
4524 fn resolve_secrets_backend_vault_without_answers_fails_closed() {
4525 let dir = tempdir().unwrap();
4526 let store = LocalFsStore::new(dir.path());
4527 let mut env = make_env("local");
4528 env.packs.push(make_binding(
4530 CapabilitySlot::Secrets,
4531 crate::defaults::VAULT_SECRETS_PACK,
4532 ));
4533 let OpError::Conflict(msg) = resolve_secrets_backend(&store, &env).unwrap_err() else {
4534 panic!("expected Conflict");
4535 };
4536 assert!(msg.contains("requires a non-empty `addr`"), "got: {msg}");
4537 }
4538
4539 #[test]
4540 fn vault_answers_map_to_backend_with_provider_defaults() {
4541 let answers = json!({"addr": " http://vault.vault.svc:8200 ", "role": "worker"});
4543 let SecretsBackend::Vault(b) = secrets_backend_from_vault_answers(Some(&answers)).unwrap()
4544 else {
4545 panic!("expected Vault");
4546 };
4547 assert_eq!(
4548 b,
4549 VaultBackend {
4550 addr: "http://vault.vault.svc:8200".to_string(),
4551 k8s_role: "worker".to_string(),
4552 kv_mount: "secret".to_string(),
4553 kv_prefix: "greentic".to_string(),
4554 auth_mount: "kubernetes".to_string(),
4555 transit_mount: "transit".to_string(),
4556 transit_key: "greentic".to_string(),
4557 namespace: None,
4558 }
4559 );
4560 }
4561
4562 #[test]
4563 fn vault_answers_override_defaults_and_namespace() {
4564 let answers = json!({
4565 "addr": "https://vault.example:8200",
4566 "role": "worker",
4567 "kv_mount": "kv",
4568 "kv_prefix": "tenant-a",
4569 "auth_mount": "k8s-eu",
4570 "transit_mount": "tr",
4571 "transit_key": "rk",
4572 "namespace": "admin/team",
4573 });
4574 let SecretsBackend::Vault(b) = secrets_backend_from_vault_answers(Some(&answers)).unwrap()
4575 else {
4576 panic!("expected Vault");
4577 };
4578 assert_eq!(b.kv_mount, "kv");
4579 assert_eq!(b.kv_prefix, "tenant-a");
4580 assert_eq!(b.auth_mount, "k8s-eu");
4581 assert_eq!(b.transit_mount, "tr");
4582 assert_eq!(b.transit_key, "rk");
4583 assert_eq!(b.namespace.as_deref(), Some("admin/team"));
4584 }
4585
4586 #[test]
4587 fn vault_answers_missing_role_fails_closed() {
4588 let answers = json!({"addr": "http://vault:8200"});
4589 let OpError::Conflict(msg) =
4590 secrets_backend_from_vault_answers(Some(&answers)).unwrap_err()
4591 else {
4592 panic!("expected Conflict");
4593 };
4594 assert!(msg.contains("`role`"), "got: {msg}");
4595 }
4596
4597 #[test]
4598 fn vault_answers_non_object_rejected() {
4599 let answers = json!("not an object");
4600 let OpError::Conflict(msg) =
4601 secrets_backend_from_vault_answers(Some(&answers)).unwrap_err()
4602 else {
4603 panic!("expected Conflict");
4604 };
4605 assert!(msg.contains("must be a JSON object"), "got: {msg}");
4606 }
4607}