Files
harmony/docs/adr/025-application-lifecycle-cli.md
Sylvain Tremblay 51c9499c55 docs(adr): add ADR-025 application lifecycle CLI
Define the dev-facing CLI contract: `harmony <scope> <verb>` grammar,
mandatory explicit context (no default), declarative/operational verb
split, three config homes, per-env profile tag, and the machine/agent
contract. Add the living CLI use-case & command guide and register it in
the mdbook nav. Mark ADR-012 superseded by 025.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 06:06:59 -04:00

14 KiB
Raw Permalink Blame History

Architecture Decision Record: Application Lifecycle CLI — Dev-Facing Verbs, Contexts, and the Declarative Boundary

Initial Author: Sylvain Tremblay

Initial Date: 2026-06-10

Last Updated Date: 2026-06-10

Status

Proposed (draft)

Extends ADR-023 (deploy architecture) — whose CLI principle (8) only covers how binaries are discovered — with the CLI's experience contract: verb grammar, the context/identity model, the declarative/operational boundary, and where "config" lives. Revises ADR-012 (project delivery automation) on three points: no mandatory staging, GitOps is not the deploy interface, and environment targeting is an explicit context rather than an ambient kubeconfig.

Context

We need one CLI that lets a person or a machine drive the full lifecycle of a client application running on a Harmony-managed tenant — build, deploy, inspect, operate. The first concrete case — a small team shipping a timesheet-style app — sets the shape of the problem: they develop locally and ship straight to production, roll-forward only — no staging, no rollback.

The audience is three-headed and shares a single surface: developers (the priority for this phase), CI/automation, and autonomous agents. The design is informed by a survey of kubectl, argocd, flux, fly.io, heroku, pulumi, dagger, terraform, ansible, and gcloud, plus current practice on CLIs built for AI agents. Two through-lines drove every decision below:

  1. Harmony already owns the vocabulary the best tools bolt on. A computed idempotent result (Outcome: NOOP/RUNNING/…), declarative desired state (Scores), a reconciling engine (Maestro), and named targets (Topologies). The CLI's job is to surface these, not invent machinery.
  2. The agent/CI contract is a strict-mode projection of good human DX. Non-interactive, JSON, idempotent, predictable exit codes — the same features a careful human wants. Build the human CLI right and the machine CLI falls out.

Constraints that bound the design:

  • Runs locally (k3d) and on any K8s (K8sAnywhereTopology) — nothing may be OKD- or vendor-specific.
  • Harmony's anti-"YAML mud pit" / compile-time-safety ethos (ADR-005) forbids reintroducing untyped, runtime-validated configuration in any form, including a TOML or YAML file of deployment knobs.

Decision

Eleven principles, grouped.

Targeting & safety

  1. No default context, ever. Every command that touches a cluster requires an explicit target: --context <name> flag, else the HARMONY_CONTEXT env var, else a hard error that lists known contexts. There is no "current context" and no implicit fallback — even local k3d must be selected (--context local). This makes "deployed to the wrong cluster" structurally impossible.

  2. Deploy is CLI-direct and context-targeted; GitOps is not the interface. A developer runs harmony app deploy --context <env> explicitly. The same verb and the same Scores deploy to local k3d (to test) or to a production tenant — only the Topology selected by the context changes (ADR-023 §2). CI runs the identical command; git-push-triggers-deploy is optional sugar a team may add, never a requirement. A continuous in-cluster reconciler (GitOps-style) is a possible future Topology, not the primary path. This supersedes ADR-012's Argo/Flux-first stance.

Project & identity

  1. Implicit app, explicit context. The app identity comes from the project (a tiny, identity-only Harmony.toml at the repo root, or [package.metadata.harmony] in the deploy crate) — the app name, and little else. Everything else derives by convention from name × context: namespace = {tenant-from-context}-{app}, image = {registry}/{project}/{app}, workload selector = by app label. A behavioral knob (replicas, env, resources) in this file is a defect — it belongs in a Score (see §6). Apps that break the naming convention fall back to the deploy crate; that escape hatch is not built until a real non-conventional app exists (Rule of Three).

The declarative boundary

  1. Two verb classes. This is the load-bearing distinction. It honors ADR-023's "no handrolled manifests" while still giving developers day-2 ergonomics.

    Class Verbs Path Mutates desired state? Needs compile?
    Declarative build · publish · deploy · ship (= build+publish+deploy) · check · run (one-off Job) through the project's Scores, re-converge yes yes (runs deploy crate)
    Operational logs · status · exec · forward · restart · describe · history direct kube client, read-only / ephemeral no no (metadata only)

    build, publish, and deploy are independently invocable and compose into ship. deploy takes an explicit image (--image <digest>) and only converges — it does not build. build produces a digest-pinned image, publish pushes it, and ship = build + publish + deploy. This is what makes roll-forward recovery — the only recovery a no-rollback team has — a plain harmony app deploy --image <prior-good-digest> with no rebuild, and it lets CI run the stages separately (digest handoff, debuggable failures). publish is Topology-specific: against a remote context it pushes the digest-pinned image to the registry; against a local context it is coded to import the image directly into k3d (k3d image import) instead of pushing. The verb is the same; the Topology supplies the behavior (ADR-023 §2).

    The local inner loop is simply harmony app ship --context local (build + deploy to local k3d) — there is no separate serve verb, because an explicit local context plus the standard verbs already cover it (principle 11; a second verb would be ceremony). check is the only pre-deploy validation we have today: it compiles and type-checks the Scores (there is no computed diff against live state — see Out of scope).

  2. No imperative state mutation. There is no scale 3 or set-env. Operational verbs are precisely the ones that don't touch desired state — that is the basis of the split. Changing replicas, env, resources, or routes means editing the Score and redeploying. An imperative shortcut would only create drift that the next deploy clobbers.

  3. Three homes for "config" — none of them a config file.

    • Desired state / behavior (replicas, env, resources, routes, dependencies) → typed Scores, git-versioned, compile-checked.
    • Target + credentials (which cluster/tenant, how to auth) → context / Topology, selected explicitly.
    • SecretsOpenBao (SecretVault), referenced by Scores, fetched at deploy. Never in code, never in a file.
  4. Per-environment values are a Score computed as a function of the context. Differences (prod = 3 replicas + managed Postgres; local = 1 replica + sqlite) are expressed in typed code that branches on a profile tag carried by the context — the Pulumi-stack idea in pure Rust, validated by the compiler. The profile is a structured field on the context, not encoded in its name: a Score branches on ctx.profile(), never on the context name (a free human handle — myapp-prod and otherapp-prod may share profile = Prod). (A dedicated typed Profile input is deferred until divergence justifies it — Rule of Three.)

  5. Reconciliation is invocation-driven (today). Convergence happens when someone runs harmony app deploy, not continuously. Out-of-band changes survive until the next deploy, then are overwritten. A continuous controller that reverts drift in real time is a deliberate future, not v1.

One surface for humans, CI, and agents

  1. Machine contract, always on. Human-readable output by default (TTY-detected); --json opts into a frozen, versioned schema — JSON to stdout, all logs/progress to stderr. No hidden prompts when stdin is not a TTY. Verbs are idempotent. Outcome maps onto exit codes (e.g. success/noop = 0, failure/blocked = non-zero) so CI and agents branch programmatically without scraping text. An agent skill/MCP surface is derived from this CLI later — never hand-built in parallel.

Credentials

  1. The credential source is part of the context, and degrades to local. A context is { cluster (endpoint + CA), tenant, credential source / identity, profile } — and carries no role: authorization is a server-side property of the identity (Zitadel token claims → OpenBao policy → RBAC), never a client-trusted field. Locally, the context yields the ambient k3d/kubeconfig — no identity infrastructure required. Remotely, the context carries a brokered source: authenticate a Zitadel service user (machine identity, role-scoped), exchange for an OpenBao-brokered, short-lived, namespace-scoped Kubernetes token, and present that. The CLI holds nothing standing; authorization is enforced server-side (OpenBao policy + cluster RBAC). The broker is K8s- agnostic by construction (standard TokenRequest + RBAC). Detailed broker mechanics and tenant/identity provisioning may warrant a follow-up ADR; provisioning is manual for now.

Grammar

  1. Grammar: harmony <scope> <verb>. A small, fixed set of scope nouns organizes the surface by persona and object: app (the developer lifecycle — harmony app deploy, app logs, app ship, …), tenant (tenant-admin), cluster (cluster-admin), and context (everyone). The app instance stays implicit — it is the project's app (principle 3); app is the scope group, not an instance argument, so it is harmony app deploy, never harmony app deploy myapp. This uniform tree is predictable for humans and breadth-first-discoverable for agents (harmony --help → scopes, harmony app --help → verbs). We grow by adding verbs / resource types under an existing scope, never by proliferating top-level commands (argocd's sprawl is the anti-pattern), and add no magic top-level verb aliases — there is no flat harmony deploy shortcut; one way to do it.

Rationale

  • No default context trades a keystroke for the elimination of an entire class of catastrophic mistake. For a tool whose first job is shipping to production, that trade is obviously correct.
  • Declarative-only is the single highest-leverage choice: every surveyed tool that offers both imperative and declarative mutation (kubectl edit/scale vs apply) suffers drift and clobbering. By making operational verbs structurally incapable of touching desired state, the conflict cannot occur.
  • Convention over config keeps the project file at one line, which is the only way to keep it from rotting into the mud pit ADR-005 rejects — and it makes operational verbs instant and toolchain-free, since they need only metadata + a kube client.
  • One surface is cheaper and more correct than maintaining separate human and machine tools, and it means an agent and a developer learn the same verbs.

Consequences

  • harmony_cli grows from a flag-only score-runner into a verb-noun CLI; deploy crates expose the standard verbs rather than ad-hoc clap.
  • Developers never hand-edit live state; every change is code + redeploy → reproducible, reviewable, diffable in git, with no drift surprises (until the next deploy, per §8).
  • The same command works local → prod; CI is just another caller, and agents drive the JSON contract.
  • A behavioral knob added to Harmony.toml is a review failure, not a feature.
  • Cost: no live "what will change" preview until a diff capability is built (Out of scope); the v1 substitute is "deploy to local and observe."

Alternatives considered

  • GitOps-first (Argo/Flux), per ADR-012. Rejected as the primary interface: it forces a git round-trip for every change, hides the build behind a controller, and answers poorly to "deploy locally to test." Retained only as a possible future Topology.
  • A config manifest with deployment knobs (Harmony.toml holding replicas/env/resources). Rejected: it is the YAML mud pit in TOML clothing — untyped, runtime-validated, the exact anti-pattern of ADR-005.
  • Ambient / current context (kubectl/fly style). Rejected: a default target is a standing invitation to deploy to the wrong cluster.
  • A standing service-account token as a CI secret. Rejected: a long-lived credential where Zitadel + OpenBao can mint short-lived, identity-bound ones.
  • No project file — pure deploy-crate discovery. Rejected: it forces a cargo compile just to learn an app's own namespace for logs.

Out of scope (deferred, not rejected)

  • plan / diff / delta. No capability computes desired-vs-live state today; Outcome is a per-Score result, not a preview. This is the eventual differentiator and deserves its own design.
  • Continuous in-cluster reconciler (real-time drift correction).
  • Step-0 provisioning via CLI (tenant, app, identity triple) — manual for now.
  • Tenant dashboard (web UI for the tenant-admin persona).
  • MCP server / agent skill manifest (derived from the CLI later).
  • CI-platform OIDC federation replacing the handed-off deploy key.
  • Typed Profile input for per-environment values (§7).
  • Non-convention app fallback (multi-namespace / custom layout, §3).

References

  • docs/adr/012-project-delivery-automation.md — predecessor; this ADR revises its staging/GitOps/kubeconfig assumptions.
  • docs/adr/023-deploy-architecture.md — deploy crates, Scores, the E2E contract; this ADR fills in its CLI principle (8).
  • docs/adr/005-interactive-project.md — Rust DSL over YAML/HCL ("no mud pit").
  • docs/adr/020-1-zitadel-openbao-secure-config-store.md — identity + secret backends the credential model (§10) composes.
  • docs/adr/016-Harmony-Agent-And-Global-Mesh-…md — the mesh that a future remote-deploy control plane would ride on.
  • CLAUDE.md — Score-Topology-Interpret, capability rules.