Skip to main content

greentic_deployer/cli/
env.rs

1//! `gtc op env {create,update,list,show,doctor,destroy}` (`A3` of `plans/next-gen-deployment.md`).
2//!
3//! Commands operate directly on the [`EnvironmentStore`] from A2. Each
4//! mutating call validates the payload before touching disk.
5
6use 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/// Payload accepted by `op env create` (and `op env update`).
24///
25/// Slot bindings (`packs`) and bundle/revision/traffic-split state are NOT
26/// accepted here — those go through their own commands so the env CRUD
27/// surface stays narrow. An env created this way starts with `packs = []`
28/// and no bundles; subsequent `op env-packs add` and `op bundles add` calls
29/// populate it.
30#[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    /// Bind address for the runtime's local HTTP listener (parsed as
39    /// `SocketAddr`). When omitted, the env is created with
40    /// `host_config.listen_addr = None`, and the runtime falls back to
41    /// `DEFAULT_LISTEN_ADDR` via `resolved_listen_addr()`. Set explicitly
42    /// to lock the env to a non-default bind.
43    #[serde(default, skip_serializing_if = "Option::is_none")]
44    pub listen_addr: Option<String>,
45    /// Persistent public base URL the runtime exposes (e.g. via a static
46    /// tunnel or external load balancer). Validated on save: origin only —
47    /// `https://host[:port]`, no path, query, or fragment. `None` leaves
48    /// the env's URL unset, so the runtime falls back to a tunnel-discovered
49    /// or `PUBLIC_BASE_URL` env-var value.
50    #[serde(default, skip_serializing_if = "Option::is_none")]
51    pub public_base_url: Option<String>,
52}
53
54/// Returned by `op env create` / `op env update`.
55#[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    /// Explicit bind address for the runtime's local HTTP listener.
62    /// `None` means the env relies on `DEFAULT_LISTEN_ADDR`; surface the
63    /// effective resolution via `op config show` (full host_config).
64    #[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
89/// `op env create`. Idempotent: if the env already exists, fails with
90/// `OpError::Conflict` — callers wanting upsert semantics should use `update`.
91pub 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    // Parse the bind address up front so a malformed value is rejected before
103    // we touch the env store or the audit log. Same pattern as `op config set`.
104    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
147/// `op env update`. Replaces `name`, `region`, and `tenant_org_id` on an
148/// existing env. The `packs`/`bundles`/`revisions`/`traffic_splits` arrays
149/// stay untouched — manage those via their own subcommands.
150pub 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
205/// `op env list`.
206pub fn list(store: &dyn EnvironmentReads, flags: &OpFlags) -> Result<OpOutcome, OpError> {
207    if flags.schema_only {
208        // `list` has no input; produce a null-input schema as a placeholder.
209        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
227/// `op env show <env_id>`.
228pub 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
257/// `op env doctor <env_id>`. Re-validates the env against `Environment::validate`
258/// and checks for missing capability slots. Returns a structured report
259/// instead of failing on the first issue.
260pub 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    // Only the core, 1-per-slot families (those bound in `packs`) have a
278    // meaningful "missing" state. The N-per-env slots (`Messaging`,
279    // `Extension`) live in their own open collections — absence is not a
280    // misconfiguration — so they never appear here.
281    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    // Resolve each binding's `kind` against the env-pack registry (A9): a
289    // binding whose descriptor no native handler backs, or whose handler
290    // serves a different slot, is a latent misconfiguration the operator
291    // should see before deploy.
292    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            // `resolve_for_slot` only produces the three variants above;
319            // `DuplicateRegistration` and `DeployerMissingCredentials`
320            // come solely from `register`.
321            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    // Extension bindings (`Path 3`) resolve against the same registry, but as
330    // an open N-per-env namespace they never contribute to `missing_slots`.
331    // `resolve_for_slot(Extension, ..)` degrades the slot check to "is this a
332    // registered extension"; a handler that serves a different slot (a core
333    // pack mis-bound as an extension) surfaces as a slot mismatch. With no
334    // extension handlers registered, every binding shows as an unknown kind —
335    // the honest answer until Phase D plug-ins register real handlers.
336    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/// Aggregated registry-resolution issues for `Environment.extensions`, reported
394/// under the `extensions` key in `doctor` output. Mirrors the per-`packs`
395/// buckets but omits `missing_slots` (the extension namespace is open).
396#[derive(Default)]
397struct ExtensionDoctor {
398    unknown_kinds: Vec<String>,
399    slot_mismatches: Vec<Value>,
400    version_skew: Vec<Value>,
401}
402
403/// `op env tool-check <env_id>`. Runs each binding's
404/// [`crate::env_packs::EnvPackHandler::preflight`] and aggregates the
405/// per-binding [`crate::tool_check::ToolCheck`] results into a structured
406/// outcome.
407///
408/// Bindings whose `kind` is not registered (or whose version is rejected by
409/// the env-pack registry) surface as `unresolved_bindings` so the operator
410/// sees both shape errors and tool-preflight errors in one report. The
411/// built-in `local` handlers return empty checks (in-process, no external
412/// tools); handlers that shell out populate this from the named-tool catalog.
413pub 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    // Extension preflight: an extension handler's `preflight()` runs exactly as
456    // a core handler's. Reported under their own keys so the operator sees core
457    // and extension tool checks distinctly; both feed the totals.
458    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
496/// `op env render <env_id> [--kind <descriptor>] [--output <dir>]` (plan §6
497/// step 10). Renders the env's declarative desired state through the
498/// deployer env-pack's
499/// [`ManifestRenderer`](crate::env_packs::ManifestRenderer) without
500/// applying anything — the artifact for direct-apply preview, GitOps
501/// repository handoff, or rendered-manifest handoff.
502///
503/// When the env's Deployer-slot binding records wizard answers
504/// (`answers_ref`), the renderer consumes them so operator overrides
505/// (custom namespace, digest-pinned image, replica count) propagate into
506/// the rendered manifests. When no answers are recorded, sandbox defaults
507/// apply.
508///
509/// With `--output <dir>` each object is written as
510/// `<NN>-<kind>-<name>.yaml` in apply order. The output directory is
511/// render-managed for files matching the `<NN>-*.yaml` pattern (one or
512/// more leading digits followed by `-`): stale managed files from
513/// previous renders are removed so `kubectl apply -f <dir>` can never
514/// resurrect an archived revision. Other files (e.g. `kustomization.yaml`)
515/// are left untouched and reported in the outcome.
516/// Without `--output` the manifests are embedded in the JSON outcome.
517pub 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    // Load answers from the binding IFF the env's Deployer-slot binding
551    // exists, its kind path matches the resolved descriptor, and
552    // `answers_ref` is `Some`.
553    let (answers, answers_ref_wire) = load_render_answers(store, &env, &descriptor)?;
554    // The K8s renderer's worker secrets identity (dev-store Secret vs. Vault SA
555    // + `VAULT_*` env) depends on the env's `Secrets`-slot binding, which the
556    // registry handler doesn't carry — resolve it and render through a handler
557    // that does. Other deployers ignore the secrets slot, so they keep the
558    // registry handler.
559    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
592/// `op env reconcile <env_id> [--kind <descriptor>]` — apply the env's
593/// declarative desired state to its live cluster and prune the workers of
594/// revisions no longer present. The apply-side counterpart of `render` (use
595/// `render` for a no-side-effect preview, or a GitOps repository handoff).
596///
597/// K8s deployer env-pack only today: applying rendered manifests to a cluster
598/// is K8s-specific, so other deployer kinds surface a `Conflict` (the AWS-ECS
599/// reconcile path is a later Phase D slice). The same `answers_ref` /
600/// `--kind` resolution as `render` applies.
601///
602/// The deployer connects through the binding's `kubeconfig_context` answer
603/// and authenticates with the ambient kubeconfig / in-cluster identity today;
604/// resolving the env's rotated ServiceAccount token (`credentials_ref` →
605/// bearer) rides the Phase D secrets sink.
606pub 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    // Reconcile applies rendered manifests to a live cluster — K8s-specific.
631    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    // Parity with render: confirm the kind is actually registered.
641    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    // Resolve the env's bound deployer credential to a ServiceAccount bearer
647    // token; `None` → connect with the ambient kubeconfig / in-cluster
648    // identity (the pre-closure behaviour). Fail-closed if a ref is bound but
649    // unresolvable. Beyond env-var / dev-store, this also reads the durable
650    // in-cluster identity Secret (ambient) so a fresh operator machine
651    // resolves a `--bind` credential it never wrote locally.
652    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    // Capture the env's local dev-store so reconcile delivers the operator's
660    // secrets to the worker (the K8s "no runtime secrets" gap). `None` when the
661    // env has no dev-store file yet — the worker's staging init is then a no-op.
662    let dev_secrets = read_dev_secrets_b64(store, &env_id)?;
663    // Resolve the env's `Secrets`-slot binding into the backend the worker
664    // resolves `secret://` refs against — dev-store (values shipped in via the
665    // Secret above) or Vault (pod identity + `VAULT_*` env, no values shipped).
666    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 the cluster was mutated as: "bound" = the env's
683            // credentials_ref resolved to a ServiceAccount bearer; "ambient" =
684            // the CLI's kubeconfig / in-cluster identity (no bound credential).
685            // Surfaced so a live mutation is never silent about which identity
686            // it ran as.
687            "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/// Connect to the cluster (binding's `kubeconfig_context`, with `bound_token`
697/// overriding the ambient identity when the env has a resolved credential) and
698/// converge desired state. Requires the `k8s-client` feature.
699#[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    // A bound (namespace-scoped) identity must not apply the cluster-scoped
715    // Namespace — `bootstrap --bind` already created it, and the bound Role
716    // grants no cluster-scoped verbs. The ambient kubeconfig / in-cluster
717    // identity (`None`) keeps managing the Namespace, so reconcile still
718    // bootstraps a fresh env unchanged.
719    let manage_namespace = bound_token.is_none();
720    run_k8s_async(async move {
721        // `bound_token`: the env's credentials_ref resolved to a ServiceAccount
722        // bearer (overrides the context's auth); `None` → the ambient
723        // kubeconfig / in-cluster identity.
724        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/// `k8s-client`-less builds cannot talk to a cluster.
740#[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
755/// Read the env's local dev-store and base64-encode it for the reconcile-time
756/// dev-store Secret. `Ok(None)` when no dev-store file exists yet (the worker's
757/// staging init is then a guarded no-op). A read error other than not-found is
758/// surfaced — a present-but-unreadable store should fail the reconcile rather
759/// than silently ship an empty Secret.
760fn 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
778/// `op env apply-revision <env_id> <revision_id> [--kind <descriptor>]` — bring
779/// a SINGLE revision's worker resources into agreement with its recorded
780/// lifecycle. A revision with cluster presence (Warming / Ready / Draining)
781/// has its worker Deployment + Service applied; an absent one (Staged / Failed
782/// / Archived / Inactive) has them torn down. The surgical counterpart of
783/// `reconcile`, which converges the WHOLE env — `apply-revision` assumes the
784/// env-level set (namespace, router) already exists (establish it with
785/// `reconcile`), so it only touches the one revision's worker pair.
786///
787/// K8s deployer env-pack only today (same gate as `reconcile`). Connects
788/// through the binding's `kubeconfig_context` answer with the ambient
789/// kubeconfig / in-cluster identity; resolving the env's bound ServiceAccount
790/// token rides the Phase D secrets sink.
791///
792/// # Known gaps (Phase D later slices)
793///
794/// - **Answer / namespace drift.** Both branches render the worker objects from
795///   the binding's *current* answers, so the teardown targets the namespace the
796///   answers name *now*. If a revision was warmed in namespace A and the binding
797///   namespace later changes to B, the archive branch deletes B's worker (a
798///   no-op) and reports success, leaving A's worker running — the same drift
799///   `reconcile`'s prune already has. A drift-safe teardown needs the
800///   per-revision applied-param snapshot or the label-based GC seam
801///   (`K8sCluster::list`) tracked with `reconcile`'s prune-scope gap; until then
802///   the binding namespace must stay stable while a revision is live (the
803///   wizard already states the namespace must match the bootstrap rules pack).
804pub 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    // Confirm the kind is actually registered (parity with reconcile).
830    let _handler = registry
831        .resolve_for_slot(CapabilitySlot::Deployer, &descriptor)
832        .map_err(|e| OpError::Conflict(e.to_string()))?;
833
834    // Applicability gate, BEFORE the per-revision lookup so an unsupported
835    // deployer kind rejects regardless of the revision arg: K8s (applies
836    // manifests to a cluster) and AWS-ECS (drives task sets) have live apply
837    // paths; any other registered deployer (e.g. local-process) does not.
838    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    // Present → apply the worker resources (warm); absent → tear them down
863    // (archive). Same B7 two-state presence model the renderer and reconcile
864    // use; the lifecycle→presence predicate is backend-agnostic.
865    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    // Backend dispatch: connect as the bound identity (fail-closed when a ref is
870    // bound but unresolvable, never a silent ambient fall-back) and drive the
871    // single revision's verb. Returns the identity used + the live resource name
872    // (K8s worker Deployment / ECS service) for the outcome. The applicability
873    // gate above guarantees the `else` arm is AWS-ECS.
874    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        // Resolve the env's Secrets backend so a Vault env's single-revision
884        // warm renders the worker with its Vault identity + `VAULT_*` env, not a
885        // default DevStore worker (parity with `reconcile` / `op env render`).
886        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            // Which Deployer verb the recorded lifecycle drove.
917            "action": action,
918            "worker_name": worker_name,
919            "answers_ref": answers_ref_wire,
920            // Identity the cluster was mutated as — see `reconcile`.
921            "identity": identity,
922        }),
923    ))
924}
925
926/// Connect to the cluster and dispatch the single revision's Deployer verb:
927/// `warm_revision` when present, `archive_revision` when absent. Requires the
928/// `k8s-client` feature.
929#[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        // `bound_token`: resolved ServiceAccount bearer (overrides the
948        // context's auth); `None` → ambient identity (same as reconcile).
949        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/// `k8s-client`-less builds cannot talk to a cluster.
970#[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/// True when the descriptor is the AWS-ECS deployer kind. `false` on builds
987/// without the AWS env-pack compiled in (`creds-aws` off) — the kind cannot be
988/// served, so the applicability gate rejects it.
989#[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
999/// Conflict for a deployer kind with no live single-revision apply path
1000/// (anything other than K8s / AWS-ECS — e.g. the local-process deployer, which
1001/// runs in-process and has nothing to apply to a remote target).
1002fn 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/// Dispatch `apply-revision` for a non-K8s deployer. Today only the AWS-ECS
1013/// env-pack has a live deploy path; every other registered kind is rejected.
1014/// Returns `(identity, worker_name)` — the AWS analogue of the K8s
1015/// `(bound|ambient, worker Deployment name)`.
1016#[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/// Fail-closed identity guard: a binding that pins a deployer role to assume
1048/// (`assume_role_arn`) MUST have a bound session, else the live call would
1049/// silently run as the ambient AWS identity — a tenant/account-isolation
1050/// footgun (the role was configured but never assumed, i.e. `op env bootstrap
1051/// --bind` was not run). `true` ⇒ refuse. (`aws_profile` honoring at deploy
1052/// time is a separate, still-deferred SDK client-builder slice — the binding
1053/// parser validates it but the verbs do not consume it yet.)
1054#[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/// Resolve the bound deployer session, parse the AWS-ECS construction inputs,
1060/// and enforce the fail-closed preconditions. Returns
1061/// `(identity_label, session, launch, region, target_group_pool)` — everything
1062/// `RealEcsTarget::resolve` needs plus the outcome's identity label. Shared by
1063/// `apply-revision` and `apply-traffic` so both honor the same guards.
1064///
1065/// Fails closed (before any AWS call) when the binding pins `assume_role_arn`
1066/// but no session is bound (`pinned_role_without_session`), and when the Fargate
1067/// launch config is absent.
1068#[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    // Bound STS session when the env declares one, else the ambient chain
1088    // (fail-closed if a ref is bound but unreadable). AWS analogue of the K8s
1089    // bound-ServiceAccount bearer.
1090    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/// Resolve the region-pinned AWS clients (with the bound session injected) and
1123/// wrap them in a handler. Shared by the AWS verb dispatchers so the
1124/// resolve + `with_target` boilerplate (and its error message) lives once.
1125#[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/// Connect to AWS and drive the single revision's ECS verb: `warm_revision`
1147/// when present, `archive_revision` when absent (mirrors
1148/// `apply_revision_k8s_cluster`). Returns `(identity, ECS service name)`.
1149/// Requires the `deploy-aws-ecs` feature.
1150#[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(&region, 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
1206/// `op env apply-traffic <env_id> <deployment_id> [--kind <descriptor>]`.
1207///
1208/// Pushes the env's recorded traffic split for one deployment to the live ALB
1209/// listener (AWS-ECS only). The split is recorded spec-only by `op traffic set`;
1210/// this verb makes it observable in the live runtime — the AWS analogue of
1211/// `apply-revision` for the routing side. K8s needs no such verb: its
1212/// in-process router reads the split from runtime-config, so the runtime applies
1213/// it without a deployer round-trip.
1214pub 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    // AWS-ECS only: the ALB listener is the live router, so the split must be
1239    // pushed to it. K8s serves splits from its in-process router (runtime
1240    // config), so there is no listener to write — `op traffic set` suffices.
1241    // Gate on the typed-const-backed helper (not a literal) so the path can't
1242    // drift from `AwsEcsDeployerHandler::DESCRIPTOR_PATH`.
1243    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    // Parity with apply-revision: confirm the kind is registered.
1252    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    // NOTE: when the binding records an ALB routing condition
1265    // (`alb_routing_host` / `alb_routing_path`), `apply_traffic_split` writes a
1266    // per-deployment listener rule so deployments coexist behind one listener;
1267    // with no routing condition it REPLACES the listener's default action
1268    // (whole-listener ownership), assuming the `alb_listener_arn` is dedicated to
1269    // this deployment — see the `op env apply-traffic` help WARNING.
1270    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 the ALB was mutated as (see apply-revision).
1281            "identity": identity,
1282            // The split this call enforced (mirrors the env's recorded entries).
1283            "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/// Connect to AWS and push one deployment's recorded traffic split to its ALB
1298/// listener via `apply_traffic_split` (a no-op live when no `alb_listener_arn`
1299/// is configured — the recorded split's invariants are still enforced). Returns
1300/// the identity used + the enforced split. Requires the `deploy-aws-ecs`
1301/// feature.
1302#[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(&region, 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
1353/// Load the deployer binding's recorded wizard answers for the render path.
1354///
1355/// Returns `(Some(json), env-relative path string)` when the binding exists
1356/// with `answers_ref`, the binding's kind path matches the descriptor, and
1357/// the file is readable. `(None, null)` when no answers are recorded.
1358/// Errors (fail-closed) when `answers_ref` is set but the file is missing,
1359/// unreadable, or contains invalid JSON — never silently falls back to
1360/// defaults.
1361///
1362/// `pub(crate)` so the credentials CLI path can read the same binding
1363/// answers when connecting a live validator client for `op credentials
1364/// requirements` (it needs `kubeconfig_context`).
1365pub(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
1383/// Read + parse a binding's `answers_ref` JSON file, enforcing that it lives
1384/// under the env dir (fail-closed on path escape or a missing file). Shared by
1385/// [`load_render_answers`] (Deployer slot) and [`load_secrets_answers`]
1386/// (Secrets slot).
1387fn 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    // Containment check: the answers file must live under the env dir.
1394    // `normalize_under_root` canonicalizes, so it ALSO fails when the file
1395    // simply does not exist — discriminate the two so a missing file gets
1396    // an actionable message instead of a path-escape one. Both fail closed.
1397    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
1428/// Resolve the env's `Secrets`-slot binding answers (the non-secret connection
1429/// config for a real backend), if the binding records an `answers_ref`. Mirrors
1430/// [`load_render_answers`] but for the `Secrets` slot.
1431fn 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
1444/// Resolve the env's `Secrets`-slot binding into the runtime secrets backend the
1445/// K8s manifests render. No binding or the dev-store kind → `DevStore`; the
1446/// Vault kind → a Vault backend whose non-secret connection config comes from
1447/// the binding's answers (`addr` + `role` required; mounts / prefix / transit /
1448/// namespace default to the provider's). An unknown secrets kind fails closed.
1449pub(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
1471/// Whether the env's `Secrets`-slot backend custodies secret values in the
1472/// local dev-store. No binding or the dev-store kind → `true` (the control
1473/// plane mints + writes webhook-secret values locally); any other kind (e.g.
1474/// Vault) → `false` (the operator seeds the value out-of-band and the control
1475/// plane only stamps the ref). Unlike [`resolve_secrets_backend`] this inspects
1476/// only the binding *kind*, never the connection answers, so it cannot fail —
1477/// endpoint provisioning must not be coupled to Vault-answer validity.
1478pub(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
1485/// Pure mapping of a Vault `Secrets`-binding's answers to a [`VaultBackend`]:
1486/// `addr` + `role` are required; the rest default to the provider's. Factored
1487/// out of [`resolve_secrets_backend`] so the field mapping + fail-closed
1488/// validation is unit-tested without a store.
1489pub(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
1540/// Resolve the `--kind` argument for `op env render` to a full
1541/// [`PackDescriptor`].
1542///
1543/// - Absent → the env's Deployer-slot binding (the common case).
1544/// - Full `<path>@<version>` → parsed as-is, so an operator can preview a
1545///   kind before binding it.
1546/// - Bare path → must match the env's deployer binding, whose pinned
1547///   version is reused. A bare path with no matching binding is rejected
1548///   rather than guessing a version.
1549fn 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
1572/// Resolve the deployer descriptor for a LIVE (cluster-mutating) verb
1573/// (`reconcile`, `apply-revision`).
1574///
1575/// Unlike [`resolve_render_kind`] — which backs the read-only `render` preview
1576/// and deliberately lets a full `--kind` describe an *unbound* deployer — a
1577/// live verb must mutate ONLY the env's declared deployer. The resolved
1578/// descriptor's **path** must equal the env's Deployer-slot binding path, so a
1579/// full `--kind` may override the binding's *version* (same deployer) but can
1580/// neither switch deployers (e.g. force `greentic.deployer.k8s` onto a
1581/// local-process env) nor apply to an env with no deployer binding at all.
1582/// Without this, `--kind <full k8s descriptor>` would drive K8s apply/teardown
1583/// against a cluster for an env that was never K8s-bound.
1584pub(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
1605/// Result of [`write_rendered_objects`].
1606struct WriteResult {
1607    /// Files written by this render.
1608    files: Vec<String>,
1609    /// Render-managed `.yaml` files from a previous render that were removed
1610    /// from disk because they are no longer in the desired state.
1611    removed_stale_files: Vec<String>,
1612    /// Non-managed `.yaml` or other files present in the directory that were
1613    /// left untouched (e.g. a user's `kustomization.yaml`).
1614    unmanaged_files: Vec<String>,
1615}
1616
1617/// Write each rendered object as a YAML file under `dir` (created if
1618/// missing). The output directory is render-managed for files matching
1619/// the `<NN>-*.yaml` pattern: stale managed files from previous renders
1620/// are deleted so `kubectl apply -f <dir>` can never resurrect an archived
1621/// revision. Other files are left alone and reported.
1622///
1623/// All objects are pre-serialized before any filesystem write so a
1624/// serialization failure leaves the directory untouched.
1625fn 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    // 1. Pre-serialize ALL objects before touching the filesystem.
1635    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    // 2. Write files.
1646    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    // 3. Scan and clean up stale render-managed files.
1655    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        // Non-yaml files are silently ignored.
1671    }
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
1682/// Whether a file name matches the render-managed pattern: a non-empty
1683/// leading run of ASCII digits followed by `-` and ending in `.yaml`.
1684/// Our naming convention is `<NN>-<kind>-<name>.yaml` where NN can exceed
1685/// 99, so we accept 1+ digits. Implemented without a regex dependency.
1686fn is_render_managed_name(name: &str) -> bool {
1687    let bytes = name.as_bytes();
1688    // Must end in .yaml (already checked by caller, but be defensive).
1689    if !name.ends_with(".yaml") {
1690        return false;
1691    }
1692    // Find the first non-digit byte.
1693    let digit_end = bytes.iter().position(|b| !b.is_ascii_digit()).unwrap_or(0);
1694    // Must have at least one digit followed by `-`.
1695    digit_end > 0 && bytes.get(digit_end) == Some(&b'-')
1696}
1697
1698/// `<NN>-<kind>-<name>.yaml`, lowercased and restricted to `[a-z0-9.-]`
1699/// so a renderer-supplied name can never traverse out of the output dir.
1700fn 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
1724/// `op env init`. Idempotent bootstrap of the `local` env with its five
1725/// default env-pack bindings; on first init only also seeds the operator
1726/// key into the env trust root so signature-gated verbs (revenue-policy,
1727/// bundle/revision DSSE) work out of the box (N1.4). The gate sits on
1728/// `<env_dir>/trust-root.json`'s presence, so a routine `init` cannot
1729/// re-grant a key revoked via `trust-root remove`.
1730///
1731/// Outcome JSON:
1732/// - `outcome` discriminator: `"created"` | `"healed"` | `"untouched"`.
1733/// - `trust_root`: seeded `{operator_key_id, public_pem, trusted_key_count}`
1734///   on first init, `null` thereafter.
1735pub 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    // Validate the URL up-front so a malformed value is rejected before any
1755    // disk state is touched. The "URL given AND env exists → reject" gate
1756    // fires INSIDE `ensure_local_environment`'s per-env flock so it's both
1757    // race-free and stat-free here.
1758    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        // Env is now persisted. Mark committed so a subsequent trust-root
1773        // seed failure + audit-append failure still fail-closes (otherwise
1774        // the audit-append failure would demote to `tracing::warn!` and
1775        // hide the missing audit record for the env we just wrote).
1776        committed.mark_committed();
1777        // N1.4: seed operator key on first init only — see
1778        // `LocalFsStore::seed_trust_root_if_absent` (Phase D PR-3a.2).
1779        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
1822/// `op env destroy <env_id> --confirm`. Removes the env's on-disk state.
1823///
1824/// Force-free safety net: the caller must pass `confirm = true`. The
1825/// `--confirm` flag is the operator-binary's responsibility; this library
1826/// just enforces the gate.
1827pub 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        // The A2 trait does not yet expose a remove API. Destructive removal
1859        // ships with the bundle-deployment retention path (B-phase); A7 wires
1860        // the audit + authorize surface so the destroy intent is logged today.
1861        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
1886/// Hand-written JSON Schema stub for [`EnvCreatePayload`]. Replaces the full
1887/// schemars derive until A1's deferred `schemars` wiring lands; the operator
1888/// surface still gets a useful machine-readable description of the payload.
1889pub 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/// Payload accepted by `op env init`. Init is otherwise a fixed-shape
1908/// bootstrap of the canonical `local` env; the only optional input is the
1909/// public URL persisted on the env's `host_config`.
1910#[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    /// Build the typed payload for [`init`]. Init takes no `--answers` /
1918    /// `--schema` JSON fallback — the only input is `--public-url`. The
1919    /// returned payload is the source of truth (an absent flag is `None`).
1920    pub fn into_payload(self, _flags: &OpFlags) -> Result<EnvInitPayload, OpError> {
1921        Ok(EnvInitPayload {
1922            public_base_url: self.public_url,
1923        })
1924    }
1925}
1926
1927/// Validate an optional `public_base_url` against the spec validator and wrap
1928/// the spec error in `OpError::InvalidArgument` with the canonical
1929/// `"public_base_url: …"` field prefix. Centralizes the format string so the
1930/// 5 entry points (`env create`, `env update`, `env init`, `env set-public-url`,
1931/// `config set`) report identical operator-facing errors.
1932pub(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
1937/// Validate an `Option<String>` carrying a `public_base_url` payload field.
1938/// Returns `Ok(None)` when the field was absent, the canonical form when
1939/// present and valid, and `Err(OpError::InvalidArgument)` when present and
1940/// invalid. The 5 entry points all share this lift to keep the
1941/// `.as_deref().map(parse).transpose()` boilerplate in one place.
1942pub(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
1948// ---- Inline-args guards (env-local mirror of messaging.rs's pattern) ------
1949//
1950// Both `EnvCreateArgs::into_payload` and `EnvUpdateArgs::into_payload` need to
1951// (a) reject `--answers` mixed with inline flags and (b) report the partial-
1952// inline set that's missing required fields. The shape is identical to the
1953// `messaging.rs` helpers but those are file-private; promoting them to
1954// `cli::mod.rs` for cross-noun reuse is a follow-up cleanup.
1955
1956fn 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
1976/// Helper used by both `EnvCreateArgs` and `EnvUpdateArgs`: enforce the
1977/// inline-vs-answers contract and collect required-flag presence. Returns
1978/// `Ok(None)` when no inline flags were supplied (caller falls through to
1979/// `--answers`), or `Ok(Some((env_id, name)))` when the required pair is
1980/// present.
1981fn 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    /// Build an [`EnvCreatePayload`] from CLI flags. Returns:
2019    /// - `Ok(None)` when no inline flag is set — caller falls through to
2020    ///   `--answers`.
2021    /// - `Err(OpError::InvalidArgument)` when SOME inline flags are set but
2022    ///   required ones (`environment_id`, `name`) are missing, OR when
2023    ///   inline flags AND `--answers` are both supplied (mutual exclusion).
2024    /// - `Ok(Some(payload))` on the fully-specified inline path.
2025    ///
2026    /// `verb` is the public verb name (`"create"` / `"update"`) folded into
2027    /// the error message.
2028    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    /// Build an [`EnvCreatePayload`] from CLI flags for the `update` verb.
2064    /// Returns:
2065    /// - `Ok(None)` when no inline flag is set — caller falls through to
2066    ///   `--answers`.
2067    /// - `Err(OpError::InvalidArgument)` when SOME inline flags are set but
2068    ///   required ones (`environment_id`, `name`) are missing, OR when
2069    ///   inline flags AND `--answers` are both supplied (mutual exclusion).
2070    /// - `Ok(Some(payload))` on the fully-specified inline path.
2071    ///
2072    /// The produced payload always sets `listen_addr: None` and
2073    /// `public_base_url: None` — those fields are not exposed on the update
2074    /// verb. URL changes go through `op env set-public-url`; listen-addr
2075    /// changes through `op config set --listen-addr`.
2076    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
2103/// `op env set-public-url <env_id> <URL>`. Dedicated verb that ONLY mutates
2104/// `host_config.public_base_url`; safer to expose than `op config set`
2105/// (which can update name/region/tenant-org/listen-addr in the same call)
2106/// when callers just want to point the env at a different origin.
2107pub 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        // Surface check: the create response (EnvSummary) must include the
2257        // bind address, otherwise operators can't see what they just set.
2258        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        // Storage check: the persisted env carries the typed SocketAddr.
2265        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        // Store must be untouched — `op env list` sees zero envs.
2293        assert!(store.list().unwrap().is_empty());
2294    }
2295
2296    #[test]
2297    fn create_preserves_corrupt_environment_json_instead_of_overwriting() {
2298        // Regression for the `is_ok()`-existence-check footgun in
2299        // `LocalFsStore::create_environment`. A corrupt `environment.json`
2300        // must NOT be treated as "env doesn't exist" — otherwise create
2301        // would silently overwrite a recoverable file with an empty env.
2302        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        // Anything but Conflict — the env "appears to exist but is unreadable",
2323        // which is a Store error, not a duplicate.
2324        assert!(
2325            !matches!(err, OpError::Conflict(_)),
2326            "expected store/json error, got Conflict (overwrote the file?): {err:?}"
2327        );
2328        // Crucially: the corrupt file is byte-for-byte preserved.
2329        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        // `None` on disk → `resolved_listen_addr()` returns `DEFAULT_LISTEN_ADDR`.
2355        // This is the documented divergence from `op env init` (the local
2356        // bootstrap), which writes `Some(DEFAULT_LISTEN_ADDR)` explicitly.
2357        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        // PR-3a.3: `op env update` now skips `None` fields instead of wiping
2367        // them. Matches the trait-shell rule from PR-3a.1 ("None values are
2368        // skipped") and aligns env update with `op config set` semantics so
2369        // one HTTP endpoint covers both verbs. Pins the behavior so future
2370        // refactors can't regress to wholesale-overwrite.
2371        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        // PR-3a.3 follow-up: `FieldUpdate::Clear` writes `None` into
2413        // optional host_config fields so callers can un-set a region,
2414        // tenant_org_id, listen_addr, or public_base_url.
2415        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        // Name stays unchanged (Clear is not available for required fields).
2453        assert_eq!(env.name, "local");
2454    }
2455
2456    #[test]
2457    fn update_environment_keep_preserves_set_does_not_collide_with_clear() {
2458        // Tri-state regression: a mix of Keep, Set, and Clear on different
2459        // fields in the same payload must each apply independently.
2460        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, // preserve
2476                    tenant_org_id: FieldUpdate::Set("new-org".to_string()), // overwrite
2477                    listen_addr: FieldUpdate::Clear, // clear
2478                    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        // Clear on a field that is already None is a no-op — must not panic
2509        // or error.
2510        let dir = tempdir().unwrap();
2511        let store = LocalFsStore::new(dir.path());
2512        let env = make_env("local");
2513        // make_env creates host_config with all optional fields as None.
2514        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        // `UpdateEnvironmentPayload::default()` must be a no-op patch —
2541        // backward compat with code that relied on `..Default::default()`.
2542        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        // env_id stays "local" so the A7 authorize gate allows the call
2573        // through; the NotFound branch is what we want to assert.
2574        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        // No `added_slots` key on "created" — that's a "healed" thing.
2626        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        // Seed an env with only the deployer slot bound — mimics the
2639        // `op env create local` → empty-packs → user adds one binding flow.
2640        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        // N1.4: env init folds the former `trust-root bootstrap` step so
2689        // first-run installs end up with a signature-ready env in one
2690        // command. The trust-root summary rides on the init outcome under
2691        // a nested `trust_root` key (non-null on FIRST init only).
2692        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        // The trust-root file on disk must contain the same key as the
2725        // dedicated `trust-root list` verb would report.
2726        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        // The seed gate sits on `<env_dir>/trust-root.json`'s presence. A
2735        // second init must report `trust_root: null` and leave the
2736        // already-seeded key alone — no duplicate entry, no replace.
2737        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        // `is_some_and(is_null)` distinguishes "key present, null value"
2746        // from "key absent" — bare `.is_null()` on indexed Value returns
2747        // true for both, masking a future serde change that drops the key.
2748        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        // SECURITY REGRESSION (Codex N1.4 adversarial review): `init` is a
2766        // routine maintenance verb. `trust-root remove` is the documented
2767        // revocation boundary for revenue-policy / bundle DSSE signing.
2768        // Once the operator has explicitly revoked the operator key, a
2769        // later `init` MUST NOT silently re-grant trust — explicit
2770        // re-grant goes through `gtc op trust-root bootstrap`.
2771        let dir = tempdir().unwrap();
2772        let store = LocalFsStore::new(dir.path());
2773
2774        // First init seeds the operator key.
2775        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        // Operator explicitly revokes the operator key.
2782        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        // Second init MUST NOT re-seed. Use the key-present-and-null check
2799        // so a future serde regression that drops the field is also caught.
2800        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        // No packs bound → every CORE slot missing. The N-per-env slots
2830        // (Messaging, Extension) live in their own collections and never
2831        // appear in missing_slots.
2832        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        // No extension handler is registered in `with_builtins`, so an
2851        // extension binding surfaces under the `extensions` block as an
2852        // unknown kind — never in `missing_slots`.
2853        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        // The extension's path is NOT in missing_slots / unknown_kinds (the
2875        // core-`packs` buckets).
2876        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        // Unknown descriptor: no native handler backs `acme-vault`.
2891        env.packs.push(make_binding(
2892            greentic_deploy_spec::CapabilitySlot::Secrets,
2893            "greentic.secrets.acme-vault@1.0.0",
2894        ));
2895        // Slot mismatch: the State slot bound to a deployer handler's descriptor.
2896        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        // Known path + correct slot, but a version the built-in doesn't implement.
2957        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        // A version-skewed binding is not also reported as unknown.
2978        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        // One resolvable built-in + one bogus kind so the verb has to
3043        // distinguish the two paths.
3044        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        // Named (non-`local`) envs are first-class on the local FS store
3121        // (fs-ownership is the authz boundary). `op env create prod` succeeds,
3122        // persists the env, and audits the mutation under the `local-owner`
3123        // policy. (Shared-store RBAC lives on the remote path, not here.)
3124        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        // environment.json was persisted.
3140        let env_json = dir.path().join("prod").join("environment.json");
3141        assert!(env_json.exists(), "create must persist environment.json");
3142        // The mutation was audited as an allowed `local-owner` decision.
3143        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    // ---- Change C: env public_url + auto-setWebhook ------------------------
3188
3189    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        // `--answers` path: no inline flag → caller delegates to --answers.
3205        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        // Codex-finding regression guard (same shape as the messaging.endpoint
3236        // verbs): SOME inline flags + missing required ones MUST error out
3237        // explicitly — never silently drop back to `--answers` which could
3238        // mutate the wrong env.
3239        let args = EnvCreateArgs {
3240            environment_id: None, // missing required
3241            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        // Mutual exclusion: inline flags + --answers together is ambiguous and
3256        // rejected at the converter (Change B precedent).
3257        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        // No env was written.
3310        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        // init with --public-url on an existing env must error out. The URL is
3334        // only applied on creation; overwriting requires `op env set-public-url`.
3335        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        // First env's URL unchanged.
3355        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        // init WITHOUT --public-url on an existing env must remain the silent
3368        // no-op/heal bootstrap it always was.
3369        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        // No env was written.
3422        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        // Trailing slash normalized away — runtime precedence matching is
3462        // origin-equality so the canonical form must drop the `/`.
3463        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        // Exact `OpError` variant depends on `audit_and_record` plumbing —
3496        // tighten to NotFound once the audit wrapper stops eagerly wrapping
3497        // store errors. For now assert no env file was written by the verb.
3498        let _ = err;
3499        assert!(!store.exists(&EnvId::try_from("missing").unwrap()).unwrap());
3500    }
3501
3502    // ---- Fix 1: EnvUpdateArgs does not expose --public-url or --listen-addr ----
3503
3504    #[test]
3505    fn env_update_args_does_not_expose_public_url_inline() {
3506        // Even when reached via --answers JSON (the legacy contract), the
3507        // produced payload carries public_base_url: None.
3508        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        // SOME inline flags + missing required → error, never silent --answers fallthrough.
3526        let args = EnvUpdateArgs {
3527            environment_id: None, // missing required
3528            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    // -- render -------------------------------------------------------------
3539
3540    use crate::cli::dispatch::EnvRenderArgs;
3541    use std::path::PathBuf;
3542
3543    /// `K8sParams::for_env` env-level set: Namespace, env-store ConfigMap,
3544    /// runtime-config ConfigMap, router Deployment + Service + PDB, 6
3545    /// NetworkPolicies (the worker- and router-egress policies are always
3546    /// rendered; their egress rule toggles allow-all/deny by pullability — see
3547    /// the manifests-crate render tests). Every env renders exactly these.
3548    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            &reg,
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            &reg,
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        // A render-managed pattern file (digits + dash prefix) from a
3655        // previous render — must be deleted.
3656        std::fs::write(
3657            out.join("99-deployment-gtc-worker-old.yaml"),
3658            "kind: Deployment\n",
3659        )
3660        .unwrap();
3661        // A user-owned file — must survive.
3662        std::fs::write(
3663            out.join("kustomization.yaml"),
3664            "apiVersion: kustomize.config.k8s.io/v1beta1\n",
3665        )
3666        .unwrap();
3667        // Non-YAML — silently ignored.
3668        std::fs::write(out.join("notes.txt"), "not a manifest\n").unwrap();
3669        let outcome = render(
3670            &store,
3671            &reg,
3672            &OpFlags::default(),
3673            render_args("zain", None, Some(out.clone())),
3674        )
3675        .unwrap();
3676        // Managed stale file was removed from disk.
3677        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        // Unmanaged file survives on disk.
3686        assert!(out.join("kustomization.yaml").exists());
3687        assert_eq!(
3688            outcome.result.get("unmanaged_files"),
3689            Some(&json!(["kustomization.yaml"])),
3690        );
3691        // Re-render into the same dir is idempotent: no stale files.
3692        let outcome2 = render(
3693            &store,
3694            &reg,
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        // Bare path matching the binding reuses its pinned version.
3716        let outcome = render(
3717            &store,
3718            &reg,
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        // A bare path that matches nothing is rejected — no version guessing.
3728        let err = render(
3729            &store,
3730            &reg,
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            &reg,
3747            &OpFlags::default(),
3748            render_args("bare", None, None),
3749        )
3750        .unwrap_err();
3751        assert!(matches!(err, OpError::Conflict(_)), "{err}");
3752        // An explicit full descriptor renders an unbound kind (preview).
3753        let outcome = render(
3754            &store,
3755            &reg,
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            &reg,
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            &reg,
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, &reg, &flags, render_args("zain", None, None)).unwrap();
3818        assert!(outcome.result.get("input_schema").is_some());
3819    }
3820
3821    // -- reconcile ----------------------------------------------------------
3822
3823    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    /// A non-K8s deployer kind cannot be reconciled to a cluster — the verb
3833    /// rejects before any connect attempt (here the env's default binding is
3834    /// local-process).
3835    #[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            &reg,
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            &reg,
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, &reg, &flags, reconcile_args("zain", None)).unwrap();
3885        assert_eq!(outcome.op, "reconcile");
3886        assert!(outcome.result.get("input_schema").is_some());
3887    }
3888
3889    // -- apply-revision -----------------------------------------------------
3890
3891    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    /// A deployer kind with no live apply path (here: local-process) is
3906    /// rejected at the applicability gate, before the per-revision lookup (so a
3907    /// bogus revision id is irrelevant here). K8s and AWS-ECS are admitted.
3908    #[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            &reg,
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    /// The AWS-ECS deployer kind IS admitted past the applicability gate (the
3934    /// PR-3c-2b live-wiring change): with a bogus revision id the verb now falls
3935    /// through to the per-revision lookup and returns `NotFound`, where it
3936    /// previously rejected the whole kind with a `Conflict`. Proves the gate no
3937    /// longer hard-rejects AWS — no AWS call is reached (revision lookup fails
3938    /// first), so the test is deterministic without credentials.
3939    #[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            &reg,
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    // -- apply-traffic ------------------------------------------------------
3965
3966    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            &reg,
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    /// A non-AWS deployer (here: local-process) is rejected — K8s and the local
4001    /// deployer serve splits from their runtime router, so there is no listener
4002    /// for `apply-traffic` to push to.
4003    #[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            &reg,
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    /// An AWS-ECS env IS admitted past the gate, but a binding without a Fargate
4029    /// launch config is rejected before any AWS call (deterministic, no creds).
4030    /// Proves the AWS path is reached AND the launch precondition fires first.
4031    #[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            &reg,
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    /// Identity guard: a binding that pins `assume_role_arn` must have a bound
4058    /// session, else the live AWS call would silently run as the ambient
4059    /// identity (a tenant/account-isolation footgun). Only that exact case
4060    /// refuses; an unpinned env legitimately falls back to ambient.
4061    #[cfg(all(feature = "creds-aws", feature = "deploy-aws-ecs"))]
4062    #[test]
4063    fn pinned_role_without_session_refuses_only_role_without_session() {
4064        // role pinned + no session → refuse (the footgun)
4065        assert!(pinned_role_without_session(
4066            Some("arn:aws:iam::1:role/dep"),
4067            false
4068        ));
4069        // role pinned + session present → ok (the session IS the assumed role)
4070        assert!(!pinned_role_without_session(
4071            Some("arn:aws:iam::1:role/dep"),
4072            true
4073        ));
4074        // no role pinned → ambient fallback is intentional, never refuse
4075        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            &reg,
4087            &OpFlags::default(),
4088            apply_revision_args("ghost", "00000000000000000000000000", None),
4089        )
4090        .unwrap_err();
4091        assert!(matches!(err, OpError::NotFound(_)), "{err}");
4092    }
4093
4094    /// A K8s env with a revision id that isn't staged → NotFound, surfaced
4095    /// AFTER the applicability gate (the env IS K8s) but before any connect.
4096    #[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            &reg,
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    /// An unparseable revision id is rejected as InvalidArgument (after the
4115    /// applicability gate passes).
4116    #[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            &reg,
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            &reg,
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    /// A live verb must not be forced onto a deployer the env isn't bound to: a
4152    /// full `--kind` K8s override against a local-process-bound env is rejected
4153    /// at the binding-match guard, before any cluster connect (the Codex F1
4154    /// fix). The same guard backs `reconcile`.
4155    #[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            &reg,
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            &reg,
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    // ---- Fix 1: answers_ref consumption ------------------------------------
4213
4214    #[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        // Build a binding WITH answers_ref.
4222        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        // Write the answers file under the env dir.
4227        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            &reg,
4238            &OpFlags::default(),
4239            render_args("zain", None, None),
4240        )
4241        .unwrap();
4242        // The namespace override must reach the rendered manifests.
4243        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        // answers_ref appears in the outcome.
4254        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        // Do NOT write the answers file.
4272        let err = render(
4273            &store,
4274            &reg,
4275            &OpFlags::default(),
4276            render_args("zain", None, None),
4277        )
4278        .unwrap_err();
4279        // The MESSAGE matters: a missing file must say so, not surface the
4280        // containment-check's "escapes env directory" canonicalize failure.
4281        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        // `..`-relative ref pointing at an EXISTING file outside the env dir:
4298        // must be rejected as an escape, never read.
4299        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            &reg,
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            &reg,
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            &reg,
4368            &OpFlags::default(),
4369            render_args("zain", None, None),
4370        )
4371        .unwrap_err();
4372        assert!(matches!(err, OpError::Conflict(_)), "got {err:?}");
4373    }
4374
4375    // ---- Fix 3: plug-in registry threading ---------------------------------
4376
4377    #[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        /// A test-only deployer handler with a custom renderer.
4384        #[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                // Reuse local-process credentials to satisfy the register gate.
4399                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            &reg,
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    // ---- secrets backend resolution (Phase E.3) ----------------------------
4458
4459    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        // No Secrets binding → dev-store (back-compat with sandbox envs).
4467        let env = make_env("local");
4468        assert!(matches!(
4469            resolve_secrets_backend(&store, &env).unwrap(),
4470            SecretsBackend::DevStore
4471        ));
4472        // An explicit dev-store binding → dev-store.
4473        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        // No Secrets binding → custodial dev-store.
4487        assert!(secrets_backend_is_dev_store(&make_env("local")));
4488
4489        // Explicit dev-store binding → custodial.
4490        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        // Vault binding → NOT custodial: the operator seeds the value out-of-band
4498        // and the control plane only stamps the ref. Note this never parses the
4499        // Vault answers, so it does not fail closed like `resolve_secrets_backend`.
4500        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        // Vault binding with no `answers_ref` → the required addr is missing.
4529        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        // addr is trimmed; the rest fall back to the provider defaults.
4542        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}