Skip to main content

greentic_deployer/cli/
mod.rs

1//! `gtc op` command surface (`A3` of `plans/next-gen-deployment.md`).
2//!
3//! Library-level command implementations for the operator wizard. Each
4//! submodule exposes one noun:
5//!
6//! - [`mod@env`] — Environment CRUD (`create`, `update`, `list`, `show`, `doctor`, `destroy`)
7//! - [`env_packs`] — Env-pack bindings (`add`, `update`, `remove`, `rollback`, `list`)
8//! - [`bundles`] — Application bundle deployments (`add`, `update`, `remove`, `list`)
9//! - [`revisions`] — Revision lifecycle (`stage`, `warm`, `drain`, `archive`, `list`)
10//! - [`traffic`] — Traffic-split management (`set`, `show`, `rollback`)
11//! - [`config`] — Host/setup/runtime config inspection (`show`, `set`)
12//! - [`credentials`] — Credential modes (`requirements`, `bootstrap`, `rotate`)
13//! - [`secrets`] — Secrets management (`list`, `put`, `get`, `rotate`)
14//!
15//! Every command pair honors:
16//!
17//! - `--schema` — dump the JSON schema of the input payload it would accept,
18//!   then exit `0`. Useful for non-interactive callers wanting to generate an
19//!   `--answers` payload programmatically.
20//! - `--answers <path>` — read a JSON/YAML payload from disk for a
21//!   non-interactive replay. Interactive prompting is out of scope for A3;
22//!   wizard rendering lands in A10.
23//!
24//! Heavy logic that depends on env-pack handlers (deployer dispatch, secrets
25//! backend, telemetry exporter, etc.) is deferred to later Phase A gates (A5,
26//! A7, A9) and Phase C. A3 wires the command *surface* against the
27//! `EnvironmentStore` from A2 and intentionally stubs paths that would
28//! require those gates with a clear `not-yet-implemented` error.
29//!
30//! ## Output
31//!
32//! Every command writes structured JSON to a `Write` sink chosen by the
33//! caller. Stable schema: `{ "op": "<verb>", "noun": "<noun>", "result": ... }`
34//! for success; `{ "op": "<verb>", "noun": "<noun>", "error": { ... } }` for
35//! failure. Human-readable rendering is layered on by the caller (operator
36//! binary or `gtc op` passthrough); the library stays output-format-neutral.
37
38use std::path::PathBuf;
39
40pub mod bootstrap;
41pub mod bundle_fetch;
42pub mod bundle_stage;
43pub mod bundles;
44pub mod config;
45pub mod credentials;
46pub mod deploy;
47pub mod dispatch;
48pub(crate) mod dispatch_remote;
49pub mod env;
50pub mod env_apply;
51pub mod env_manifest;
52pub mod env_packs;
53pub mod extensions;
54pub mod messaging;
55pub mod migrate;
56pub mod migrate_state;
57pub mod pack_config_stage;
58pub mod revisions;
59pub mod secrets;
60pub mod traffic;
61pub mod trust_root;
62pub mod updates;
63// pub mod bundles;
64// pub mod revisions;
65// pub mod traffic;
66// pub mod config;
67// pub mod credentials;
68// pub mod secrets;
69
70#[cfg(test)]
71pub(crate) mod tests_common;
72
73use serde::Serialize;
74use serde_json::Value;
75use thiserror::Error;
76
77use crate::environment::{
78    AuditDecision, AuditEvent, AuditLog, AuditResult, LifecycleError, LocalFsStore, StoreError,
79    authorize_local_owner, current_local_actor,
80};
81use greentic_deploy_spec::{EnvId, SpecError};
82
83/// Top-level error shared across `op` command implementations.
84#[derive(Debug, Error)]
85pub enum OpError {
86    #[error("storage error: {0}")]
87    Store(#[from] StoreError),
88    #[error("spec validation failed: {0}")]
89    Spec(#[from] SpecError),
90    #[error("io error on {path}: {source}")]
91    Io {
92        path: PathBuf,
93        #[source]
94        source: std::io::Error,
95    },
96    #[error("invalid json/yaml in {path}: {message}")]
97    AnswersParse { path: PathBuf, message: String },
98    #[error("schema generation failed: {0}")]
99    SchemaGeneration(String),
100    #[error("invalid argument: {0}")]
101    InvalidArgument(String),
102    #[error("not found: {0}")]
103    NotFound(String),
104    #[error("not yet implemented: {0}")]
105    NotYetImplemented(String),
106    #[error("conflict: {0}")]
107    Conflict(String),
108    /// A remote `bundle_source_uri` could not be fetched (registry unreachable,
109    /// auth/transport failure, or the artifact is missing). The fetched bytes
110    /// are gated against the manifest `bundle_digest` separately by the caller.
111    #[error("bundle fetch failed: {0}")]
112    Fetch(String),
113    #[error("unauthorized: {policy} — {reason}")]
114    Unauthorized { policy: String, reason: String },
115    #[error("audit log write failed after the mutation committed: {0}")]
116    Audit(String),
117    #[error("revenue policy: {0}")]
118    RevenuePolicy(#[from] crate::environment::BundleDeploymentError),
119    #[error("trust root: {0}")]
120    TrustRoot(#[from] crate::environment::TrustRootError),
121    /// Operator-key load or generation failed. Distinct from `RevenuePolicy`
122    /// so trust-root / bundle verbs surface the right noun in their error
123    /// envelopes.
124    #[error("operator key: {0}")]
125    OperatorKey(#[from] crate::operator_key::OperatorKeyError),
126}
127
128impl From<LifecycleError> for OpError {
129    fn from(err: LifecycleError) -> Self {
130        match err {
131            LifecycleError::NotFound {
132                env_id,
133                revision_id,
134            } => OpError::NotFound(format!(
135                "revision `{revision_id}` not found in env `{env_id}`"
136            )),
137            LifecycleError::InvalidTransition { from, to } => {
138                OpError::Conflict(format!("spec rejects transition `{from:?} → {to:?}`"))
139            }
140            LifecycleError::Conflict {
141                revision_id,
142                actual,
143                expected_starts,
144            } => OpError::Conflict(format!(
145                "revision `{revision_id}` is in `{actual:?}`; expected one of {expected_starts:?}"
146            )),
147            LifecycleError::EmptyChain => {
148                OpError::InvalidArgument("empty transition chain".to_string())
149            }
150            LifecycleError::ActiveTrafficReference {
151                revision_id,
152                splits,
153            } => {
154                let detail = splits
155                    .iter()
156                    .map(|s| {
157                        format!(
158                            "deployment `{}` / bundle `{}` ({}bps)",
159                            s.deployment_id, s.bundle_id, s.weight_bps
160                        )
161                    })
162                    .collect::<Vec<_>>()
163                    .join(", ");
164                OpError::Conflict(format!(
165                    "revision `{revision_id}` is still referenced by live traffic split(s): [{detail}]; rebalance via `gtc op traffic set` before archiving"
166                ))
167            }
168            LifecycleError::HealthGateFailed {
169                revision_id,
170                failed_checks,
171                message,
172            } => {
173                let checks = if failed_checks.is_empty() {
174                    String::from("none reported")
175                } else {
176                    failed_checks
177                        .iter()
178                        .map(|c| format!("{c:?}"))
179                        .collect::<Vec<_>>()
180                        .join(", ")
181                };
182                OpError::Conflict(format!(
183                    "revision `{revision_id}` failed warm/ready health gate (checks: [{checks}]): {message}"
184                ))
185            }
186            LifecycleError::Store(source) => OpError::Store(source),
187        }
188    }
189}
190
191impl OpError {
192    /// Short machine code for the error envelope (`error.kind`).
193    pub fn kind(&self) -> &'static str {
194        match self {
195            OpError::Store(_) => "store",
196            OpError::Spec(_) => "spec",
197            OpError::Io { .. } => "io",
198            OpError::AnswersParse { .. } => "answers-parse",
199            OpError::SchemaGeneration(_) => "schema-generation",
200            OpError::InvalidArgument(_) => "invalid-argument",
201            OpError::NotFound(_) => "not-found",
202            OpError::NotYetImplemented(_) => "not-yet-implemented",
203            OpError::Conflict(_) => "conflict",
204            OpError::Fetch(_) => "fetch",
205            OpError::Unauthorized { .. } => "unauthorized",
206            OpError::Audit(_) => "audit",
207            OpError::RevenuePolicy(_) => "revenue-policy",
208            OpError::TrustRoot(_) => "trust-root",
209            OpError::OperatorKey(_) => "operator-key",
210        }
211    }
212}
213
214/// Context for [`audit_and_record`] — everything the helper needs to build an
215/// [`AuditEvent`] except the mutation's generations (which the closure
216/// returns as [`AuditGens`]).
217#[derive(Debug)]
218pub(crate) struct AuditCtx {
219    pub env_id: EnvId,
220    pub noun: &'static str,
221    pub verb: &'static str,
222    pub target: Value,
223    pub idempotency_key: Option<String>,
224}
225
226/// Downcast a [`crate::environment::StoreError`] so domain-specific failures
227/// surface under their own [`OpError`] noun instead of being flattened into
228/// `OpError::Store`. Without this, the error envelope's `error.kind` regresses
229/// from `"trust-root"` / `"operator-key"` (the typed CLI noun) to the generic
230/// `"store"` for every CLI verb that calls through to typed
231/// [`crate::environment::mutations::EnvironmentMutations`] verbs.
232///
233/// Cross-cutting concern shared by every verb-group migration in the PR-3a.*
234/// train — lives here so 14 future migrations don't each invent a copy.
235pub(crate) fn map_store_err_preserving_noun(e: crate::environment::StoreError) -> OpError {
236    match e {
237        crate::environment::StoreError::TrustRoot(inner) => OpError::TrustRoot(inner),
238        crate::environment::StoreError::OperatorKey(inner) => OpError::OperatorKey(inner),
239        crate::environment::StoreError::RevenuePolicy(inner) => OpError::RevenuePolicy(inner),
240        crate::environment::StoreError::Conflict(msg) => OpError::Conflict(msg),
241        crate::environment::StoreError::InvalidArgument(msg) => OpError::InvalidArgument(msg),
242        crate::environment::StoreError::NotFound(id) => {
243            OpError::NotFound(format!("environment `{id}`"))
244        }
245        crate::environment::StoreError::DependentNotFound(msg) => OpError::NotFound(msg),
246        // PR-4.0 (F4): remote-backend RBAC denials (`403`) and 501s keep
247        // their typed nouns — the HTTP store surfaces them as dedicated
248        // variants so the error envelope renders `error.kind:
249        // "unauthorized"` / `"not-yet-implemented"` instead of `"conflict"`.
250        crate::environment::StoreError::Unauthorized { policy, reason } => {
251            OpError::Unauthorized { policy, reason }
252        }
253        crate::environment::StoreError::NotYetImplemented(detail) => {
254            OpError::NotYetImplemented(detail)
255        }
256        // PR-3a.6: the typed revision-lifecycle verbs (`warm_revision` /
257        // `drain_revision` / `archive_revision`) wrap their inner
258        // `LifecycleError` in this variant so the structured detail
259        // (`HealthGateFailed::failed_checks`, `Conflict::expected_starts`,
260        // `ActiveTrafficReference::splits`) survives the trip back to the
261        // CLI verb. Unwrap to the existing `LifecycleError → OpError`
262        // mapping rather than flattening to `OpError::Store`.
263        crate::environment::StoreError::Lifecycle(inner) => OpError::from(*inner),
264        // Committed-on-error: forward the inner error's noun by
265        // recursing. Callers (see `cli::revisions::typed_transition`)
266        // are responsible for inspecting the outer variant FIRST and
267        // calling `CommitMarker::mark_committed` before mapping —
268        // otherwise the audit boundary won't fail-closed.
269        crate::environment::StoreError::CommittedAfterSave(inner) => {
270            map_store_err_preserving_noun(*inner)
271        }
272        other => OpError::Store(other),
273    }
274}
275
276/// Mint a fresh [`greentic_deploy_spec::IdempotencyKey`] (ULID) for a
277/// CLI-initiated mutation that doesn't carry a caller-supplied key in its
278/// payload. Cross-cutting helper: every verb-group migration that didn't
279/// already require an idempotency key in its payload (e.g. trust-root) uses
280/// this so audit ctx + store call share the same key.
281///
282/// Caller-supplied keys via the payload remain the right shape for verbs
283/// whose mutation cost is high enough to warrant operator-level dedup
284/// control (see `cli/traffic.rs` precedent).
285pub(crate) fn mint_idempotency_key() -> greentic_deploy_spec::IdempotencyKey {
286    greentic_deploy_spec::IdempotencyKey::new(ulid::Ulid::new().to_string())
287        .expect("freshly minted ULID is non-empty")
288}
289
290/// Resolve a caller-supplied idempotency key from an optional CLI payload
291/// field, falling back to a fresh-minted ULID. Cross-cutting helper for
292/// every typed-verb CLI site that accepts an optional caller-supplied key
293/// — `cli::bundles::remove`, `cli::revisions::typed_transition`, and the
294/// remaining PR-3a.* migrations.
295pub(crate) fn resolve_idempotency_key(
296    supplied: Option<String>,
297) -> Result<greentic_deploy_spec::IdempotencyKey, OpError> {
298    match supplied {
299        Some(raw) => greentic_deploy_spec::IdempotencyKey::new(raw)
300            .map_err(|e| OpError::InvalidArgument(format!("idempotency_key: {e}"))),
301        None => Ok(mint_idempotency_key()),
302    }
303}
304
305/// Closure-callable handle for signalling "this mutation persisted state to
306/// disk even though it's returning Err."
307///
308/// `audit_and_record` is fail-closed for committed mutations: if the audit
309/// append fails, a committed mutation's success is downgraded to
310/// [`OpError::Audit`]. The old default was "Ok = committed, Err = not
311/// committed," which is wrong for verbs that persist state on an error
312/// path (the B9 warm/ready gate flips a revision to `Failed` and saves
313/// before surfacing [`LifecycleError::HealthGateFailed`]). Callers on
314/// such paths invoke [`CommitMarker::mark_committed`] before returning
315/// `Err`, and the audit boundary then treats the audit-append failure as
316/// fail-closed instead of demoting it to `tracing::warn!`.
317///
318/// Default behavior is preserved for every other caller: ignore the
319/// parameter (idiomatic `|_committed| { ... }`) and the marker stays
320/// unset, so non-committing errors keep their existing demote-to-warn
321/// semantics.
322pub(crate) struct CommitMarker(std::cell::Cell<bool>);
323
324impl CommitMarker {
325    pub(crate) fn new() -> Self {
326        Self(std::cell::Cell::new(false))
327    }
328
329    /// Mark the mutation as having persisted state before its (forthcoming)
330    /// `Err` return. Calling this on the Ok path is harmless but
331    /// redundant — `Ok(_)` already implies committed.
332    pub fn mark_committed(&self) {
333        self.0.set(true);
334    }
335
336    pub(crate) fn is_committed(&self) -> bool {
337        self.0.get()
338    }
339}
340
341/// Closure return — the pre- and post-mutation generations.
342#[derive(Debug, Default, Clone, Copy)]
343pub(crate) struct AuditGens {
344    pub previous: Option<u64>,
345    pub new: Option<u64>,
346}
347
348impl AuditGens {
349    pub const NONE: Self = Self {
350        previous: None,
351        new: None,
352    };
353}
354
355/// Wrap a mutating verb body in local-store authorization + append-only audit.
356///
357/// 1. Runs [`authorize_local_owner`] against `ctx.env_id`.
358/// 2. On `Deny`: returns `OpError::Unauthorized` without calling `mutate`.
359/// 3. On `Allow`: runs `mutate` and records the outcome.
360/// 4. Always appends an [`AuditEvent`] to `<store_root>/<env_id>/audit/events.jsonl`.
361///
362/// Audit persistence is **fail-closed for committed mutations**: if `mutate`
363/// returned `Ok` (state committed) but the audit event could not be appended,
364/// the helper discards the success and returns [`OpError::Audit`]. A
365/// state-changing op never reports success without a durable audit record.
366///
367/// `mutate` receives a [`CommitMarker`]; closures whose error path *also*
368/// persists state (e.g. the B9 health gate flipping a revision to `Failed`
369/// before surfacing `LifecycleError::HealthGateFailed`) must call
370/// [`CommitMarker::mark_committed`] before returning `Err`, so an
371/// audit-append failure on that path is treated as fail-closed too.
372/// Closures that never persist state on the `Err` path simply ignore the
373/// marker (idiomatic `|_committed| { ... }`) — `Ok` already implies
374/// committed.
375///
376/// For non-committing outcomes (authorization denials, mutation errors that
377/// didn't persist, `NotYetImplemented` stubs) there is no committed state
378/// to protect, so an audit-append failure is demoted to `tracing::warn!`
379/// and the original (error) result is returned unchanged.
380///
381/// Note this closes the "unwritable/full audit dir" gap but not the
382/// process-death-between-write-and-append window — durable write-ahead intent
383/// belongs to A8's transactional remote store.
384///
385/// The closure returns `(OpOutcome, AuditGens)` where `AuditGens` carries the
386/// `previous_generation` (read under the env flock inside the closure, when
387/// applicable) and `new_generation` (the post-mutation value). Both default
388/// to `None` for verbs that don't track generations (env/bundles/revisions/
389/// credentials/secrets/config/migrate-*). When the closure returns a
390/// `previous_generation`, it overrides the value passed in via `AuditCtx`
391/// (which is treated as a default).
392pub(crate) fn audit_and_record<F>(
393    store: &LocalFsStore,
394    ctx: AuditCtx,
395    mutate: F,
396) -> Result<OpOutcome, OpError>
397where
398    F: FnOnce(&CommitMarker) -> Result<(OpOutcome, AuditGens), OpError>,
399{
400    let decision = authorize_local_owner(&ctx.env_id);
401    let commit_marker = CommitMarker::new();
402    let (result, gens) = match &decision {
403        AuditDecision::Deny { policy, reason } => (
404            Err(OpError::Unauthorized {
405                policy: policy.clone(),
406                reason: reason.clone(),
407            }),
408            AuditGens::default(),
409        ),
410        AuditDecision::Allow { .. } => match mutate(&commit_marker) {
411            Ok((outcome, g)) => (Ok(outcome), g),
412            Err(err) => (Err(err), AuditGens::default()),
413        },
414    };
415    // Either path can commit: `Ok` implies committed by definition; the
416    // closure also signals committed-on-`Err` (B9 health-gate Failed
417    // persistence) via the CommitMarker.
418    let committed = result.is_ok() || commit_marker.is_committed();
419
420    let audit_result = match &result {
421        Ok(_) => AuditResult::Ok,
422        Err(OpError::NotYetImplemented(detail)) => AuditResult::NotYetImplemented {
423            detail: detail.clone(),
424        },
425        Err(err) => AuditResult::Error {
426            kind: err.kind().to_string(),
427            message: err.to_string(),
428        },
429    };
430
431    let event = AuditEvent {
432        schema: crate::environment::AUDIT_EVENT_SCHEMA_V1.into(),
433        event_id: ulid::Ulid::new().to_string(),
434        ts: chrono::Utc::now(),
435        actor: current_local_actor(),
436        env_id: ctx.env_id.as_str().to_string(),
437        noun: ctx.noun.to_string(),
438        verb: ctx.verb.to_string(),
439        target: ctx.target,
440        previous_generation: gens.previous,
441        new_generation: gens.new,
442        idempotency_key: ctx.idempotency_key,
443        authorization: decision,
444        result: audit_result,
445    };
446
447    let append_outcome = AuditLog::for_env(store, &ctx.env_id).and_then(|log| log.append(&event));
448    if let Err(e) = append_outcome {
449        if committed {
450            // Fail-closed: a committed mutation must not report success without
451            // a durable audit record. Surface the failure so the operator can
452            // reconcile the (already-persisted) state change.
453            return Err(OpError::Audit(format!(
454                "{e} (event_id={}, {}.{} on env `{}`)",
455                event.event_id, event.noun, event.verb, event.env_id
456            )));
457        }
458        tracing::warn!(
459            target: "greentic.audit",
460            error = %e,
461            event_id = %event.event_id,
462            "failed to append audit event for a non-committing op; continuing with op result"
463        );
464    }
465
466    result
467}
468
469/// Mode flags shared by every `op` subcommand.
470#[derive(Debug, Clone, Default)]
471pub struct OpFlags {
472    /// When set, the command prints the JSON schema of its input payload and
473    /// exits without touching the store.
474    pub schema_only: bool,
475    /// When set, the command reads its payload from this path (JSON or YAML)
476    /// instead of prompting interactively.
477    pub answers: Option<PathBuf>,
478}
479
480/// Standard success envelope.
481#[derive(Debug, Clone, Serialize)]
482pub struct OpOutcome {
483    pub op: &'static str,
484    pub noun: &'static str,
485    pub result: Value,
486}
487
488impl OpOutcome {
489    pub fn new(noun: &'static str, op: &'static str, result: Value) -> Self {
490        Self { op, noun, result }
491    }
492}
493
494/// Read an answers payload from disk as JSON or YAML. The path extension
495/// disambiguates: `.json` → JSON, `.yaml`/`.yml` → YAML. Other extensions
496/// fall back to JSON (with a YAML retry on parse failure) so callers can pipe
497/// `gtc … --schema | jq … > answers.txt` without re-extensioning.
498pub fn load_answers<T: serde::de::DeserializeOwned>(path: &std::path::Path) -> Result<T, OpError> {
499    let bytes = std::fs::read(path).map_err(|source| OpError::Io {
500        path: path.to_path_buf(),
501        source,
502    })?;
503    let ext = path
504        .extension()
505        .and_then(|e| e.to_str())
506        .map(|s| s.to_ascii_lowercase());
507    match ext.as_deref() {
508        Some("yaml") | Some("yml") => {
509            serde_yaml_bw::from_slice(&bytes).map_err(|e| OpError::AnswersParse {
510                path: path.to_path_buf(),
511                message: format!("yaml: {e}"),
512            })
513        }
514        Some("json") => serde_json::from_slice(&bytes).map_err(|e| OpError::AnswersParse {
515            path: path.to_path_buf(),
516            message: format!("json: {e}"),
517        }),
518        _ => {
519            // Heuristic: try JSON first, then YAML.
520            serde_json::from_slice(&bytes).or_else(|json_err| {
521                serde_yaml_bw::from_slice(&bytes).map_err(|yaml_err| OpError::AnswersParse {
522                    path: path.to_path_buf(),
523                    message: format!("json: {json_err}; yaml: {yaml_err}"),
524                })
525            })
526        }
527    }
528}
529
530/// Render an `OpError` into the standard JSON error envelope.
531pub fn render_error(noun: &'static str, op: &'static str, err: &OpError) -> Value {
532    serde_json::json!({
533        "op": op,
534        "noun": noun,
535        "error": {
536            "kind": err.kind(),
537            "message": err.to_string(),
538        }
539    })
540}