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}