All checks were successful
Run Check Script / check (pull_request) Successful in 2m22s
Clarification + concrete mechanism for ADR-012 (Project Delivery Automation). Pulls together two prior attempts (modules/application RustWebapp + the per-component harmony-fleet-release binary) and proposes a unified shape: release is a Score driven by Topology capabilities (ContainerBuilder, OciRegistry, HelmRegistry, ContinuousDelivery), composed alongside DeployScore / MonitoringScore into the opinionated pipeline ADR-012 specified. Kept in drafts as 012-1 rather than a fresh ADR-025 — this implements ADR-012's intent, doesn't compete with it. Open questions deliberately left open for further 012-N follow-ups.
391 lines
17 KiB
Markdown
391 lines
17 KiB
Markdown
# ADR-012 follow-up: Release Architecture Mechanism (Capability-Driven Build, Package, Push)
|
|
|
|
Initial Author: Jean-Gabriel Gill-Couture (drafted with Claude)
|
|
|
|
Initial Date: 2026-05-27
|
|
|
|
Last Updated Date: 2026-05-27
|
|
|
|
## Status
|
|
|
|
**Draft. Clarification + mechanism for ADR-012, not a separate
|
|
decision.** ADR-012 (Project Delivery Automation, 2025-06-04)
|
|
locked the *intent*: application-level Scores (LAMPScore-style)
|
|
drive an opinionated pipeline — *empty-check → package&publish →
|
|
deploy staging → sanity-check → deploy production → sanity-check* —
|
|
with CD tools (Argo / Flux) activated by default and called via
|
|
API; the same `harmony` binary runs the project's module locally
|
|
and in CI.
|
|
|
|
This document supplements that intent with the concrete *mechanism*:
|
|
how steps 2, 3, 5 (package&publish, deploy staging, deploy
|
|
production) actually compile and execute against today's
|
|
Score-Topology-Capability primitives, and what to delete from the
|
|
two interim attempts (`modules/application` + the per-component
|
|
`harmony-fleet-release` binary) when the mechanism lands.
|
|
|
|
Companion references:
|
|
- **ADR-023 (Deploy Architecture, Accepted)** — the `DeployScore`
|
|
proposed here plugs into the `harmony_cli::run` flow and
|
|
`*-deploy` crate pattern ADR-023 mandates; the smoke-test
|
|
contract (ADR-023 principle 4) is how steps 4 and 6 of ADR-012
|
|
block.
|
|
- **ADR-024 draft (Fleet Platform Capability Decomposition)** —
|
|
same decomposition shape, applied to release: framework-level
|
|
capability traits, not per-app methods.
|
|
- **ADR-003** — capability traits represent industry concepts, not
|
|
tools. Every new capability here passes the swap-out test.
|
|
- **ADR-018** — template hydration. `ChartSource::Builder` keeps
|
|
the chart fully typed up to package time.
|
|
|
|
Open questions remain in the *Open questions* section below; they
|
|
are deliberately left open for follow-up clarifications to bolt on
|
|
the same way this one bolts on to ADR-012.
|
|
|
|
## Context
|
|
|
|
ADR-012's intent never got a concrete mechanism beyond `LAMPScore`
|
|
and the (later-abandoned) `modules/application` parallel hierarchy.
|
|
As a result, today every app harmony hosts re-implements its own
|
|
release pipeline:
|
|
|
|
- A ~270-line per-component release binary (`fleet_release.rs`).
|
|
- A per-component `release.sh` wrapper script.
|
|
- A per-component `.gitea/workflows/<app>-<component>.yaml`.
|
|
- Hand-written chart hydration (`fleet/harmony-fleet-deploy/src/operator/chart.rs`).
|
|
|
|
PR review (#301) flagged this directly: *"we should not have to
|
|
rebuild a new cli for every component of every app using harmony"*
|
|
and *"the burden of designing the process and cli should not be on
|
|
the user. Fleet is the equivalent of a user."* The framework owns
|
|
deploy (ADR-023); release is the missing half of ADR-012.
|
|
|
|
A naive fix — "add a `define_release!` macro so each component is
|
|
one declarative invocation" — solves the duplication but leaves the
|
|
mechanism wrong. Building, packaging, and pushing are not app
|
|
concerns. They are *capabilities* that an environment provides (a
|
|
docker daemon, an OCI registry, a CI runner), the same way DNS,
|
|
LoadBalancer, or PostgreSQL are.
|
|
|
|
This ADR pulls release into the Score-Topology-Capability pattern
|
|
that already governs deploy.
|
|
|
|
## Two prior attempts and what each got right
|
|
|
|
### Attempt 1 — `modules/application` (the `RustWebapp` example, `ApplicationScore`, `ApplicationFeature`)
|
|
|
|
`examples/rust/src/main.rs` shows what good DX looks like:
|
|
|
|
```rust
|
|
let application = Arc::new(RustWebapp {
|
|
name: "harmony-example-rust-webapp".to_string(),
|
|
project_root: PathBuf::from("./webapp"),
|
|
framework: Some(RustWebFramework::Leptos),
|
|
service_port: 3000,
|
|
..
|
|
});
|
|
|
|
let app = ApplicationScore {
|
|
features: vec![
|
|
Box::new(PackagingDeployment { application: application.clone() }),
|
|
Box::new(Monitoring { application: application.clone(), alert_receiver: vec![...] }),
|
|
],
|
|
application,
|
|
};
|
|
```
|
|
|
|
**What it got right:**
|
|
|
|
| Property | Why it's right |
|
|
|---|---|
|
|
| Declarative app spec at the call site | One struct, ~15 lines, describes the whole thing |
|
|
| Per-app type implements capability traits (`OCICompliant`, `HelmPackage`, `Webapp`) | Compiler rejects nonsensical combos — features state their bounds (`impl<A: OCICompliant + HelmPackage> ApplicationFeature for PackagingDeployment<A>`) |
|
|
| Features compose orthogonally | `vec![PackagingDeployment, Monitoring, …]` extensible without touching the app |
|
|
| One feature does one thing | Small, testable, replaceable |
|
|
|
|
**What it got wrong:**
|
|
|
|
| Anti-pattern | Symptom |
|
|
|---|---|
|
|
| Parallel hierarchy | `Application` / `ApplicationFeature` / `ApplicationInterpret` / `ApplicationScore` live alongside `Score` / `Interpret` — two ways to express the same thing |
|
|
| App-owned capabilities | `OCICompliant::build_push_oci_image()` is a method on the app — the app *does* the build. Should be: topology *provides* a builder, score *uses* it |
|
|
| `MultiTargetTopology` + `DeploymentTarget::LocalDev / Production` | Inline `match topology.current_target() { LocalDev => …, _ => Argo… }` in `PackagingDeployment::ensure_installed`. The author's own comment: *"It still does not feel right though."* |
|
|
| ArgoCD hardcoded as the production mechanism | Should be a `ContinuousDelivery` capability; OPNsense vs CoreDNS distinction (ADR-003) applies — Argo today, Flux or something else tomorrow |
|
|
|
|
### Attempt 2 — `harmony-fleet-release` binary + `release.sh` (the current branch)
|
|
|
|
After abandoning `modules/application`'s parallel hierarchy, fleet
|
|
went the other direction: hand-rolled per-component binaries, no
|
|
framework-level abstraction. Solved the parallel-hierarchy problem
|
|
by removing the hierarchy entirely; created a 270-line per-component
|
|
duplication problem in its place.
|
|
|
|
**Net diagnosis:** attempt 1 had the right *shape* (declarative
|
|
app + composable features + capability bounds), wrong *mechanism*
|
|
(parallel layer, app-side capabilities). Attempt 2 had the right
|
|
mechanism (use the existing Score/Topology primitives) but no
|
|
abstraction. The fix is to keep attempt 1's shape and express it
|
|
in attempt 2's mechanism.
|
|
|
|
## Mechanism (proposed)
|
|
|
|
**A release is a Score driven by Topology capabilities, exactly like
|
|
a deploy.** No parallel hierarchy. No per-component CLI binaries. No
|
|
app-side build/push methods. ADR-012's opinionated lifecycle pipeline
|
|
becomes a composed `Vec<Box<dyn Score<T>>>` — each pipeline step is
|
|
one Score, harmony executes them in order, each blocks on the
|
|
smoke-test contract (ADR-023, step 4).
|
|
|
|
### Capabilities (topology-side)
|
|
|
|
New capability traits, alongside `DnsServer`, `HelmCommand`, etc.:
|
|
|
|
```rust
|
|
pub trait ContainerBuilder {
|
|
async fn build(&self, ctx: &BuildContext, image_ref: &str) -> Result<(), Error>;
|
|
}
|
|
|
|
pub trait OciRegistry {
|
|
async fn push_image(&self, image_ref: &str) -> Result<(), Error>;
|
|
fn registry_url(&self) -> &str;
|
|
}
|
|
|
|
pub trait HelmRegistry {
|
|
async fn push_chart(&self, tgz: &Path, project: &str) -> Result<(), Error>;
|
|
}
|
|
|
|
pub trait ContinuousDelivery {
|
|
async fn sync_to(&self, chart_ref: &ChartRef, target: &DeployTarget) -> Result<(), Error>;
|
|
}
|
|
```
|
|
|
|
Each capability is an industry concept (per ADR-003 rule). Adapters:
|
|
|
|
| Capability | LocalDev provider | CI provider | Production provider |
|
|
|---|---|---|---|
|
|
| `ContainerBuilder` | local docker daemon | gitea runner docker | remote buildkit |
|
|
| `OciRegistry` | local registry, or noop (k3d image-import) | hub.nationtech.io | hub.nationtech.io |
|
|
| `HelmRegistry` | noop (local helm) | hub.nationtech.io | hub.nationtech.io |
|
|
| `ContinuousDelivery` | direct helm install | direct helm install | ArgoCD `ArgoHelmScore` |
|
|
|
|
`K8sAnywhereTopology` composes these capabilities the way it already
|
|
composes `K8sclient + HelmCommand + Ingress + …`. A CI-runner
|
|
topology variant composes the same caps with CI-flavored adapters.
|
|
|
|
### Application spec (data only)
|
|
|
|
```rust
|
|
pub struct AppSpec {
|
|
pub name: &'static str,
|
|
pub project_root: PathBuf,
|
|
pub image: ImageSource, // Dockerfile path + build args, OR a `Buildable` impl that emits one
|
|
pub chart: ChartSource, // PathBuf to a directory, OR a `ChartBuilder` closure / impl returning HelmChart
|
|
}
|
|
```
|
|
|
|
No methods that *do* work. The spec is pure data. Methods (`build`,
|
|
`push`, `package`) live on the topology capabilities. This is the
|
|
shift that fixes attempt 1: `RustWebapp` no longer implements
|
|
`OCICompliant::build_push_oci_image()`; instead, `AppSpec` describes
|
|
*what* to build, and `ContainerBuilder` (on the topology) does it.
|
|
|
|
### Scores
|
|
|
|
```rust
|
|
pub struct ReleaseScore { app: Arc<AppSpec>, version: String }
|
|
pub struct DeployScore { app: Arc<AppSpec>, version: String, target: DeployTarget }
|
|
|
|
impl<T: Topology + ContainerBuilder + OciRegistry + HelmRegistry> Score<T> for ReleaseScore { ... }
|
|
impl<T: Topology + ContinuousDelivery> Score<T> for DeployScore { ... }
|
|
```
|
|
|
|
The trait bounds *are* the spec of what a topology must provide.
|
|
Compile-time guarantee: you cannot construct `ReleaseScore` against
|
|
a topology that can't release.
|
|
|
|
### The opinionated pipeline (ADR-012 realized)
|
|
|
|
ADR-012's pipeline maps directly onto a Score sequence:
|
|
|
|
| ADR-012 step | Mechanism |
|
|
|---|---|
|
|
| 1. Empty check | Out of scope for harmony (matches ADR-012). Project's own CI step runs ahead of `harmony-<app>` invocation. |
|
|
| 2. Package & publish | `ReleaseScore` — builds image, hydrates chart, pushes both. |
|
|
| 3. Deploy to staging | `DeployScore { target: DeployTarget::Staging }` — talks to the topology's `ContinuousDelivery` (Argo by default per ADR-012). |
|
|
| 4. Sanity check staging | Smoke-test companion on the `DeployScore` (ADR-023 principle 4). |
|
|
| 5. Deploy to production | Same `DeployScore` with `DeployTarget::Production`. CI gates approval; harmony just runs the score. |
|
|
| 6. Sanity check production | Same smoke-test companion against production. |
|
|
|
|
### Call site (after the migration)
|
|
|
|
```rust
|
|
let app = Arc::new(AppSpec {
|
|
name: "harmony-fleet-operator",
|
|
project_root: ".".into(),
|
|
image: ImageSource::Dockerfile("fleet/harmony-fleet-operator/Dockerfile".into()),
|
|
chart: ChartSource::Builder(Box::new(operator::chart::build)),
|
|
});
|
|
|
|
let scores: Vec<Box<dyn Score<_>>> = vec![
|
|
Box::new(ReleaseScore { app: app.clone(), version: from_tag(ref_name)? }),
|
|
Box::new(DeployScore { app: app.clone(), version, target: DeployTarget::Staging }),
|
|
Box::new(DeployScore { app: app.clone(), version, target: DeployTarget::Production }),
|
|
];
|
|
|
|
harmony_cli::run(Inventory::autoload(), CITopology::from_env(), scores, None).await?
|
|
```
|
|
|
|
**That's the user-facing surface for any app.** It matches ADR-012's
|
|
"run the same command anywhere" promise — same binary, local laptop
|
|
or CI runner; topology adapters differ, Scores don't. No per-component
|
|
release binary. No `release.sh` per component. Adding a component is
|
|
one new `AppSpec` value.
|
|
|
|
### CI integration
|
|
|
|
Workflow yaml shrinks to:
|
|
|
|
```yaml
|
|
- name: Release
|
|
run: |
|
|
harmony-fleet --release --from-tag "$GITHUB_REF_NAME"
|
|
```
|
|
|
|
The `--from-tag` flag is implemented inside `harmony_release` (Rust,
|
|
not bash). It parses `<app>-<component>-v<semver>` against the
|
|
registered `AppSpec`s and constructs `ReleaseScore { app, version }`
|
|
for the matching one. Tag malformed → fail at CI start, not three
|
|
minutes into the docker build.
|
|
|
|
`.gitea/scripts/resolve-release-version.sh` (just shipped) is the
|
|
interim form; the script disappears once `--from-tag` lands.
|
|
|
|
### What replaces the `modules/application` layer
|
|
|
|
`Application` / `ApplicationFeature` / `ApplicationInterpret` /
|
|
`ApplicationScore` are deleted. Migration path:
|
|
|
|
| Old | New |
|
|
|---|---|
|
|
| `RustWebapp` (struct implementing capabilities) | `AppSpec { name, project_root, image: ImageSource::Buildable(Box::new(RustLeptosBuilder)), chart: … }` |
|
|
| `OCICompliant::build_push_oci_image` | `ContainerBuilder::build` on the topology (Score calls it) |
|
|
| `HelmPackage::build_push_helm_package` | `HelmRegistry::push_chart` on the topology (Score calls it) |
|
|
| `PackagingDeployment` (ApplicationFeature) | `ReleaseScore + DeployScore` (regular Scores) |
|
|
| `Monitoring` (ApplicationFeature) | `MonitoringScore` (regular Score) |
|
|
| `ApplicationScore` wrapping features | `Vec<Box<dyn Score<T>>>` passed to `harmony_cli::run` |
|
|
|
|
The `RustWebapp` Leptos Dockerfile generator survives — it becomes a
|
|
`Buildable` impl that emits a `Dockerfile` for `ImageSource::Buildable`
|
|
to use. Same code, different home.
|
|
|
|
## Alternatives considered
|
|
|
|
### Macro per component — `define_release!(Component::Operator { … })`
|
|
|
|
Solves the duplication. Still leaves build/push as app-side work,
|
|
still gives every app its own CLI surface. Doesn't address the
|
|
capability gap. **Rejected.**
|
|
|
|
### Keep `modules/application` as-is, add a release feature
|
|
|
|
Adds a third release variant to the existing parallel hierarchy.
|
|
Doubles down on the wrong mechanism. **Rejected.**
|
|
|
|
### Plugin-discovery `harmony` top-level binary (ADR-023 "tomorrow C")
|
|
|
|
Orthogonal to this clarification. Plugin discovery is *how the
|
|
binaries are invoked*; this is *what the binaries do*. Compatible:
|
|
each app ships a `harmony-<app>` plugin binary that registers its
|
|
`AppSpec`s with `harmony_release::cli`.
|
|
|
|
### Promote to a fresh ADR-025
|
|
|
|
The shape proposed here doesn't disagree with ADR-012 — it
|
|
*implements* it. A new ADR would invite reading them as competing
|
|
decisions and split the conversation in two. Keeping this as
|
|
ADR-012's first clarification (012-1) keeps the lineage explicit.
|
|
**Rejected for now;** revisit if a future decision genuinely
|
|
diverges from ADR-012's intent.
|
|
|
|
## Consequences
|
|
|
|
### Positive
|
|
|
|
- ADR-012 intent finally has a mechanism. The opinionated pipeline
|
|
becomes a composed `Vec<Box<dyn Score<T>>>` — concrete, testable,
|
|
identical local and in CI.
|
|
- One framework, one mechanism. Release joins deploy in the
|
|
Score-Topology-Capability pattern. No second concept to learn.
|
|
- Capability-driven swap-out. Move from docker→podman, Harbor→ECR,
|
|
helm→ko, Argo→Flux without touching any app's `AppSpec`. ADR-003
|
|
rule held.
|
|
- ~250 lines deleted from each app (per-component bin + release.sh
|
|
+ workflow yaml duplication).
|
|
- Adding a component is one `AppSpec` value, not a new binary.
|
|
- LocalDev vs CI vs Production is *topology selection at runtime*
|
|
(ADR-023 principle 6), not branching inside a Score.
|
|
|
|
### Negative / costs
|
|
|
|
- Migration of `modules/application` and the `RustWebapp` example to
|
|
the new shape is a non-trivial PR (preserve the Leptos Dockerfile
|
|
generation as a `Buildable` impl).
|
|
- New crate `harmony_release` (or new module inside `harmony` core).
|
|
Sizing TBD during implementation; target the same ~ADR-023 scope.
|
|
- Adapter coverage: at least three `ContainerBuilder` adapters
|
|
(local docker, CI docker, k3d-image-import) and two `OciRegistry`
|
|
/ `HelmRegistry` adapters (hub.nationtech.io, noop) need to land
|
|
for the first migration to be useful.
|
|
|
|
### Risks to watch
|
|
|
|
- **Capability surface creep.** Resist adding a fourth or fifth
|
|
capability for every minor variant (e.g. `MultiArchContainerBuilder`,
|
|
`SignedOciRegistry`). Extend existing capabilities first; create
|
|
new ones only when the swap-out test (ADR-003) holds.
|
|
- **Build context size.** `BuildContext` could absorb every flag
|
|
ever needed (`build_args`, `secrets`, `platforms`, `targets`,
|
|
`cache_from`, …). Start small; grow with documented justification.
|
|
- **`Buildable` trait explosion.** Per-language Dockerfile generators
|
|
(`RustLeptos`, `Python`, `Node`, …) belong as `Buildable` impls,
|
|
not as variants on `AppSpec`. Resist adding them to core until at
|
|
least two callers need each one.
|
|
|
|
## Implementation order (separate PRs, each shippable)
|
|
|
|
1. **`harmony_release` crate** — `AppSpec`, `ImageSource`,
|
|
`ChartSource`, `ReleaseScore`, capability trait stubs, `--from-tag`
|
|
parsing. No adapters yet.
|
|
2. **Local + CI capability adapters** — `LocalDockerBuilder`,
|
|
`CiDockerBuilder`, `HarborOciRegistry`, `HarborHelmRegistry`.
|
|
Compose into `K8sAnywhereTopology` and a new `CiTopology`.
|
|
3. **Migrate fleet operator release** to `AppSpec` + `ReleaseScore`.
|
|
Delete `harmony-fleet-release` binary, `release.sh`,
|
|
`.gitea/scripts/resolve-release-version.sh`. Workflow yaml goes to
|
|
the ~15-line form. Validates the framework on a real app.
|
|
4. **Add fleet agent + callout** as `AppSpec` entries when their
|
|
pipelines land. One-line additions, no scaffolding.
|
|
5. **Migrate `modules/application` `RustWebapp` example** to the new
|
|
shape. Preserves the Leptos Dockerfile generator. Deletes the
|
|
parallel `Application` / `ApplicationFeature` hierarchy.
|
|
6. **(Stretch) `ContinuousDelivery` capability** as a real abstraction
|
|
above the current `ArgoHelmScore`. Enables Flux / direct-helm as
|
|
peer providers. Out of v1 scope; ADR remains compatible.
|
|
|
|
## Open questions
|
|
|
|
- **Chart hydration genericism.** Should `ChartSource::FromScore` exist?
|
|
The deploy Score's resources are a near-superset of what the chart
|
|
contains. If we get this right, `ChartSource::Builder` becomes the
|
|
exception, not the rule. Worth scoping in #5 or later.
|
|
- **`DeployTarget` shape.** Today fleet uses `(namespace, image_tag)`.
|
|
A typed enum (`InCluster { namespace, … }`, `MultiCluster { … }`,
|
|
`EdgeDevice { device_id, … }`) may capture more. Defer to the
|
|
agent-CD work (manual upgrades per device) where this gets exercised.
|
|
- **Image identity.** The image's full ref (`<registry>/<project>/<name>:<version>`)
|
|
is currently built by string concatenation in three places. Should
|
|
there be an `ImageRef` type with parse/display impls? Probably; add
|
|
in #1 if cost is small.
|
|
- **Buildable for non-Rust languages.** Out of scope until a non-Rust
|
|
app needs it, then add by demand.
|