greentic_deployer/cli/dispatch.rs
1//! Clap-derive dispatcher for `greentic-deployer op …` (`A3`).
2//!
3//! Owns the `OpCommand` clap tree + the `dispatch_op` entry point. The
4//! actual per-noun command logic lives in `cli::env`/`cli::env_packs`/etc;
5//! this module is the wiring layer that converts argv → typed payloads →
6//! library calls and prints the JSON envelope to stdout.
7
8use std::path::PathBuf;
9
10use clap::{Args, Parser, Subcommand};
11use greentic_deploy_spec::CapabilitySlot;
12
13use crate::environment::LocalFsStore;
14
15use super::{OpError, OpFlags, OpOutcome, render_error};
16
17/// `greentic-deployer op …`. Mirrors the `gtc op …` surface; the gtc-side
18/// passthrough shells out to `greentic-operator op …`, which dispatches
19/// through the same `OpCommand` clap tree.
20#[derive(Parser, Debug)]
21#[command(
22 after_help = "Nouns: env, env-packs, extensions, bundles, revisions, traffic, config, credentials, secrets, messaging.\n\
23 Every verb honors:\n\
24 --schema dump the JSON schema of the payload it would accept, then exit\n\
25 --answers <PATH> read the payload from a JSON or YAML file\n\n\
26 Examples:\n\
27 greentic-operator op env create --answers env.json\n\
28 greentic-operator op revisions warm --answers warm.yaml\n\
29 greentic-operator op env show <env-id>\n\n\
30 Errors are written to stderr as a JSON envelope:\n\
31 {\"op\":\"<verb>\",\"noun\":\"<noun>\",\"error\":{\"kind\":\"…\",\"message\":\"…\"}}\n\
32 Success output goes to stdout as:\n\
33 {\"op\":\"<verb>\",\"noun\":\"<noun>\",\"result\":…}"
34)]
35pub struct OpCommand {
36 /// Optional root for the local `EnvironmentStore`. Defaults to
37 /// `~/.greentic/environments`.
38 #[arg(long, global = true)]
39 pub store_root: Option<PathBuf>,
40
41 /// Target a remote operator store over HTTP instead of the local FS
42 /// store. Overrides `GREENTIC_STORE_URL`. When set (and not `--schema`),
43 /// mutation verbs run against the remote A8 HTTP store.
44 #[arg(long, global = true)]
45 pub store_url: Option<String>,
46
47 /// Bearer token for the remote `--store-url` store. Overrides
48 /// `GREENTIC_STORE_TOKEN`.
49 #[arg(long, global = true)]
50 pub store_token: Option<String>,
51
52 /// Dump the JSON Schema of the input payload this verb accepts, then
53 /// exit. The library is free to return a hand-written stub until A1's
54 /// `schemars` derive wiring lands.
55 #[arg(long, global = true)]
56 pub schema: bool,
57
58 /// Read the verb's payload from this JSON or YAML file instead of
59 /// reading positionals.
60 #[arg(long, global = true)]
61 pub answers: Option<PathBuf>,
62
63 #[command(subcommand)]
64 pub noun: OpNoun,
65}
66
67#[derive(Subcommand, Debug)]
68pub enum OpNoun {
69 /// Environment CRUD (`create`/`update`/`list`/`show`/`doctor`/`destroy`).
70 Env {
71 #[command(subcommand)]
72 verb: EnvVerb,
73 },
74 /// Env-pack bindings (`add`/`update`/`remove`/`rollback`/`list`).
75 EnvPacks {
76 #[command(subcommand)]
77 verb: EnvPacksVerb,
78 },
79 /// Application bundle deployments.
80 Bundles {
81 #[command(subcommand)]
82 verb: BundlesVerb,
83 },
84 /// Revision lifecycle.
85 Revisions {
86 #[command(subcommand)]
87 verb: RevisionsVerb,
88 },
89 /// Traffic split per deployment.
90 Traffic {
91 #[command(subcommand)]
92 verb: TrafficVerb,
93 },
94 /// One-shot bundle deployment: add (if new) → stage → warm → route 100 %
95 /// traffic, with sensible defaults. Re-deploying an existing bundle stages
96 /// a new revision and blue-green shifts traffic onto it. The
97 /// `bundles`/`revisions`/`traffic` verbs remain for fine-tuning.
98 Deploy(BundleDeployArgs),
99 /// Host/setup/runtime config inspection.
100 Config {
101 #[command(subcommand)]
102 verb: ConfigVerb,
103 },
104 /// Cloud credentials.
105 Credentials {
106 #[command(subcommand)]
107 verb: CredentialsVerb,
108 },
109 /// Secrets management.
110 Secrets {
111 #[command(subcommand)]
112 verb: SecretsVerb,
113 },
114 /// Per-environment trust-root management (C2). Lists, adds, or removes
115 /// `(key_id, public_pem)` pairs verifiers consult to validate DSSE
116 /// envelopes (revenue policy today; bundle/revision signatures next).
117 #[command(name = "trust-root")]
118 TrustRoot {
119 #[command(subcommand)]
120 verb: TrustRootVerb,
121 },
122 /// Per-environment messaging endpoints (`Phase M1`). N-per-env instances
123 /// of messaging providers (e.g. multiple Teams bots), each with its own
124 /// curated bundle set and optional welcome flow.
125 Messaging {
126 #[command(subcommand)]
127 verb: MessagingNoun,
128 },
129 /// Open-namespace extension bindings (`Path 3`). N-per-env capabilities
130 /// resolved by workloads via `ext://<path>[/<instance>]` — config-shaped,
131 /// no typed host interface, no schema bump per family. Contrast
132 /// `env-packs`, which manages the closed, 1-per-slot core `packs`.
133 Extensions {
134 #[command(subcommand)]
135 verb: ExtensionsVerb,
136 },
137 /// Per-environment update-channel enrollment (`P1b`). `enroll` mints a
138 /// client certificate at the Cert-CA and persists it to the env secrets
139 /// backend; `status` reports the stored certificate's serial + validity.
140 Updates {
141 #[command(subcommand)]
142 verb: UpdatesVerb,
143 },
144}
145
146#[derive(Subcommand, Debug)]
147pub enum MessagingNoun {
148 /// Manage a per-environment messaging endpoint (`add`/`list`/`show`/
149 /// `link-bundle`/`unlink-bundle`/`set-welcome-flow`/`remove`).
150 Endpoint {
151 #[command(subcommand)]
152 verb: MessagingEndpointVerb,
153 },
154}
155
156#[derive(Subcommand, Debug)]
157pub enum MessagingEndpointVerb {
158 /// Mint a new endpoint for `<env>` with `<provider_type>` /
159 /// `<provider_id>` instance identity. Bundle linkage and welcome-flow
160 /// follow via dedicated verbs.
161 Add(MessagingEndpointAddArgs),
162 /// List every endpoint in `<env>`.
163 List { env_id: String },
164 /// Show one endpoint in `<env>` by `<endpoint_id>` (ULID).
165 Show { env_id: String, endpoint_id: String },
166 /// Link a bundle to an endpoint. Bundle must already be deployed in the env.
167 #[command(name = "link-bundle")]
168 LinkBundle(MessagingEndpointLinkBundleArgs),
169 /// Remove a bundle from an endpoint's `linked_bundles`. Fails when the
170 /// bundle owns the endpoint's welcome_flow — clear that first.
171 #[command(name = "unlink-bundle")]
172 UnlinkBundle(MessagingEndpointLinkBundleArgs),
173 /// Set the endpoint's default welcome flow (referenced on first contact
174 /// per M1.5). The flow's bundle must already be linked.
175 #[command(name = "set-welcome-flow")]
176 SetWelcomeFlow(MessagingEndpointSetWelcomeFlowArgs),
177 /// Remove an endpoint. Idempotent: removing an absent endpoint succeeds.
178 Remove(MessagingEndpointRemoveArgs),
179 /// Rotate the webhook secret on an existing endpoint. Generates a fresh
180 /// 32-char CSPRNG secret, bumps generation. Idempotent on the same key.
181 #[command(name = "rotate-webhook-secret")]
182 RotateWebhookSecret(MessagingEndpointRemoveArgs),
183}
184
185/// Inline-flag form of `messaging.endpoint add`. When all required fields
186/// (`--env`, `--provider-type`, `--provider-id`, `--display-name`,
187/// `--idempotency-key`, `--updated-by`) are present, the payload is built
188/// directly from these flags. Otherwise dispatch falls through to
189/// `--answers <PATH>` / `--schema`, matching the `trust-root add` precedent.
190#[derive(Args, Debug)]
191pub struct MessagingEndpointAddArgs {
192 /// Environment id, e.g. `local`.
193 #[arg(long)]
194 pub env: Option<String>,
195 /// Provider type — `telegram`, `teams`, `slack`, `whatsapp`, `webex`,
196 /// `email`, `webchat`.
197 #[arg(long = "provider-type")]
198 pub provider_type: Option<String>,
199 /// Per-environment instance identity for the provider, e.g.
200 /// `telegram-legal-bot`. Together with `--provider-type` it must be
201 /// unique inside the env.
202 #[arg(long = "provider-id")]
203 pub provider_id: Option<String>,
204 /// Human-readable label for the endpoint.
205 #[arg(long = "display-name")]
206 pub display_name: Option<String>,
207 /// Secret reference URI, e.g. `secret://local/global/telegram/bot_token`.
208 /// Repeating.
209 #[arg(long = "secret-ref", value_name = "URI")]
210 pub secret_ref: Vec<String>,
211 /// Per-endpoint webhook secret ref (telegram-class only). Required when
212 /// adding a telegram-class endpoint against a remote `--store-url` store
213 /// (which never mints secrets — the operator provisions the value and
214 /// passes the ref); omit on the local store to auto-mint into the dev-store.
215 #[arg(long = "webhook-secret-ref", value_name = "URI")]
216 pub webhook_secret_ref: Option<String>,
217 /// Idempotency key. Required for safe retries; mutations replay no-op when
218 /// the same key + identity is supplied.
219 #[arg(long = "idempotency-key")]
220 pub idempotency_key: Option<String>,
221 /// Free-form actor label that appears in the env-audit log.
222 #[arg(long = "updated-by")]
223 pub updated_by: Option<String>,
224}
225
226/// Inline-flag form of `messaging.endpoint link-bundle` and `unlink-bundle`.
227/// When all four required fields are present the payload is built directly;
228/// otherwise dispatch falls through to `--answers` / `--schema`.
229#[derive(Args, Debug)]
230pub struct MessagingEndpointLinkBundleArgs {
231 /// Environment id.
232 #[arg(long)]
233 pub env: Option<String>,
234 /// Endpoint id (ULID).
235 #[arg(long = "endpoint-id")]
236 pub endpoint_id: Option<String>,
237 /// Bundle id. Must already be deployed in the env.
238 #[arg(long = "bundle-id")]
239 pub bundle_id: Option<String>,
240 /// Idempotency key.
241 #[arg(long = "idempotency-key")]
242 pub idempotency_key: Option<String>,
243 /// Free-form actor label.
244 #[arg(long = "updated-by")]
245 pub updated_by: Option<String>,
246}
247
248/// Inline-flag form of `messaging.endpoint set-welcome-flow`. When all six
249/// required fields are present the payload is built directly; otherwise
250/// dispatch falls through to `--answers` / `--schema`.
251#[derive(Args, Debug)]
252pub struct MessagingEndpointSetWelcomeFlowArgs {
253 /// Environment id.
254 #[arg(long)]
255 pub env: Option<String>,
256 /// Endpoint id (ULID).
257 #[arg(long = "endpoint-id")]
258 pub endpoint_id: Option<String>,
259 /// Bundle id of the welcome-flow's pack. Must already be linked to the
260 /// endpoint via `link-bundle`.
261 #[arg(long = "bundle-id")]
262 pub bundle_id: Option<String>,
263 /// Pack id that hosts the welcome flow.
264 #[arg(long = "pack-id")]
265 pub pack_id: Option<String>,
266 /// Flow id (the welcome-flow entry point).
267 #[arg(long = "flow-id")]
268 pub flow_id: Option<String>,
269 /// Idempotency key.
270 #[arg(long = "idempotency-key")]
271 pub idempotency_key: Option<String>,
272 /// Free-form actor label.
273 #[arg(long = "updated-by")]
274 pub updated_by: Option<String>,
275}
276
277/// Inline-flag form of `messaging.endpoint remove` and `rotate-webhook-secret`.
278/// When all four required fields are present the payload is built directly;
279/// otherwise dispatch falls through to `--answers` / `--schema`.
280#[derive(Args, Debug)]
281pub struct MessagingEndpointRemoveArgs {
282 /// Environment id.
283 #[arg(long)]
284 pub env: Option<String>,
285 /// Endpoint id (ULID).
286 #[arg(long = "endpoint-id")]
287 pub endpoint_id: Option<String>,
288 /// Idempotency key.
289 #[arg(long = "idempotency-key")]
290 pub idempotency_key: Option<String>,
291 /// Free-form actor label.
292 #[arg(long = "updated-by")]
293 pub updated_by: Option<String>,
294}
295
296#[derive(Subcommand, Debug)]
297pub enum EnvVerb {
298 /// Idempotent bootstrap of the `local` environment with the five default
299 /// env-pack bindings (A4 helper exposed as a CLI verb). Creates the env
300 /// if missing, fills in any missing default bindings on an existing env,
301 /// or reports `untouched` if the env is already complete. User-bound
302 /// non-default descriptors are NEVER overwritten.
303 Init(EnvInitArgs),
304 /// Declarative, upsert-only environment apply. Reads a
305 /// `greentic.env-manifest.v1` document via `--answers <PATH>` and
306 /// reconciles the env toward it: validate → diff → plan → execute →
307 /// verify. Re-running an unchanged manifest is a visible no-op.
308 /// `--dry-run` previews the plan; `--check` is the CI gate (exit 1 on
309 /// pending diff).
310 Apply(EnvApplyArgs),
311 Create(EnvCreateArgs),
312 Update(EnvUpdateArgs),
313 /// `op env set-public-url <env_id> <URL>`. Replaces the env's persisted
314 /// `host_config.public_base_url`. Equivalent to
315 /// `op config set --public-url <URL>` but easier to discover for the
316 /// common "I set the URL once and forget it" path.
317 SetPublicUrl(EnvSetPublicUrlArgs),
318 List,
319 Show {
320 env_id: String,
321 },
322 Doctor {
323 env_id: String,
324 },
325 /// Run per-binding tool preflight. Resolves each env-pack binding via the
326 /// registry and invokes its handler's `preflight()` to check external
327 /// tools (binary presence, version, auth, scope) needed for real work.
328 /// The built-in `local` handlers report no external tools.
329 ToolCheck {
330 env_id: String,
331 },
332 /// Render the env's declarative desired state through the deployer
333 /// env-pack's manifest renderer, without applying anything (plan §6
334 /// step 10). With `--output` writes one YAML file per object in apply
335 /// order; otherwise embeds the manifests in the JSON outcome.
336 Render(EnvRenderArgs),
337 /// Apply the env's declarative desired state to its live cluster and
338 /// prune the workers of revisions no longer present (the apply-side
339 /// counterpart of `render`). K8s deployer env-pack only today; connects
340 /// through the binding's `kubeconfig_context` answer.
341 Reconcile(EnvReconcileArgs),
342 /// Bring a SINGLE revision's worker resources into agreement with its
343 /// recorded lifecycle (present → apply the worker pair, absent → tear it
344 /// down) — the surgical counterpart of `reconcile`. K8s deployer env-pack
345 /// only today; connects through the binding's `kubeconfig_context` answer.
346 ApplyRevision(EnvApplyRevisionArgs),
347 /// Push one deployment's recorded traffic split to its live ALB listener
348 /// (the routing-side counterpart of `apply-revision`). AWS-ECS deployer
349 /// env-pack only — K8s serves splits from its in-process runtime router, so
350 /// `op traffic set` alone suffices there. The split itself is recorded by
351 /// `op traffic set`; this verb makes it observable in the live runtime.
352 ///
353 /// Routing depends on the binding's ALB routing answers. With a routing
354 /// condition set (`alb_routing_host` / `alb_routing_path`), this writes a
355 /// per-deployment listener RULE keyed by that host/path, leaving the default
356 /// action and sibling deployments' rules intact — deployments coexist behind
357 /// one listener. With NO routing condition, it falls back to REPLACING the
358 /// listener's default action, which assumes the `alb_listener_arn` is
359 /// DEDICATED to this one deployment (a split then clobbers any sibling
360 /// routing / auth / redirect on that listener).
361 ApplyTraffic(EnvApplyTrafficArgs),
362 Destroy {
363 env_id: String,
364 #[arg(long)]
365 confirm: bool,
366 },
367 /// Migrate the legacy `dev` environment to `<target>` (typically `local`).
368 /// Run with `--check` to scan without touching state; `--apply` performs
369 /// the one-shot rewrite only when the check is clean (A4b).
370 MigrateDev {
371 /// Target env id — typically `local`.
372 target: String,
373 /// Scan for blocking findings and report; do not touch state.
374 #[arg(long, conflicts_with = "apply")]
375 check: bool,
376 /// Re-run the check; if clean, perform the migration.
377 #[arg(long, conflicts_with = "check")]
378 apply: bool,
379 },
380 /// Archive the legacy `<state_dir>/deploy/` artifact tree (A6).
381 /// `--apply` renames it to a hidden `.deploy-migrated-<ts>/` sentinel;
382 /// contents are NOT copied into the new layout. Quiesce live deploys
383 /// first — `apply::run` does not participate in the migration lock.
384 MigrateState {
385 /// Target env id — must exist in EnvironmentStore.
386 target: String,
387 /// Scan for blocking findings and report; do not touch state.
388 #[arg(long, conflicts_with = "apply")]
389 check: bool,
390 /// Re-run the check; if clean, rename the legacy tree.
391 #[arg(long, conflicts_with = "check")]
392 apply: bool,
393 /// Override the legacy state-dir root. Defaults to
394 /// `$HOME/.greentic/state`.
395 #[arg(long = "state-dir")]
396 state_dir: Option<PathBuf>,
397 },
398}
399
400#[derive(Subcommand, Debug)]
401pub enum TrustRootVerb {
402 /// Seed the env trust root with the local operator key. The
403 /// revenue-policy writer never auto-seeds, so this verb is the
404 /// authorized path that grants signing rights to a new env. Idempotent.
405 Bootstrap { env_id: Option<String> },
406 /// List trusted keys for one env.
407 List { env_id: String },
408 /// Add a `(key_id, public_pem)` pair. PEM source: `--public-key-pem` (inline)
409 /// or `--public-key-file <PATH>`.
410 Add(TrustRootAddArgs),
411 /// Remove a key by `key_id` (case-insensitive). No-op if absent.
412 Remove(TrustRootRemoveArgs),
413}
414
415#[derive(Args, Debug)]
416pub struct TrustRootAddArgs {
417 pub env_id: Option<String>,
418 #[arg(long = "key-id")]
419 pub key_id: Option<String>,
420 #[arg(long = "public-key-pem")]
421 pub public_key_pem: Option<String>,
422 #[arg(long = "public-key-file")]
423 pub public_key_file: Option<PathBuf>,
424}
425
426/// Args for `op env init`. Only `--public-url` is settable inline;
427/// init is otherwise an idempotent bootstrap of the canonical `local` env.
428/// If the env already exists, an inline `--public-url` is NOT applied —
429/// use `op env set-public-url` (or `op config set --public-url`) to mutate
430/// an existing env's URL.
431#[derive(Args, Debug)]
432pub struct EnvInitArgs {
433 /// Persistent public base URL the runtime exposes
434 /// (`https://host[:port]`, origin only). Stored in
435 /// `Environment.host_config.public_base_url`. Only takes effect when
436 /// `init` actually creates the env; on `untouched` / `healed` outcomes
437 /// the existing URL is preserved.
438 #[arg(long = "public-url")]
439 pub public_url: Option<String>,
440}
441
442/// Args for `op env apply`. The manifest itself arrives via the global
443/// `--answers <PATH>` flag (it IS the verb's payload); these flags only
444/// shape how the plan is executed.
445#[derive(Args, Debug)]
446pub struct EnvApplyArgs {
447 /// Validate + diff + print the plan, then exit without mutating
448 /// anything (exit 0 even when changes are pending — it's a preview).
449 #[arg(long = "dry-run")]
450 pub dry_run: bool,
451 /// CI convergence gate: validate + diff + print the plan, then exit
452 /// non-zero when the plan contains pending diffable changes, 0 when the
453 /// env is converged. Always-put secret steps don't count as drift
454 /// (values cannot be diffed until A9); they're reported separately.
455 /// Never mutates anything.
456 #[arg(long, conflicts_with = "dry_run")]
457 pub check: bool,
458 /// Never prompt — neither for missing secret values nor the plan
459 /// confirmation (implies `--yes`). Missing inputs are collected and
460 /// reported (JSON `missing` section) instead of asked for; any missing
461 /// input fails the apply before it mutates.
462 #[arg(long = "non-interactive")]
463 pub non_interactive: bool,
464 /// Write a skeleton `greentic.env-manifest.v1` document to PATH and
465 /// exit — a starting point for `--answers`. Needs no manifest and
466 /// touches no store state.
467 #[arg(
468 long = "emit-answers-template",
469 value_name = "PATH",
470 conflicts_with_all = ["dry_run", "check"]
471 )]
472 pub emit_answers_template: Option<PathBuf>,
473 /// Audit principal forwarded to every composed mutation. Defaults to
474 /// `env-apply`.
475 #[arg(long = "updated-by")]
476 pub updated_by: Option<String>,
477 /// Skip the interactive confirmation shown when the plan contains
478 /// mutations and stdin/stdout are a TTY. Non-TTY implies `--yes`.
479 #[arg(long)]
480 pub yes: bool,
481}
482
483impl EnvApplyArgs {
484 pub(crate) fn into_options(self) -> super::env_apply::ApplyOptions {
485 use super::env_apply::ApplyMode;
486 // clap's `conflicts_with` guarantees at most one of `--dry-run` /
487 // `--check` is set.
488 let mode = if self.check {
489 ApplyMode::Check
490 } else if self.dry_run {
491 ApplyMode::DryRun
492 } else {
493 ApplyMode::Apply
494 };
495 super::env_apply::ApplyOptions {
496 mode,
497 updated_by: self.updated_by,
498 yes: self.yes,
499 non_interactive: self.non_interactive,
500 // The CLI never pre-collects paste-sourced secrets; an unset value
501 // is prompted (interactive) or reported missing (headless).
502 ..Default::default()
503 }
504 }
505}
506
507/// Args for `op env create` and `op env update`. All fields are optional
508/// at the clap layer so `--answers` / `--schema` keep working unchanged;
509/// the dispatcher builds an `EnvCreatePayload` only when the required
510/// inline flags are supplied, otherwise hands `None` to the library
511/// function (`resolve_payload` then defers to `--answers`).
512#[derive(Args, Debug)]
513pub struct EnvCreateArgs {
514 /// Environment id (e.g. `local`, `prod-eu`).
515 pub environment_id: Option<String>,
516 /// Display name. Defaults to `--environment-id` if omitted on the CLI
517 /// path; required on the JSON path. Pass either positionally below or
518 /// via `--name`.
519 #[arg(long = "name")]
520 pub name: Option<String>,
521 /// Optional region tag (free-form).
522 #[arg(long = "region")]
523 pub region: Option<String>,
524 /// Tenant organization id this env belongs to.
525 #[arg(long = "tenant-org")]
526 pub tenant_org_id: Option<String>,
527 /// Bind address for the runtime's local HTTP listener (e.g.
528 /// `127.0.0.1:8080`). Validated as `SocketAddr` before any state is
529 /// touched.
530 #[arg(long = "listen-addr")]
531 pub listen_addr: Option<String>,
532 /// Persistent public base URL the runtime exposes
533 /// (`https://host[:port]`, origin only).
534 #[arg(long = "public-url")]
535 pub public_url: Option<String>,
536}
537
538/// Args for `op env update`. Update accepts the metadata fields only.
539/// URL changes go through `op env set-public-url` (single-field verb)
540/// or `op config set --public-url` (URL inside a multi-field host_config
541/// update); listen-addr changes through `op config set --listen-addr`.
542#[derive(Args, Debug)]
543pub struct EnvUpdateArgs {
544 /// Environment id (e.g. `local`, `prod-eu`).
545 pub environment_id: Option<String>,
546 /// Display name. Defaults to `--environment-id` if omitted on the CLI
547 /// path; required on the JSON path. Pass either positionally below or
548 /// via `--name`.
549 #[arg(long = "name")]
550 pub name: Option<String>,
551 /// Optional region tag (free-form).
552 #[arg(long = "region")]
553 pub region: Option<String>,
554 /// Tenant organization id this env belongs to.
555 #[arg(long = "tenant-org")]
556 pub tenant_org_id: Option<String>,
557}
558
559/// Args for `op env render <env_id> [--kind <descriptor>] [--output <dir>]`.
560/// Read-only: renders without applying. `--answers`/`--schema` payload
561/// machinery does not apply — the inputs are these three direct args.
562#[derive(Args, Debug)]
563pub struct EnvRenderArgs {
564 /// Environment id (e.g. `zain-prod`).
565 pub env_id: String,
566 /// Deployer env-pack kind to render with — a full `<path>@<version>`
567 /// descriptor, or a bare path matching the env's deployer binding.
568 /// Defaults to the env's Deployer-slot binding.
569 #[arg(long)]
570 pub kind: Option<String>,
571 /// Directory to write one YAML file per object (created if missing;
572 /// same-named files are overwritten). Files are named
573 /// `<NN>-<kind>-<name>.yaml` in apply order. Without this flag the
574 /// manifests are embedded in the JSON outcome instead.
575 #[arg(long)]
576 pub output: Option<PathBuf>,
577}
578
579/// Args for `op env reconcile`. Applies desired state to the live cluster —
580/// the apply-side counterpart of `render` (use `render` for a no-side-effect
581/// preview).
582#[derive(Args, Debug)]
583pub struct EnvReconcileArgs {
584 /// Environment id (e.g. `zain-prod`).
585 pub env_id: String,
586 /// Deployer env-pack kind to reconcile with — a full `<path>@<version>`
587 /// descriptor, or a bare path matching the env's deployer binding.
588 /// Defaults to the env's Deployer-slot binding.
589 #[arg(long)]
590 pub kind: Option<String>,
591}
592
593/// Args for `op env apply-revision <env_id> <revision_id> [--kind <descriptor>]`.
594/// Surgical single-revision counterpart of `reconcile`: brings ONE revision's
595/// worker resources into agreement with its recorded lifecycle (present →
596/// apply, absent → tear down). Assumes the env-level set (namespace, router)
597/// already exists — `reconcile` establishes that.
598#[derive(Args, Debug)]
599pub struct EnvApplyRevisionArgs {
600 /// Environment id (e.g. `zain-prod`).
601 pub env_id: String,
602 /// Revision id (ULID) to apply — must already exist in the env.
603 pub revision_id: String,
604 /// Deployer env-pack kind to apply with — a full `<path>@<version>`
605 /// descriptor, or a bare path matching the env's deployer binding.
606 /// Defaults to the env's Deployer-slot binding.
607 #[arg(long)]
608 pub kind: Option<String>,
609}
610
611/// Args for `op env apply-traffic <env_id> <deployment_id> [--kind <descriptor>]`.
612/// Pushes the deployment's recorded traffic split to its live ALB listener
613/// (AWS-ECS only). Record the split first with `op traffic set`.
614#[derive(Args, Debug)]
615pub struct EnvApplyTrafficArgs {
616 /// Environment id (e.g. `zain-prod`).
617 pub env_id: String,
618 /// Deployment id (ULID) whose recorded split to apply.
619 pub deployment_id: String,
620 /// Deployer env-pack kind to apply with — a full `<path>@<version>`
621 /// descriptor, or a bare path matching the env's deployer binding.
622 /// Defaults to the env's Deployer-slot binding.
623 #[arg(long)]
624 pub kind: Option<String>,
625}
626
627/// Args for `op env set-public-url <env_id> <URL>`. Both fields are
628/// required positional — this verb only sets the public URL, no other
629/// host_config fields. `--answers` is rejected: this is a dedicated
630/// single-purpose verb, not an `op config set` alias.
631#[derive(Args, Debug)]
632pub struct EnvSetPublicUrlArgs {
633 /// Environment id (e.g. `local`).
634 pub env_id: String,
635 /// Public base URL the runtime exposes (`https://host[:port]`, origin
636 /// only — no path, query, or fragment).
637 pub url: String,
638}
639
640#[derive(Args, Debug)]
641pub struct TrustRootRemoveArgs {
642 pub env_id: Option<String>,
643 #[arg(long = "key-id")]
644 pub key_id: Option<String>,
645}
646
647#[derive(Subcommand, Debug)]
648pub enum EnvPacksVerb {
649 Add,
650 Update,
651 Remove,
652 Rollback,
653 List { env_id: String },
654}
655
656#[derive(Subcommand, Debug)]
657pub enum ExtensionsVerb {
658 Add,
659 Update,
660 Remove,
661 Rollback,
662 List { env_id: String },
663}
664
665#[derive(Subcommand, Debug)]
666pub enum UpdatesVerb {
667 /// Enroll the env's update channel: mint a key + CSR, exchange it at the
668 /// Cert-CA for a signed client certificate, and persist cert/key/CA into the
669 /// env secrets backend. Re-running re-enrolls (manual rotation).
670 Enroll(UpdatesEnrollArgs),
671 /// Report the enrolled update-channel certificate's serial + validity window.
672 Status { env_id: Option<String> },
673 /// Fetch a signed update plan (over the enrolled mTLS channel or from a
674 /// local file), verify it against the env trust root, and stage it.
675 Get(UpdatesGetArgs),
676 /// Apply a staged update plan to its environment: re-verify, snapshot,
677 /// converge via the env-apply pipeline, and roll back on failure.
678 Apply(UpdatesApplyArgs),
679 /// Force-fail a plan stranded in `applying` by a crashed applier
680 /// (`applying → failed`, audited), so a fresh `get` + `apply` can proceed.
681 /// Requires `--force`; does not roll back partial changes.
682 Recover(UpdatesRecoverArgs),
683 /// Set the update-channel notification policy (`update-channel.json`):
684 /// whether the runtime acts on a discovered update, and the fallback poll
685 /// interval. Only the flags supplied are changed. Disabled by default.
686 ConfigSet(UpdatesConfigSetArgs),
687 /// Show the update-channel notification policy (stored fields + resolved
688 /// effective values). Read-only.
689 ConfigShow { env_id: Option<String> },
690 /// Build and DSSE-sign an UpdatePlan carrying a content target
691 /// (`--target-file`), one or more binary artifacts (`--binary`), or both,
692 /// writing `plan.json` + `plan.json.sig` that round-trip through the
693 /// existing `verify_update_plan`. Producer side of the update path.
694 PlanBuild(UpdatesPlanBuildArgs),
695}
696
697#[derive(Args, Debug)]
698pub struct UpdatesEnrollArgs {
699 pub env_id: Option<String>,
700 #[arg(long = "ca-url")]
701 pub ca_url: Option<String>,
702}
703
704#[derive(Args, Debug)]
705pub struct UpdatesApplyArgs {
706 pub env_id: Option<String>,
707 /// Plan id of the staged plan to apply (from a prior `op updates get`).
708 #[arg(long = "plan-id")]
709 pub plan_id: Option<String>,
710}
711
712#[derive(Args, Debug)]
713pub struct UpdatesRecoverArgs {
714 pub env_id: Option<String>,
715 /// Plan id of the `applying` plan to force-fail (from a prior `op updates get`).
716 #[arg(long = "plan-id")]
717 pub plan_id: Option<String>,
718 /// Assert the applier is dead and force-fail `applying → failed`. Required —
719 /// recover refuses without it (a live apply is indistinguishable on disk).
720 #[arg(long)]
721 pub force: bool,
722}
723
724#[derive(Args, Debug)]
725pub struct UpdatesConfigSetArgs {
726 pub env_id: Option<String>,
727 /// Master switch for the update-channel notification machinery. Omit to
728 /// leave unchanged (absent = disabled, deny-by-default).
729 #[arg(long)]
730 pub enabled: Option<bool>,
731 /// Action on a verified notification: `record-only` or `stage`. Omit to
732 /// leave unchanged (unset resolves to `stage`).
733 #[arg(long = "on-notify")]
734 pub on_notify: Option<String>,
735 /// Fallback poll interval in seconds (>= 60). Omit to leave unchanged.
736 #[arg(long = "poll-interval-secs")]
737 pub poll_interval_secs: Option<u64>,
738}
739
740#[derive(Args, Debug)]
741pub struct UpdatesPlanBuildArgs {
742 /// Target environment id.
743 pub env_id: Option<String>,
744 /// Monotonic plan sequence (the consumer enforces anti-rollback).
745 #[arg(long)]
746 pub sequence: Option<u64>,
747 /// Binary artifact spec (repeatable). Comma-separated key=value:
748 /// `name=greentic-start,version=1.1.9,target=x86_64-unknown-linux-gnu,digest=sha256:<hex>[,source=https://...]`.
749 /// Required unless `--target-file` is supplied.
750 #[arg(long = "binary")]
751 pub binaries: Vec<String>,
752 /// PKCS#8 Ed25519 private key PEM for signing. Default: the global operator key.
753 #[arg(long = "signing-key")]
754 pub signing_key: Option<PathBuf>,
755 /// JSON file for the plan target (env-manifest.v1). Required unless
756 /// `--binary` is supplied. Default: minimal manifest with schema + env id.
757 #[arg(long = "target-file")]
758 pub target_file: Option<PathBuf>,
759 /// Minimum runtime version (semver) for `compat.min_runtime`.
760 #[arg(long = "min-runtime")]
761 pub min_runtime: Option<String>,
762 /// Output directory for `plan.json` + `plan.json.sig`. Default: current dir.
763 #[arg(long = "out-dir")]
764 pub out_dir: Option<PathBuf>,
765}
766
767#[derive(Args, Debug)]
768pub struct UpdatesGetArgs {
769 pub env_id: Option<String>,
770 /// Fetch the signed plan (document + `.sig` sidecar) from this base URL over
771 /// the enrolled mTLS channel.
772 #[arg(long = "plan-url", conflicts_with_all = ["plan_file", "plan_sig_file"])]
773 pub plan_url: Option<String>,
774 /// Local plan document (airgap import / testing). Requires `--plan-sig-file`.
775 #[arg(long = "plan-file", requires = "plan_sig_file")]
776 pub plan_file: Option<PathBuf>,
777 /// DSSE envelope sidecar for `--plan-file`.
778 #[arg(long = "plan-sig-file", requires = "plan_file")]
779 pub plan_sig_file: Option<PathBuf>,
780}
781
782#[derive(Subcommand, Debug)]
783pub enum BundlesVerb {
784 Add,
785 Update,
786 Remove,
787 List { env_id: String },
788}
789
790#[derive(Subcommand, Debug)]
791pub enum RevisionsVerb {
792 Stage(RevisionStageArgs),
793 Warm,
794 Drain,
795 Archive,
796 List { env_id: String },
797}
798
799/// Args for `op revisions stage`. All fields are optional at the clap layer so
800/// `--answers` / `--payload-json` / `--schema` keep working unchanged; the
801/// dispatcher builds a `RevisionStagePayload` only when the positional args are
802/// supplied, otherwise hands `None` to the library function.
803#[derive(Args, Debug)]
804pub struct RevisionStageArgs {
805 /// Environment id, e.g. `local`.
806 pub env_id: Option<String>,
807 /// Deployment ULID the revision belongs to.
808 #[arg(long)]
809 pub deployment: Option<String>,
810 /// Local `.gtbundle` to extract and pin into the revision's pack-list.
811 #[arg(long)]
812 pub bundle: Option<PathBuf>,
813}
814
815#[derive(Subcommand, Debug)]
816pub enum TrafficVerb {
817 /// Replace the traffic split for one deployment. Entries are
818 /// `<revision_id>=<percent>` (integer 0..=100, optional `%` suffix) or
819 /// `<revision_id>=<N>bps` (basis points 0..=10_000). Sum must equal
820 /// 100 % (10,000 bps). Validates revisions are `Ready` before saving.
821 Set(TrafficSetArgs),
822 /// Show the live split for one deployment.
823 Show(TrafficTargetArgs),
824 /// Roll back to the previously-saved split for one deployment.
825 Rollback(TrafficTargetArgs),
826}
827
828/// Args for `op traffic set`. All fields are optional at the clap layer so
829/// that `--answers` / `--payload-json` / `--schema` continue to work
830/// unchanged; the dispatcher builds a `TrafficSetPayload` only when the
831/// required clap args are supplied, and otherwise hands `None` to the
832/// library function so it can resolve the payload from `--answers`.
833#[derive(Args, Debug)]
834pub struct TrafficSetArgs {
835 /// Environment id, e.g. `local`.
836 pub env_id: Option<String>,
837 /// Repeated `<revision_id>=<weight>` entries — weight is `N`/`N%`
838 /// (percent) or `Nbps` (basis points). Sum must reach 100 %.
839 pub entries: Vec<String>,
840 /// Deployment ULID.
841 #[arg(long)]
842 pub deployment: Option<String>,
843 /// Idempotency key (required). Same key = no-op replay; different key
844 /// overwrites the rollback target. Any stable string works.
845 #[arg(long)]
846 pub idempotency_key: Option<String>,
847 /// Actor recorded on the split. Defaults to `operator`.
848 #[arg(long)]
849 pub updated_by: Option<String>,
850 /// Sidecar authorization-doc path. Defaults to `auth.json`.
851 #[arg(long)]
852 pub authorization_ref: Option<PathBuf>,
853}
854
855/// Args for `op deploy`. All fields are optional at the clap layer so
856/// `--answers` / `--schema` keep working unchanged; the dispatcher builds a
857/// `BundleDeployPayload` only when args are supplied, and requires `--bundle`
858/// on that direct path.
859#[derive(Args, Debug)]
860pub struct BundleDeployArgs {
861 /// Local `.gtbundle` to deploy. Required on the direct CLI path.
862 #[arg(long)]
863 pub bundle: Option<PathBuf>,
864 /// Environment id. Defaults to `local`.
865 #[arg(long)]
866 pub env: Option<String>,
867 /// Bundle id. Defaults to the `.gtbundle` filename stem.
868 #[arg(long = "bundle-id")]
869 pub bundle_id: Option<String>,
870 /// Billing principal (P6). Defaults to `local-dev` on the `local` env;
871 /// required for any other env.
872 #[arg(long = "customer-id")]
873 pub customer_id: Option<String>,
874 /// Idempotency key for the traffic cut-over. Defaults to a value derived
875 /// from the freshly-minted revision id.
876 #[arg(long = "idempotency-key")]
877 pub idempotency_key: Option<String>,
878 /// D.4: per-pack provider config override (string values). Repeating,
879 /// formatted as `<pack_id>:<key>=<value>`. The value is ALWAYS stored as
880 /// a JSON string — no type coercion. Use `--config-override-json` for
881 /// typed values (bool, number, object, array).
882 /// Example: `--config-override messaging-telegram:api_base_url=https://staging.example.com`.
883 #[arg(long = "config-override", value_name = "PACK_ID:KEY=VALUE")]
884 pub config_override: Vec<String>,
885 /// D.4: per-pack provider config override (typed JSON values). Repeating,
886 /// formatted as `<pack_id>:<key>=<json>`. The value is parsed as JSON;
887 /// a parse error is rejected. Both `--config-override` and
888 /// `--config-override-json` merge into the same map (later flags win
889 /// per-(pack,key)).
890 /// Example: `--config-override-json messaging-telegram:retry_max=5`.
891 #[arg(long = "config-override-json", value_name = "PACK_ID:KEY=JSON")]
892 pub config_override_json: Vec<String>,
893 /// D.4: bulk-load config overrides from a JSON file shaped
894 /// `{"<pack_id>": {"<key>": <value>, ...}, ...}`. Individual
895 /// `--config-override` / `--config-override-json` flags MERGE on top
896 /// (per-pack, per-key).
897 #[arg(long = "config-overrides-from", value_name = "FILE")]
898 pub config_overrides_from: Option<PathBuf>,
899 /// Route binding: path prefix to dispatch into this bundle, e.g. `/legal`.
900 /// Repeating. Sets `route_binding.path_prefixes` at deploy time so a
901 /// follow-up `bundles update` isn't needed for the common case.
902 #[arg(long = "path-prefix", value_name = "PREFIX")]
903 pub path_prefix: Vec<String>,
904 /// Route binding: host to dispatch into this bundle. Repeating.
905 /// Sets `route_binding.hosts` at deploy time.
906 #[arg(long = "host", value_name = "HOST")]
907 pub host: Vec<String>,
908 /// Route binding: tenant id for `tenant_selector`. When supplied, the
909 /// resolved deployment carries this tenant through the runtime config so
910 /// per-tenant secret URIs (e.g. `secrets://<env>/<tenant>/…`) resolve
911 /// correctly. Requires no other routing flag — pair with `--path-prefix`
912 /// or `--host` to make the deployment reachable.
913 #[arg(long = "tenant", value_name = "TENANT")]
914 pub tenant: Option<String>,
915 /// Route binding: team id for `tenant_selector`. Defaults to `default`
916 /// when `--tenant` is supplied without `--team`.
917 #[arg(long = "team", value_name = "TEAM")]
918 pub team: Option<String>,
919}
920
921#[derive(Args, Debug)]
922pub struct TrafficTargetArgs {
923 /// Environment id, e.g. `local`.
924 pub env_id: Option<String>,
925 /// Deployment ULID.
926 #[arg(long)]
927 pub deployment: Option<String>,
928}
929
930#[derive(Subcommand, Debug)]
931pub enum ConfigVerb {
932 Show,
933 Set,
934}
935
936#[derive(Subcommand, Debug)]
937pub enum CredentialsVerb {
938 Requirements,
939 Bootstrap,
940 Rotate,
941}
942
943#[derive(Subcommand, Debug)]
944pub enum SecretsVerb {
945 List,
946 Put,
947 Get,
948 Rotate,
949}
950
951#[derive(Args, Debug)]
952pub struct PayloadArg {
953 /// Inline payload as a JSON string. Mutually exclusive with `--answers`.
954 #[arg(long, conflicts_with = "answers")]
955 pub payload_json: Option<String>,
956}
957
958/// Build a [`LocalFsStore`] honoring `--store-root`, falling back to the
959/// per-user default.
960pub fn build_store(cmd: &OpCommand) -> Result<LocalFsStore, OpError> {
961 let root = match &cmd.store_root {
962 Some(p) => p.clone(),
963 None => LocalFsStore::default_root().ok_or_else(|| {
964 OpError::InvalidArgument("no --store-root and HOME / USERPROFILE not set".to_string())
965 })?,
966 };
967 Ok(LocalFsStore::new(root))
968}
969
970/// Print an `OpOutcome` to stdout as compact JSON and return `Ok(())`.
971pub fn print_outcome(outcome: &OpOutcome) -> Result<(), OpError> {
972 let value = serde_json::to_value(outcome)
973 .map_err(|e| OpError::InvalidArgument(format!("serialize outcome: {e}")))?;
974 println!("{value}");
975 Ok(())
976}
977
978/// Print a typed error in the JSON envelope and return the same error so
979/// the caller can map it to the process exit code.
980pub fn print_error(noun: &'static str, op: &'static str, err: &OpError) {
981 let value = render_error(noun, op, err);
982 eprintln!("{value}");
983}
984
985/// Top-level dispatcher. The verb modules each load their own payload via
986/// `--answers` or `--payload-json`; this function only routes argv into
987/// the right library call.
988///
989/// Uses the built-in env-pack registry (five default `local` handlers).
990/// Phase D plug-ins that register additional handlers should call
991/// [`dispatch_op_with_registry`] instead, passing a registry populated
992/// with both built-ins and their plug-in handlers.
993///
994/// On success, the per-verb result is written to stdout as the documented
995/// `{op, noun, result}` envelope. On error, the documented
996/// `{op, noun, error: {kind, message}}` envelope is written to stderr and
997/// the same `OpError` is returned so callers can map it to a process exit
998/// code without re-rendering. Stdout and stderr never cross-contaminate.
999pub fn dispatch_op(cmd: OpCommand) -> Result<(), OpError> {
1000 let registry = crate::env_packs::EnvPackRegistry::with_builtins();
1001 dispatch_op_with_registry(cmd, ®istry)
1002}
1003
1004/// Phase D plug-in entry point — register handlers on the registry, then
1005/// call this. The `credentials` noun (and any future noun that resolves
1006/// handlers through the registry) will see every handler the caller
1007/// registered.
1008pub fn dispatch_op_with_registry(
1009 cmd: OpCommand,
1010 registry: &crate::env_packs::EnvPackRegistry,
1011) -> Result<(), OpError> {
1012 let flags = OpFlags {
1013 schema_only: cmd.schema,
1014 answers: cmd.answers.clone(),
1015 };
1016 let (noun, verb) = noun_verb_labels(&cmd.noun);
1017
1018 // Remote store selection (PR-3c). --store-url / GREENTIC_STORE_URL picks
1019 // the A8 HTTP backend. Schema-only requests stay local (schema is
1020 // store-independent and never touches the FS), so the operator can
1021 // inspect payloads without a running server.
1022 //
1023 // URL and token are paired by ORIGIN: an env-configured
1024 // GREENTIC_STORE_TOKEN must not leak to an ad-hoc `--store-url` flag
1025 // endpoint. A flag URL only accepts a flag token; an env URL accepts a
1026 // flag token or the env token.
1027 let (store_url, store_token) = crate::cli::dispatch_remote::resolve_remote_target(
1028 cmd.store_url.clone(),
1029 cmd.store_token.clone(),
1030 std::env::var("GREENTIC_STORE_URL").ok(),
1031 std::env::var("GREENTIC_STORE_TOKEN").ok(),
1032 );
1033 if let Some(raw_url) = store_url
1034 && !cmd.schema
1035 {
1036 return crate::cli::dispatch_remote::dispatch_op_remote(&raw_url, store_token, cmd, &flags)
1037 .inspect_err(|err| print_error(noun, verb, err));
1038 }
1039
1040 let store = build_store(&cmd).inspect_err(|err| print_error(noun, verb, err))?;
1041 let result = match cmd.noun {
1042 OpNoun::Env { verb } => dispatch_env(&store, registry, &flags, verb),
1043 OpNoun::EnvPacks { verb } => dispatch_env_packs(&store, &flags, verb),
1044 OpNoun::Bundles { verb } => dispatch_bundles(&store, &flags, verb),
1045 OpNoun::Revisions { verb } => dispatch_revisions(&store, &flags, verb),
1046 OpNoun::Traffic { verb } => dispatch_traffic(&store, &flags, verb),
1047 OpNoun::Deploy(args) => dispatch_deploy(&store, &flags, args),
1048 OpNoun::Config { verb } => dispatch_config(&store, &flags, verb),
1049 OpNoun::Credentials { verb } => dispatch_credentials(&store, registry, &flags, verb),
1050 OpNoun::Secrets { verb } => dispatch_secrets(&store, &flags, verb),
1051 OpNoun::TrustRoot { verb } => dispatch_trust_root(&store, &flags, verb),
1052 OpNoun::Messaging { verb } => dispatch_messaging(&store, &flags, verb),
1053 OpNoun::Extensions { verb } => dispatch_extensions(&store, &flags, verb),
1054 OpNoun::Updates { verb } => dispatch_updates(&store, &flags, verb),
1055 };
1056 result.inspect_err(|err| print_error(noun, verb, err))
1057}
1058
1059/// Map an [`OpNoun`] to its `(noun, verb)` static label pair for envelope
1060/// rendering. Kept in lockstep with the verb enums above; adding a new
1061/// noun/verb requires extending the match here too.
1062pub fn noun_verb_labels(noun: &OpNoun) -> (&'static str, &'static str) {
1063 match noun {
1064 OpNoun::Env { verb } => (
1065 "env",
1066 match verb {
1067 EnvVerb::Init(_) => "init",
1068 EnvVerb::Apply(_) => "apply",
1069 EnvVerb::Create(_) => "create",
1070 EnvVerb::Update(_) => "update",
1071 EnvVerb::SetPublicUrl(_) => "set-public-url",
1072 EnvVerb::List => "list",
1073 EnvVerb::Show { .. } => "show",
1074 EnvVerb::Doctor { .. } => "doctor",
1075 EnvVerb::ToolCheck { .. } => "tool-check",
1076 EnvVerb::Render(_) => "render",
1077 EnvVerb::Reconcile(_) => "reconcile",
1078 EnvVerb::ApplyRevision(_) => "apply-revision",
1079 EnvVerb::ApplyTraffic(_) => "apply-traffic",
1080 EnvVerb::Destroy { .. } => "destroy",
1081 EnvVerb::MigrateDev { .. } => "migrate-dev",
1082 EnvVerb::MigrateState { .. } => "migrate-state",
1083 },
1084 ),
1085 OpNoun::EnvPacks { verb } => (
1086 "env-packs",
1087 match verb {
1088 EnvPacksVerb::Add => "add",
1089 EnvPacksVerb::Update => "update",
1090 EnvPacksVerb::Remove => "remove",
1091 EnvPacksVerb::Rollback => "rollback",
1092 EnvPacksVerb::List { .. } => "list",
1093 },
1094 ),
1095 OpNoun::Bundles { verb } => (
1096 "bundles",
1097 match verb {
1098 BundlesVerb::Add => "add",
1099 BundlesVerb::Update => "update",
1100 BundlesVerb::Remove => "remove",
1101 BundlesVerb::List { .. } => "list",
1102 },
1103 ),
1104 OpNoun::Revisions { verb } => (
1105 "revisions",
1106 match verb {
1107 RevisionsVerb::Stage(_) => "stage",
1108 RevisionsVerb::Warm => "warm",
1109 RevisionsVerb::Drain => "drain",
1110 RevisionsVerb::Archive => "archive",
1111 RevisionsVerb::List { .. } => "list",
1112 },
1113 ),
1114 OpNoun::Traffic { verb } => (
1115 "traffic",
1116 match verb {
1117 TrafficVerb::Set(_) => "set",
1118 TrafficVerb::Show(_) => "show",
1119 TrafficVerb::Rollback(_) => "rollback",
1120 },
1121 ),
1122 OpNoun::Deploy(_) => ("deploy", "run"),
1123 OpNoun::Config { verb } => (
1124 "config",
1125 match verb {
1126 ConfigVerb::Show => "show",
1127 ConfigVerb::Set => "set",
1128 },
1129 ),
1130 OpNoun::Credentials { verb } => (
1131 "credentials",
1132 match verb {
1133 CredentialsVerb::Requirements => "requirements",
1134 CredentialsVerb::Bootstrap => "bootstrap",
1135 CredentialsVerb::Rotate => "rotate",
1136 },
1137 ),
1138 OpNoun::Secrets { verb } => (
1139 "secrets",
1140 match verb {
1141 SecretsVerb::List => "list",
1142 SecretsVerb::Put => "put",
1143 SecretsVerb::Get => "get",
1144 SecretsVerb::Rotate => "rotate",
1145 },
1146 ),
1147 OpNoun::TrustRoot { verb } => (
1148 "trust-root",
1149 match verb {
1150 TrustRootVerb::Bootstrap { .. } => "bootstrap",
1151 TrustRootVerb::List { .. } => "list",
1152 TrustRootVerb::Add(_) => "add",
1153 TrustRootVerb::Remove(_) => "remove",
1154 },
1155 ),
1156 OpNoun::Messaging { verb } => (
1157 "messaging.endpoint",
1158 match verb {
1159 MessagingNoun::Endpoint { verb } => match verb {
1160 MessagingEndpointVerb::Add(_) => "add",
1161 MessagingEndpointVerb::List { .. } => "list",
1162 MessagingEndpointVerb::Show { .. } => "show",
1163 MessagingEndpointVerb::LinkBundle(_) => "link-bundle",
1164 MessagingEndpointVerb::UnlinkBundle(_) => "unlink-bundle",
1165 MessagingEndpointVerb::SetWelcomeFlow(_) => "set-welcome-flow",
1166 MessagingEndpointVerb::Remove(_) => "remove",
1167 MessagingEndpointVerb::RotateWebhookSecret(_) => "rotate-webhook-secret",
1168 },
1169 },
1170 ),
1171 OpNoun::Extensions { verb } => (
1172 "extensions",
1173 match verb {
1174 ExtensionsVerb::Add => "add",
1175 ExtensionsVerb::Update => "update",
1176 ExtensionsVerb::Remove => "remove",
1177 ExtensionsVerb::Rollback => "rollback",
1178 ExtensionsVerb::List { .. } => "list",
1179 },
1180 ),
1181 OpNoun::Updates { verb } => (
1182 "updates",
1183 match verb {
1184 UpdatesVerb::Enroll(_) => "enroll",
1185 UpdatesVerb::Status { .. } => "status",
1186 UpdatesVerb::Get(_) => "get",
1187 UpdatesVerb::Apply(_) => "apply",
1188 UpdatesVerb::Recover(_) => "recover",
1189 UpdatesVerb::ConfigSet(_) => "config-set",
1190 UpdatesVerb::ConfigShow { .. } => "config-show",
1191 UpdatesVerb::PlanBuild(_) => "plan-build",
1192 },
1193 ),
1194 }
1195}
1196
1197fn dispatch_env(
1198 store: &LocalFsStore,
1199 registry: &crate::env_packs::EnvPackRegistry,
1200 flags: &OpFlags,
1201 verb: EnvVerb,
1202) -> Result<(), OpError> {
1203 let outcome = match verb {
1204 EnvVerb::Init(args) => super::env::init(store, flags, args.into_payload(flags)?)?,
1205 EnvVerb::Apply(mut args) => {
1206 if let Some(path) = args.emit_answers_template.take() {
1207 super::env_apply::emit_answers_template(&path)?
1208 } else {
1209 super::env_apply::apply(store, flags, args.into_options())?
1210 }
1211 }
1212 EnvVerb::Create(args) => {
1213 super::env::create(store, flags, args.into_payload("create", flags)?)?
1214 }
1215 EnvVerb::Update(args) => {
1216 super::env::update(store, flags, args.into_payload("update", flags)?)?
1217 }
1218
1219 EnvVerb::SetPublicUrl(args) => {
1220 super::env::set_public_url(store, flags, &args.env_id, &args.url)?
1221 }
1222 EnvVerb::List => super::env::list(store, flags)?,
1223 EnvVerb::Show { env_id } => super::env::show(store, flags, &env_id)?,
1224 EnvVerb::Doctor { env_id } => super::env::doctor(store, flags, &env_id)?,
1225 EnvVerb::ToolCheck { env_id } => super::env::tool_check(store, flags, &env_id)?,
1226 EnvVerb::Render(args) => super::env::render(store, registry, flags, args)?,
1227 EnvVerb::Reconcile(args) => super::env::reconcile(store, registry, flags, args)?,
1228 EnvVerb::ApplyRevision(args) => super::env::apply_revision(store, registry, flags, args)?,
1229 EnvVerb::ApplyTraffic(args) => super::env::apply_traffic(store, registry, flags, args)?,
1230 EnvVerb::Destroy { env_id, confirm } => {
1231 super::env::destroy(store, flags, &env_id, confirm)?
1232 }
1233 EnvVerb::MigrateDev {
1234 target,
1235 check,
1236 apply,
1237 } => {
1238 if !(check ^ apply) {
1239 return Err(OpError::InvalidArgument(
1240 "migrate-dev requires exactly one of --check or --apply".to_string(),
1241 ));
1242 }
1243 if check {
1244 super::migrate::check(store, flags, &target)?
1245 } else {
1246 super::migrate::apply(store, flags, &target)?
1247 }
1248 }
1249 EnvVerb::MigrateState {
1250 target,
1251 check,
1252 apply,
1253 state_dir,
1254 } => {
1255 if !(check ^ apply) {
1256 return Err(OpError::InvalidArgument(
1257 "migrate-state requires exactly one of --check or --apply".to_string(),
1258 ));
1259 }
1260 if check {
1261 super::migrate_state::check(store, flags, &target, state_dir.as_deref())?
1262 } else {
1263 super::migrate_state::apply(store, flags, &target, state_dir.as_deref())?
1264 }
1265 }
1266 };
1267 print_outcome(&outcome)
1268}
1269
1270fn dispatch_env_packs(
1271 store: &LocalFsStore,
1272 flags: &OpFlags,
1273 verb: EnvPacksVerb,
1274) -> Result<(), OpError> {
1275 let outcome = match verb {
1276 EnvPacksVerb::Add => super::env_packs::add(store, flags, None)?,
1277 EnvPacksVerb::Update => super::env_packs::update(store, flags, None)?,
1278 EnvPacksVerb::Remove => super::env_packs::remove(store, flags, None)?,
1279 EnvPacksVerb::Rollback => super::env_packs::rollback(store, flags, None)?,
1280 EnvPacksVerb::List { env_id } => super::env_packs::list(store, flags, &env_id)?,
1281 };
1282 print_outcome(&outcome)
1283}
1284
1285fn dispatch_extensions(
1286 store: &LocalFsStore,
1287 flags: &OpFlags,
1288 verb: ExtensionsVerb,
1289) -> Result<(), OpError> {
1290 let outcome = match verb {
1291 ExtensionsVerb::Add => super::extensions::add(store, flags, None)?,
1292 ExtensionsVerb::Update => super::extensions::update(store, flags, None)?,
1293 ExtensionsVerb::Remove => super::extensions::remove(store, flags, None)?,
1294 ExtensionsVerb::Rollback => super::extensions::rollback(store, flags, None)?,
1295 ExtensionsVerb::List { env_id } => super::extensions::list(store, flags, &env_id)?,
1296 };
1297 print_outcome(&outcome)
1298}
1299
1300fn dispatch_bundles(
1301 store: &LocalFsStore,
1302 flags: &OpFlags,
1303 verb: BundlesVerb,
1304) -> Result<(), OpError> {
1305 let outcome = match verb {
1306 BundlesVerb::Add => super::bundles::add(store, flags, None)?,
1307 BundlesVerb::Update => super::bundles::update(store, flags, None)?,
1308 BundlesVerb::Remove => super::bundles::remove(store, flags, None)?,
1309 BundlesVerb::List { env_id } => super::bundles::list(store, flags, &env_id)?,
1310 };
1311 print_outcome(&outcome)
1312}
1313
1314fn dispatch_revisions(
1315 store: &LocalFsStore,
1316 flags: &OpFlags,
1317 verb: RevisionsVerb,
1318) -> Result<(), OpError> {
1319 let outcome = match verb {
1320 RevisionsVerb::Stage(args) => {
1321 let payload = super::revisions::payload_from_stage_args(args)?;
1322 super::revisions::stage(store, flags, payload)?
1323 }
1324 RevisionsVerb::Warm => super::revisions::warm(store, flags, None)?,
1325 RevisionsVerb::Drain => super::revisions::drain(store, flags, None)?,
1326 RevisionsVerb::Archive => super::revisions::archive(store, flags, None)?,
1327 RevisionsVerb::List { env_id } => super::revisions::list(store, flags, &env_id)?,
1328 };
1329 print_outcome(&outcome)
1330}
1331
1332fn dispatch_traffic(
1333 store: &LocalFsStore,
1334 flags: &OpFlags,
1335 verb: TrafficVerb,
1336) -> Result<(), OpError> {
1337 let outcome = match verb {
1338 TrafficVerb::Set(args) => {
1339 let payload = super::traffic::payload_from_set_args(args)?;
1340 super::traffic::set(store, flags, payload)?
1341 }
1342 TrafficVerb::Show(args) => {
1343 let payload = super::traffic::payload_from_target_args(args)?;
1344 super::traffic::show(store, flags, payload)?
1345 }
1346 TrafficVerb::Rollback(args) => {
1347 let payload = super::traffic::payload_from_target_args(args)?;
1348 super::traffic::rollback(store, flags, payload)?
1349 }
1350 };
1351 print_outcome(&outcome)
1352}
1353
1354fn dispatch_deploy(
1355 store: &LocalFsStore,
1356 flags: &OpFlags,
1357 args: BundleDeployArgs,
1358) -> Result<(), OpError> {
1359 let payload = super::deploy::payload_from_deploy_args(args)?;
1360 let outcome = super::deploy::deploy(store, flags, payload)?;
1361 print_outcome(&outcome)
1362}
1363
1364fn dispatch_config(store: &LocalFsStore, flags: &OpFlags, verb: ConfigVerb) -> Result<(), OpError> {
1365 let outcome = match verb {
1366 ConfigVerb::Show => super::config::show(store, flags, None)?,
1367 ConfigVerb::Set => super::config::set(store, flags, None)?,
1368 };
1369 print_outcome(&outcome)
1370}
1371
1372fn dispatch_credentials(
1373 store: &LocalFsStore,
1374 registry: &crate::env_packs::EnvPackRegistry,
1375 flags: &OpFlags,
1376 verb: CredentialsVerb,
1377) -> Result<(), OpError> {
1378 let outcome = match verb {
1379 CredentialsVerb::Requirements => {
1380 super::credentials::requirements(store, registry, flags, None)?
1381 }
1382 CredentialsVerb::Bootstrap => super::credentials::bootstrap(store, registry, flags, None)?,
1383 CredentialsVerb::Rotate => super::credentials::rotate(store, registry, flags, None)?,
1384 };
1385 print_outcome(&outcome)
1386}
1387
1388fn dispatch_secrets(
1389 store: &LocalFsStore,
1390 flags: &OpFlags,
1391 verb: SecretsVerb,
1392) -> Result<(), OpError> {
1393 let outcome = match verb {
1394 SecretsVerb::List => super::secrets::list(store, flags, None)?,
1395 SecretsVerb::Put => super::secrets::put(store, flags, None)?,
1396 SecretsVerb::Get => super::secrets::get(store, flags, None)?,
1397 SecretsVerb::Rotate => super::secrets::rotate(store, flags, None)?,
1398 };
1399 print_outcome(&outcome)
1400}
1401
1402fn dispatch_trust_root(
1403 store: &LocalFsStore,
1404 flags: &OpFlags,
1405 verb: TrustRootVerb,
1406) -> Result<(), OpError> {
1407 let outcome = match verb {
1408 TrustRootVerb::Bootstrap { env_id } => {
1409 let payload = env_id
1410 .map(|id| super::trust_root::TrustRootBootstrapPayload { environment_id: id });
1411 super::trust_root::bootstrap(store, flags, payload)?
1412 }
1413 TrustRootVerb::List { env_id } => super::trust_root::list(store, flags, &env_id)?,
1414 TrustRootVerb::Add(args) => {
1415 let payload = match (args.env_id, args.key_id) {
1416 (Some(env_id), Some(key_id)) => Some(super::trust_root::TrustRootAddPayload {
1417 environment_id: env_id,
1418 key_id,
1419 public_key_pem: args.public_key_pem,
1420 public_key_file: args.public_key_file,
1421 }),
1422 _ => None, // fall through to --answers / --schema
1423 };
1424 super::trust_root::add(store, flags, payload)?
1425 }
1426 TrustRootVerb::Remove(args) => {
1427 let payload = match (args.env_id, args.key_id) {
1428 (Some(env_id), Some(key_id)) => Some(super::trust_root::TrustRootRemovePayload {
1429 environment_id: env_id,
1430 key_id,
1431 }),
1432 _ => None,
1433 };
1434 super::trust_root::remove(store, flags, payload)?
1435 }
1436 };
1437 print_outcome(&outcome)
1438}
1439
1440fn dispatch_updates(
1441 store: &LocalFsStore,
1442 flags: &OpFlags,
1443 verb: UpdatesVerb,
1444) -> Result<(), OpError> {
1445 let outcome = match verb {
1446 UpdatesVerb::Enroll(args) => {
1447 let payload = match (args.env_id, args.ca_url) {
1448 (Some(environment_id), Some(ca_url)) => {
1449 Some(super::updates::UpdatesEnrollPayload {
1450 environment_id,
1451 ca_url,
1452 })
1453 }
1454 _ => None, // fall through to --answers / --schema
1455 };
1456 super::updates::enroll(store, flags, payload)?
1457 }
1458 UpdatesVerb::Status { env_id } => {
1459 let payload = env_id
1460 .map(|environment_id| super::updates::UpdatesStatusPayload { environment_id });
1461 super::updates::status(store, flags, payload)?
1462 }
1463 UpdatesVerb::Get(args) => {
1464 let payload = args
1465 .env_id
1466 .map(|environment_id| super::updates::UpdatesGetPayload {
1467 environment_id,
1468 plan_url: args.plan_url,
1469 plan_file: args.plan_file,
1470 plan_sig_file: args.plan_sig_file,
1471 });
1472 super::updates::get(store, flags, payload)?
1473 }
1474 UpdatesVerb::Apply(args) => {
1475 let payload = match (args.env_id, args.plan_id) {
1476 (Some(environment_id), Some(plan_id)) => {
1477 Some(super::updates::ApplyUpdatesPayload {
1478 environment_id,
1479 plan_id,
1480 })
1481 }
1482 _ => None, // fall through to --answers / --schema
1483 };
1484 super::updates::apply_updates(store, flags, payload)?
1485 }
1486 UpdatesVerb::Recover(args) => {
1487 // `--force` is operator attestation, not a payload field: thread it
1488 // separately so it applies whether the ids come from the CLI or from
1489 // `--answers` (it is never silently dropped on the answers path).
1490 let force = args.force;
1491 let payload = match (args.env_id, args.plan_id) {
1492 (Some(environment_id), Some(plan_id)) => {
1493 Some(super::updates::RecoverUpdatesPayload {
1494 environment_id,
1495 plan_id,
1496 })
1497 }
1498 _ => None, // fall through to --answers / --schema
1499 };
1500 super::updates::recover_updates(store, flags, payload, force)?
1501 }
1502 UpdatesVerb::ConfigSet(args) => {
1503 let payload =
1504 args.env_id
1505 .map(|environment_id| super::updates::UpdateConfigSetPayload {
1506 environment_id,
1507 enabled: args.enabled,
1508 on_notify: args.on_notify,
1509 poll_interval_secs: args.poll_interval_secs,
1510 });
1511 super::updates::config_set(store, flags, payload)?
1512 }
1513 UpdatesVerb::ConfigShow { env_id } => {
1514 let payload = env_id
1515 .map(|environment_id| super::updates::UpdateConfigShowFilter { environment_id });
1516 super::updates::config_show(store, flags, payload)?
1517 }
1518 UpdatesVerb::PlanBuild(args) => super::updates::plan_build(store, flags, args)?,
1519 };
1520 print_outcome(&outcome)
1521}
1522
1523fn dispatch_messaging(
1524 store: &LocalFsStore,
1525 flags: &OpFlags,
1526 verb: MessagingNoun,
1527) -> Result<(), OpError> {
1528 let outcome = match verb {
1529 MessagingNoun::Endpoint { verb } => match verb {
1530 MessagingEndpointVerb::Add(args) => {
1531 super::messaging::add(store, flags, args.into_payload("add", flags)?)?
1532 }
1533 MessagingEndpointVerb::List { env_id } => {
1534 super::messaging::list(store, flags, &env_id)?
1535 }
1536 MessagingEndpointVerb::Show {
1537 env_id,
1538 endpoint_id,
1539 } => super::messaging::show(store, flags, &env_id, &endpoint_id)?,
1540 MessagingEndpointVerb::LinkBundle(args) => super::messaging::link_bundle(
1541 store,
1542 flags,
1543 args.into_payload("link-bundle", flags)?,
1544 )?,
1545 MessagingEndpointVerb::UnlinkBundle(args) => super::messaging::unlink_bundle(
1546 store,
1547 flags,
1548 args.into_payload("unlink-bundle", flags)?,
1549 )?,
1550 MessagingEndpointVerb::SetWelcomeFlow(args) => super::messaging::set_welcome_flow(
1551 store,
1552 flags,
1553 args.into_payload("set-welcome-flow", flags)?,
1554 )?,
1555 MessagingEndpointVerb::Remove(args) => {
1556 super::messaging::remove(store, flags, args.into_remove_payload("remove", flags)?)?
1557 }
1558 MessagingEndpointVerb::RotateWebhookSecret(args) => {
1559 super::messaging::rotate_webhook_secret(
1560 store,
1561 flags,
1562 args.into_rotate_payload("rotate-webhook-secret", flags)?,
1563 )?
1564 }
1565 },
1566 };
1567 print_outcome(&outcome)
1568}
1569
1570/// Silence the `CapabilitySlot` re-export warning while preserving the symbol
1571/// for downstream noun modules that take a slot positional in future work.
1572#[allow(dead_code)]
1573fn _slot_anchor(_: CapabilitySlot) {}