review/2026-09-08 #349

Open
johnride wants to merge 4 commits from review/2026-09-08 into master
116 changed files with 2962 additions and 5716 deletions

18
Cargo.lock generated
View File

@@ -2532,18 +2532,6 @@ dependencies = [
"syn 3.0.3", "syn 3.0.3",
] ]
[[package]]
name = "docker-compose-types"
version = "0.24.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bdfd601efc90644958510466e8904ca90da65a98fdfe4d21f940644a21c026b4"
dependencies = [
"derive_builder 0.20.2",
"indexmap 2.14.0",
"serde",
"serde_yaml",
]
[[package]] [[package]]
name = "dockerfile_builder" name = "dockerfile_builder"
version = "0.1.6" version = "0.1.6"
@@ -2838,10 +2826,13 @@ name = "example-compose-java-react"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"async-trait",
"harmony",
"harmony_app", "harmony_app",
"harmony_cli", "harmony_cli",
"harmony_macros", "harmony_macros",
"harmony_types", "harmony_types",
"serde_json",
"tokio", "tokio",
] ]
@@ -4295,15 +4286,12 @@ version = "0.1.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"async-trait", "async-trait",
"docker-compose-types",
"fqdn",
"harmony", "harmony",
"harmony-k8s", "harmony-k8s",
"harmony_config", "harmony_config",
"harmony_types", "harmony_types",
"k8s-openapi", "k8s-openapi",
"log", "log",
"reqwest 0.12.28",
"schemars 0.8.22", "schemars 0.8.22",
"serde", "serde",
"serde_json", "serde_json",

View File

@@ -1,5 +1,12 @@
[workspace] [workspace]
resolver = "2" resolver = "2"
exclude = [
"examples/dx_accepts_kind",
"examples/dx_bind_origin",
"examples/dx_guide_ship",
"examples/dx_slot_secret",
"examples/notes",
]
members = [ members = [
"examples/*", "examples/*",
"harmony", "harmony",

View File

@@ -71,11 +71,9 @@ APIs are not accepted designs.
Verified against current code: Verified against current code:
- The short `ComposeDeploy` example does not yet cover the real application, - Apps implement `HarmonyApp` and compose Scores. `ComposeDeploy` /
which still assembles lower-level Scores. `Application` are gone. The Java+React example does not yet create a
- `ComposeDeploy` supports one public endpoint. Capability environment wiring Service or Ingress (`K8sDeploymentScore` is image+env only).
applies to every Compose service, PostgreSQL exports one fixed variable, and
the current Zitadel capability covers only a simple PKCE application.
- The real deploy still duplicates namespace-derived names and some provider - The real deploy still duplicates namespace-derived names and some provider
conventions. Those are concrete DRY problems. conventions. Those are concrete DRY problems.
- The application layer does not yet prove its full readiness claim; Helm - The application layer does not yet prove its full readiness claim; Helm

View File

@@ -45,6 +45,8 @@ Key ADRs that lock the foundational decisions:
- **ADR-023** — Deploy architecture: Scores everywhere (including - **ADR-023** — Deploy architecture: Scores everywhere (including
tests), per-app `*-deploy` crates, deploy blocks on smoke-test, tests), per-app `*-deploy` crates, deploy blocks on smoke-test,
topologies are compile-time. topologies are compile-time.
- **ADR-029** — One component, one file, one type. Runtimes are
capabilities; contexts bind, they do not fork types.
The full ADR set lives under `docs/adr/`. The full ADR set lives under `docs/adr/`.

View File

@@ -34,7 +34,7 @@
- [Developer Guide](./guides/developer-guide.md) - [Developer Guide](./guides/developer-guide.md)
- [Application CLI — Use Cases & Commands](./guides/application-cli.md) - [Application CLI — Use Cases & Commands](./guides/application-cli.md)
- [Harmony Auth CLI](./guides/harmony-auth-cli.md) - [Harmony Auth CLI](./guides/harmony-auth-cli.md)
- [Application Capabilities — .with(...)](./guides/application-capabilities.md) - [Application Scores](./guides/application-capabilities.md)
- [Writing a Score](./guides/writing-a-score.md) - [Writing a Score](./guides/writing-a-score.md)
- [Writing a Topology](./guides/writing-a-topology.md) - [Writing a Topology](./guides/writing-a-topology.md)
- [Adding Capabilities](./guides/adding-capabilities.md) - [Adding Capabilities](./guides/adding-capabilities.md)
@@ -85,3 +85,4 @@
- [026 · Application Lifecycle CLI](./adr/026-application-lifecycle-cli.md) - [026 · Application Lifecycle CLI](./adr/026-application-lifecycle-cli.md)
- [027 · Multi-Tenant Cloud Identity](./adr/027-multi-tenant-identity.md) - [027 · Multi-Tenant Cloud Identity](./adr/027-multi-tenant-identity.md)
- [028 · Typed Score References](./adr/028-typed-score-references.md) - [028 · Typed Score References](./adr/028-typed-score-references.md)
- [029 · Application Components — One File, One Type](./adr/029-application-components.md)

View File

@@ -8,7 +8,9 @@ Last Updated Date: 2026-06-10
## Status ## Status
Proposed (draft) Proposed (draft). The `Application` / `ComposeDeploy` authoring layer is
withdrawn; apps implement `HarmonyApp` and return Scores (ADR-029). The
verb/context contract below still holds.
Extends ADR-023 (deploy architecture) — whose CLI principle (8) only Extends ADR-023 (deploy architecture) — whose CLI principle (8) only
covers *how binaries are discovered* — with the CLI's **experience covers *how binaries are discovered* — with the CLI's **experience

View File

@@ -0,0 +1,114 @@
# Architecture Decision Record: Application Components — One File, One Type
Initial Author: Jean-Gabriel Gill-Couture
Initial Date: 2026-09-03
Last Updated Date: 2026-09-03
## Status
Proposed.
Supersedes the app-facing use of a single `Application` graph
(`harmony_app::application`) as how an application is structured.
Does not replace ADR-023 (framework Scores) or ADR-028 (typed Refs).
## Context
An application is a map of components someone can point at. The crate
should *be* that map. Earlier shapes hid it: one god-object `Application`,
a type per runtime (`FrontendCommand` / `FrontendContainer`), both image
and command on one blob checked later, a typestate builder that encodes
bind order (a DAG — and real systems cycle, ADR-028).
## Decision
**One component, one file, one type.** Opening `frontend.rs` is opening
the frontend.
Runtimes are capabilities on that type, not sibling types:
- `Container` — image on a cluster
- `Command` — a process (the command is a string in the impl)
- `Remote` — this context does not run it; it only names where it lives
A database that is always on the cluster implements `Container` only. A
frontend that is a container in one context and a process in another
implements both. The struct is still `Frontend`.
A **context** binds each component to one runtime that component
implements. That bind is an ordinary struct the author fills in
(`frontend: app.frontend.as_command()`, …) — missing a field or calling
`as_command()` on something with no `Command` impl does not compile. Not
a macro: macros are cryptic if Rust is not your first language. Refs whose content depends on how it runs (public origin vs
`localhost`) live on that runtime impl. Contexts are user-declared.
Harmony may ship default bindings as examples, not as a closed enum.
Components exchange typed Refs (ADR-028). A Ref is desired state, valid
before either side has run. Cycles are two Refs, not two builder stages.
A new **framework** Score exists only when Harmony should orchestrate a
reusable capability. App glue is ordinary code in the component file.
Org-specific Scores live in that org's crate, not in `harmony`.
Planned framework Score: OCI/image — Dockerfile in config, build/push
from the context, export `ContainerRef` for `K8sDeploymentScore`. Until
then, build/push may be procedural and still yield a `ContainerRef`.
## Principles
- **Feldman:** illegal states don't compile (`Command` on Postgres,
Mailhog in Production, `CalloutRef` where `IssuerRef` is required).
Runtime is not a boolean on a god object.
- **Crichton:** file = mental object; you can reason about `frontend.rs`
without the mesh. Notation matches: `impl Command for Frontend` *is*
"we run it with a command".
- **Parse, don't validate:** no `.image` + `.command` on one blob
checked later. Missing `impl Command` is the parse.
- **SOLID:** SRP = one file; ISP = Postgres doesn't see `Command`;
DIP = others depend on refs; OCP = a new context is new impls on
**context-dependent** components only; LSP = `Frontend` stays
`Frontend`, refs stay `UrlRef`.
**Ugly we accept:** N named `impl Production` / `LocalDev` / `Staging`
on dual-runtime files. Better than a mesh-wide enum or a typestate DAG.
**Ugly we refuse:** a second type per runtime; context as `if local`
inside scores; builder order as types.
**Harmony's job:** `Container`, `Command`, `Remote`, `Unit`, scores, refs.
**App's job:** one file per component, impl the runtimes it actually
has, impl the contexts that bind them.
A process launcher (`devbox run`, `cargo watch`, …) is a string inside
`impl Command for Frontend`, never a Harmony type.
## Consequences
- The crate layout *is* the documentation — five components or tens.
- Always-cluster components keep a single `Container` impl.
- The `Application` builder was deleted. Apps implement `HarmonyApp` and
compose Scores.
- `--context` (ADR-026) selects bindings. It does not rename types.
## Alternatives considered
**Single `Application` declaration.** One lowering to Scores. The mental
model sits behind a god object.
**Typestate builder.** Readable as a script; types encode order. Breaks
cycles; Harmony would have to know the app's slots.
**Dual types per runtime.** The author no longer has one frontend.
**Closed context enum in Harmony.** Defaults become the product.
Applications cannot declare their own tying-together.
## Additional Notes
Related: ADR-023, ADR-026, ADR-028.
First implementation: `harmony_app::dx` (`Command` / `Container` /
`Remote`, `as_command` / `as_container`, `Slot` / `Ref`), `HostProcessScore`.

View File

@@ -60,6 +60,7 @@ Every ADR follows this structure:
| [026](./026-application-lifecycle-cli.md) | Application Lifecycle CLI | Accepted | | [026](./026-application-lifecycle-cli.md) | Application Lifecycle CLI | Accepted |
| [027](./027-multi-tenant-identity.md) | Multi-Tenant Cloud Identity | Accepted | | [027](./027-multi-tenant-identity.md) | Multi-Tenant Cloud Identity | Accepted |
| [028](./028-typed-score-references.md) | Typed Score References | Proposed | | [028](./028-typed-score-references.md) | Typed Score References | Proposed |
| [029](./029-application-components.md) | Application Components — One File, One Type | Proposed |
## Contributing ## Contributing

View File

@@ -1,14 +1,12 @@
# Capabilities Catalog (Topology) # Capabilities Catalog (Topology)
> **Note:** this page lists **Topology** capabilities — what a *cluster* can > **Note:** this page lists **Topology** capabilities — what a *cluster* can
> do, exposed as trait bounds a `Score` requires. For **application** > do, exposed as trait bounds a `Score` requires. Apps compose Scores; see
> capabilities — add-ons you attach to an app with `.with(...)` (databases, > [Application Scores](../guides/application-capabilities.md).
> monitoring, …) — see
> [Application Capabilities](../guides/application-capabilities.md).
A `Capability` is a specific feature or API that a `Topology` offers. `Interpret` logic uses these capabilities to execute a `Score`. A `Capability` is a specific feature or API that a `Topology` offers. `Interpret` logic uses these capabilities to execute a `Score`.
This list is primarily for developers **writing new Topologies or Scores**. As a user, you just need to know that the `Topology` you pick (like `K8sAnywhereTopology`) provides the capabilities your `Scores` (like `ApplicationScore`) need. This list is primarily for developers **writing new Topologies or Scores**. As a user, you just need to know that the `Topology` you pick (like `K8sAnywhereTopology`) provides the capabilities your `Scores` need.
<!--toc:start--> <!--toc:start-->

View File

@@ -1,94 +1,20 @@
# Application Capabilities — `.with(...)` # Application Scores
> **Status: in progress (feature branch).** The `harmony_app` application Apps implement [`HarmonyApp`](../../harmony_app/src/app.rs) and return Scores.
> layer and capabilities described here are landing incrementally. The There is no `.with(...)` capability menu and no `ComposeDeploy` /
> *decisions and rationale* live in `Application` god-object.
> [ADR-026](../adr/026-application-lifecycle-cli.md); this is the "how".
> Companion to [Application CLI](./application-cli.md).
A **capability** is an add-on you attach to an app deployment — a database, Compose the Scores that already exist: `K8sPostgreSQLScore`,
monitoring, auth — by declaring it: `FleetDeploymentScore`, `HostProcessScore`, `K8sDeploymentScore`, …
```rust ```rust
ComposeDeploy::from_dir("timesheet", "./app")? async fn scores(&self, ctx: &AppContext, images: &ImageRefs) -> Result<Vec<Box<dyn Score<T>>>, AppError> {
.expose("frontend", "timesheet.example") Ok(vec![
.with(Postgres::managed()) // a managed database Box::new(K8sPostgreSQLScore::new(ctx.namespace()).cluster_name("app-db")),
.with(Monitoring::new().alert(discord)); // a downtime alert Box::new(K8sDeploymentScore { /* … */ }),
``` ])
The app author *declares intent*; the capability deploys what it needs and
wires itself to the app. Everything else — `ship`/`deploy`/`status`/`logs`,
contexts, profiles — comes from the [application CLI](./application-cli.md).
> **Two different "capabilities" — don't confuse them.**
> - **Topology capabilities** ([catalog](../catalogs/capabilities.md)) are what
> a *cluster* can do — `K8sClient`, `DnsServer`, `HelmCommand` — exposed as
> trait bounds a `Score` requires. Infrastructure-facing.
> - **Application capabilities** (this page) are add-ons a *developer* attaches
> to their app with `.with(...)`. They live in `harmony_app::capabilities`.
## The menu
| Capability | Declares | Deploys | Wires into the app |
|---|---|---|---|
| `Postgres::managed()` | a managed PostgreSQL | a CNPG cluster `<app>-db` | `DATABASE_URL``secretKeyRef(<app>-db-app, uri)` |
| `Monitoring::new().alert(r)` | a downtime alert | a Prometheus alert rule + routes it to `r` | nothing (selector-scoped to the app's namespace) |
(`ZitadelAuth` for OIDC login is next.)
## How wiring works — by reference, never by value
A capability never passes a *value* through Harmony. Postgres' generated
password lives only in the CNPG-created `<app>-db-app` Secret; the capability
injects an env var that **references** it (`secretKeyRef`), resolved in-cluster
at runtime. This is what keeps the rendered chart publishable (it contains a
reference, not a secret) and is why the app layer needs no typed Score outputs
today (ADR-026). The trade: wiring must be expressible as a stable k8s
reference (service DNS, Secret/ConfigMap key) — true for k8s-native add-ons.
## Writing your own capability
A capability implements one small trait
(`harmony_app::capabilities::Capability`):
```rust
pub trait Capability {
/// What this capability deploys (a database, an operator, …).
fn scores(&self, app: &AppRef) -> Vec<Box<dyn Score<K8sAnywhereTopology>>> { vec![] }
/// Env injected into the app's containers, wired by reference.
fn env(&self, app: &AppRef) -> Vec<EnvVar> { vec![] }
} }
``` ```
`AppRef { name, namespace, profile }` is how the capability learns who it's See [Application CLI](./application-cli.md) for `ship` / `deploy` / `status` /
augmenting (and so derives names like `<app>-db`). Implement either method or `logs`. Authoring DX (one component, one type) is ADR-029.
both, then `app.with(MyThing)`.
### Recipe (e.g. a `Redis` capability)
1. **Find the backing Score.** Capabilities *compose* existing Scores — they
do not hand-roll manifests (ADR-023). Look in `harmony::modules::*` for one
(Postgres composes `harmony::modules::postgresql::K8sPostgreSQLScore`). If
none exists, write the Score first ([Writing a Score](./writing-a-score.md)).
2. **Return it from `scores()`**, naming resources off `app` (e.g. cluster
`format!("{}-redis", app.name)`).
3. **Wire it by reference in `env()`.** Determine what stable reference the
Score exposes — a Service DNS name, or a key in a Secret/ConfigMap it
creates — and inject an `EnvVar` pointing at it. (Postgres references CNPG's
`<cluster>-app` secret's `uri` key.) The convention between a Score's output
and a capability's `env()` is currently by agreement, not type-checked —
read the Score to confirm the names it produces.
4. **Receivers and other inputs** (e.g. an alert `DiscordWebhook`) come from
`harmony::modules::*` too — `Monitoring` takes a
`harmony::modules::monitoring::alert_channel::discord_alert_channel::DiscordWebhook`.
`Postgres` and `Monitoring` in `harmony_app/src/capabilities.rs` are the two
worked examples to copy.
## Deprecated: the old `ApplicationFeature` model
The previous approach — `ApplicationScore { features: vec![Monitoring, …] }`
over `ApplicationFeature` — is **deprecated** in favor of this one. It coupled
app delivery to a fixed feature menu and the (cancelled) ArgoCD path, and
wasn't composable with plain Scores. Migrate `ApplicationScore`
`ComposeDeploy` (or your own `HarmonyApp`) + `.with(...)` capabilities.

View File

@@ -1,13 +1,16 @@
# Harmony Application CLI — Use Cases & Commands # Harmony Application CLI — Use Cases & Commands
> **Status: partially implemented.** The app lifecycle provides build, > **Status: partially implemented.** A `HarmonyApp` composes Scores; the CLI
> publish, ship, deploy, status, and logs. Other verbs below remain the design > parses argv, resolves `--context`, and calls `ship` / `deploy` / `status` /
> target. The *decisions and rationale* > `logs`. Other verbs below remain the design target. See
> live in [ADR-026](../adr/026-application-lifecycle-cli.md) — read that > [ADR-026](../adr/026-application-lifecycle-cli.md).
> for the "why"; this doc is the "what" and "how".
## Mental model ## Mental model
**One component, one file, one type**
([ADR-029](../adr/029-application-components.md)). Opening `frontend.rs`
is opening the frontend. Contexts bind a runtime; they do not split the type.
Four ideas carry the whole CLI: Four ideas carry the whole CLI:
- **`harmony <scope> <verb>`.** A small fixed set of scope nouns — - **`harmony <scope> <verb>`.** A small fixed set of scope nouns —

View File

@@ -52,9 +52,10 @@ hostname, repository, OpenBao URL, Zitadel URL, and OpenBao role. OpenBao holds:
- `RegistryCredentials` in the context namespace, using a push-scoped registry - `RegistryCredentials` in the context namespace, using a push-scoped registry
robot account; robot account;
- Kubernetes cluster access for the tenant namespace; - Kubernetes cluster access for the tenant namespace;
- application secrets; - application secrets and pull-only registry credentials under
- pull-only registry credentials under `<device-prefix>/<deployment>/<key>` (same folder; the agent reads
`<device-prefix>/registry/device-pull/<reference>`. `image_pull_secret` as JSON and `secret_env` as UTF-8 — it never injects
the pull robot into a container).
Each device pull secret is JSON scoped to one registry authority. The authority Each device pull secret is JSON scoped to one registry authority. The authority
includes the port when the registry uses one: includes the port when the registry uses one:
@@ -86,12 +87,13 @@ CI and devices never share registry credentials.
| CI publisher | Push to the application's repositories | Tenant CI machine identity | | CI publisher | Push to the application's repositories | Tenant CI machine identity |
| Device puller | Pull only from the application's repositories | Device groups authorized for the Fleet Deployment | | Device puller | Pull only from the application's repositories | Device groups authorized for the Fleet Deployment |
The Rust manifest places only an `image_pull_secret` reference in each private The Rust manifest places only an `image_pull_secret` **key name** in each
service. The operator adds exact referenced pull-secret paths to that private service (no slashes). That key lives in the deployment subtree the
Deployment's OpenBao policy. The agent reads the credential only when Podman operator already grants (`<device-prefix>/<deployment>/*`). The agent reads
needs to pull a missing image and sends it through Podman's registry-auth the credential only when Podman needs to pull a missing image and sends it
header. It does not run `podman login`, write an auth file, or place credentials through Podman's registry-auth header. It does not run `podman login`, write
in desired state, container environment, labels, or logs. an auth file, or place credentials in desired state, container environment,
labels, or logs.
The registry should provide separate repository-scoped robot accounts for push The registry should provide separate repository-scoped robot accounts for push
and pull. For the first hosted deployments, those repositories can live under and pull. For the first hosted deployments, those repositories can live under

View File

@@ -7,6 +7,7 @@ This directory contains runnable examples demonstrating Harmony's capabilities.
| Example | Description | Local K3D | Existing Cluster | Hardware Needed | | Example | Description | Local K3D | Existing Cluster | Hardware Needed |
|---------|-------------|:---------:|:----------------:|:---------------:| |---------|-------------|:---------:|:----------------:|:---------------:|
| `postgresql` | Deploy a PostgreSQL cluster | ✅ | ✅ | — | | `postgresql` | Deploy a PostgreSQL cluster | ✅ | ✅ | — |
| `notes` | ADR-029 frontend+backend+Postgres (`localdev` / `cluster`) | ✅ | — | — |
| `ntfy` | Deploy ntfy notification server | ✅ | ✅ | — | | `ntfy` | Deploy ntfy notification server | ✅ | ✅ | — |
| `tenant` | Create a multi-tenant namespace | ✅ | ✅ | — | | `tenant` | Create a multi-tenant namespace | ✅ | ✅ | — |
| `cert_manager` | Provision TLS certificates | ✅ | ✅ | — | | `cert_manager` | Provision TLS certificates | ✅ | ✅ | — |
@@ -126,6 +127,23 @@ export HARMONY_AUTOINSTALL=false
cargo run -p example-monitoring cargo run -p example-monitoring
``` ```
## App-structure DX experiments (ADR-029)
Four compile-only crates. Same app (frontend, backend, postgres, identity, bucket);
different types. Not shippable deploys.
| Crate | Package | Idea |
|-------|---------|------|
| `dx_bind_origin` | `example-dx-bind-origin` | `bind::<T, command>()`, origin as a Ref |
| `dx_slot_secret` | `example-dx-slot-secret` | `Slot` / `Secret<Jdbc>`, exhaustive `bind!` |
| `dx_accepts_kind` | `example-dx-accepts-kind` | `Accepts` matrix, `Inhabit<Dev>` / `Live` |
| `dx_guide_ship` | `example-dx-guide-ship` | Tutorial inventory, `Runtime` enum |
```bash
cargo check -p example-dx-bind-origin -p example-dx-slot-secret \
-p example-dx-accepts-kind -p example-dx-guide-ship
```
## Notes on Private Infrastructure ## Notes on Private Infrastructure
Some examples use NationTech-hosted infrastructure by default (DNS domains like `*.nationtech.io`, `*.harmony.mcd`). These are not suitable for public use without modification. See the [Getting Started Guide](../docs/guides/getting-started.md) for the recommended public examples. Some examples use NationTech-hosted infrastructure by default (DNS domains like `*.nationtech.io`, `*.harmony.mcd`). These are not suitable for public use without modification. See the [Getting Started Guide](../docs/guides/getting-started.md) for the recommended public examples.

View File

@@ -4,16 +4,19 @@ edition = "2024"
version.workspace = true version.workspace = true
readme.workspace = true readme.workspace = true
license.workspace = true license.workspace = true
description = "Deploy a Java+React app to Kubernetes by importing its docker-compose. The whole example is one main.rs; the machinery lives in harmony_app. See ADR-026." description = "Thin HarmonyApp composing K8sPostgreSQLScore + K8sDeploymentScore for a Java+React app."
[[bin]] [[bin]]
name = "compose-deploy" name = "compose-deploy"
path = "src/main.rs" path = "src/main.rs"
[dependencies] [dependencies]
harmony = { path = "../../harmony" }
harmony_app = { path = "../../harmony_app" } harmony_app = { path = "../../harmony_app" }
harmony_cli = { path = "../../harmony_cli" } harmony_cli = { path = "../../harmony_cli" }
harmony_macros = { path = "../../harmony_macros" } harmony_macros = { path = "../../harmony_macros" }
harmony_types = { path = "../../harmony_types" } harmony_types = { path = "../../harmony_types" }
anyhow = { workspace = true } anyhow = { workspace = true }
async-trait.workspace = true
serde_json.workspace = true
tokio = { workspace = true, features = ["full"] } tokio = { workspace = true, features = ["full"] }

View File

@@ -1,79 +1,14 @@
# Deploy a Java + React app from its `docker-compose.yml` # Java + React timesheet as a `HarmonyApp`
Deploys an existing containerized app to Kubernetes with Harmony by A thin [`HarmonyApp`](../../harmony_app) that composes existing Scores:
**importing its `docker-compose.yml`** — no hand-written manifests, no Argo. `K8sPostgreSQLScore` and `K8sDeploymentScore` for backend and frontend.
The compose file stays the source of truth for the app's *base* shape; Images come from `app/backend` and `app/frontend`. The `docker-compose.yml`
deploy-only concerns are typed Rust. is the original local shape; Harmony does not import it.
```
app/ the "customer" project (their existing repo)
docker-compose.yml ← source of truth: images, ports, env, volumes
backend/ (Java + SQLite, Dockerfile)
frontend/ (React + nginx, Dockerfile)
Harmony.toml identity only — the app name (ADR-026 §3)
src/
compose.rs import docker-compose → typed model (loud on the unsupported)
chart.rs model + profile knobs → a hydrated helm chart (typed k8s)
publish.rs build images + (push to a registry | import to k3d)
score.rs ComposeAppScore: helm upgrade --install + Ingress
deploy.rs ComposeDeploy — the declarative builder (impls HarmonyApp)
main.rs the app and its compiled deploy contexts
```
## The app, declared (`main.rs`)
```rust
let app = ComposeDeploy::from_dir("timesheet", "./app")?
.expose("frontend", "timesheet.example.harmony.mcd")
.with(Postgres::managed()); // a managed CNPG database, wired in
let contexts = ContextCatalog::new([Context {
name: context_name!("local"),
namespace: "timesheet".parse()?,
spec: ContextSpec::Local(LocalContext::ManagedK3d),
}])?;
harmony_cli::app::app_main(app, contexts).await
```
That declaration gets you `ship` / `deploy` / `status` / `logs` over any
`--context`, all from `harmony_app`. See
[Application CLI](../../docs/guides/application-cli.md) and
[Application Capabilities](../../docs/guides/application-capabilities.md).
## Design
- **compose = base, profile = deploy knobs.** The importer reads only images,
ports, env, volumes. Replicas, storage class, TLS, rolling strategy come
from the `Profile` the context carries (ADR-026 §6/§7), not from compose or
`Harmony.toml`. A behavioral knob in either is a defect.
- **Capabilities compose upward.** `.with(Postgres::managed())` /
`.with(Monitoring::new()...)` / `.with(ZitadelAuth::oidc(...))` each deploy
their own Scores and wire into the app **by reference** (a DB URL via
`secretKeyRef`, a client_id via `configMapKeyRef`) — no `ApplicationScore`,
no ArgoCD.
- **Same definition, local → prod.** Only the context changes; the *same*
`ComposeAppScore` converges on local k3d or a tenant cluster.
- **Loud, never lossy.** Bind mounts and unparseable ports are hard errors;
`depends_on`/`command`/`restart` are warned, never silently dropped.
## Run it (local k3d)
```sh ```sh
cd examples/compose_java_react cargo run --bin compose-deploy -- ship --context local
cargo run --bin compose-deploy -- ship --context local # build → k3d import → deploy (+ Postgres)
cargo run --bin compose-deploy -- status --context local
cargo run --bin compose-deploy -- logs --context local --tail 50
``` ```
The binary accepts only contexts compiled into `main.rs`. A context is always `--context` is required. `K8sDeploymentScore` does not create a Service or
required, so omitting `--context` and `HARMONY_CONTEXT` is an error. Ingress, so in-cluster DNS (`BACKEND_URL=http://backend:8080`) and public
expose are holes until those Scores exist.
**Production** uses the same verbs against a prod context, with cluster
credentials brokered from OpenBao.
## Storage note (SQLite)
`Profile::Local` deploys single-replica with `Recreate` on a `ReadWriteOnce`
volume — safe for the demo's single-writer SQLite (one pod at a time). Prod
(`Profile::Prod`) replicates with `RollingUpdate` on `ReadWriteMany`; for a
real multi-writer store use `.with(Postgres::managed())` (the demo already
provisions it) and point the app at `DATABASE_URL`.

View File

@@ -1,8 +1,4 @@
# The customer's existing docker-compose — the single source of truth for # Original local compose. HarmonyApp does not import this file.
# the app's *base* shape (images, ports, env, volumes). Harmony imports
# this and derives the k8s deployment; deploy-only knobs (ingress host,
# storage class, replicas, rolling strategy) live in the deploy crate's
# Score, never here. See ADR-026.
name: timesheet name: timesheet
services: services:
backend: backend:

View File

@@ -1,26 +1,81 @@
//! `compose-deploy` — deploy this Java+React app to any context. //! Timesheet: a thin [`HarmonyApp`] that composes existing Scores.
//!
//! The whole app, declared:
//!
//! compose-deploy ship --context local # build + import to k3d + deploy
//! compose-deploy status --context local
//! compose-deploy logs --context local
//!
//! `ship`/`deploy`/`status`/`logs` and the context model all come from
//! `harmony_app` — this file only declares the app and its available target.
use harmony_app::{ComposeDeploy, Context, ContextCatalog, ContextSpec, LocalContext, Postgres}; use async_trait::async_trait;
use harmony::modules::k8s::deployment::K8sDeploymentScore;
use harmony::modules::postgresql::K8sPostgreSQLScore;
use harmony::score::Score;
use harmony::topology::K8sAnywhereTopology;
use harmony_app::{
AppContext, AppError, AppIdentity, Context, ContextCatalog, ContextSpec, HarmonyApp, ImageRefs,
ImageSpec, LocalContext,
};
use harmony_macros::context_name; use harmony_macros::context_name;
use serde_json::json;
struct Timesheet;
#[async_trait]
impl HarmonyApp<K8sAnywhereTopology> for Timesheet {
fn identity(&self, ctx: &AppContext) -> AppIdentity {
AppIdentity {
name: "timesheet".into(),
namespace: ctx.namespace().into(),
}
}
fn images(&self, ctx: &AppContext) -> Result<Vec<ImageSpec>, AppError> {
let root = format!("{}/app", env!("CARGO_MANIFEST_DIR"));
Ok(["backend", "frontend"]
.map(|name| ImageSpec {
name: name.into(),
image: ctx.image(name),
context: format!("{root}/{name}").into(),
dockerfile: format!("{root}/{name}/Dockerfile").into(),
platform: None,
build_args: Vec::new(),
})
.into())
}
async fn scores(
&self,
ctx: &AppContext,
images: &ImageRefs,
) -> Result<Vec<Box<dyn Score<K8sAnywhereTopology>>>, AppError> {
let namespace = ctx.namespace();
let postgres = K8sPostgreSQLScore::new(namespace).cluster_name("timesheet-db");
let backend = K8sDeploymentScore {
name: "backend".into(),
image: images.require("backend")?.into(),
namespace: Some(namespace.into()),
env_vars: json!([{
"name": "DATABASE_URL",
"valueFrom": {
"secretKeyRef": { "name": "timesheet-db-app", "key": "uri" }
}
}]),
};
let frontend = K8sDeploymentScore {
name: "frontend".into(),
image: images.require("frontend")?.into(),
namespace: Some(namespace.into()),
env_vars: json!([{
"name": "BACKEND_URL",
"value": "http://backend:8080"
}]),
};
Ok(vec![
Box::new(postgres),
Box::new(backend),
Box::new(frontend),
])
}
}
#[tokio::main] #[tokio::main]
async fn main() -> anyhow::Result<()> { async fn main() -> anyhow::Result<()> {
let app = ComposeDeploy::from_dir("timesheet", concat!(env!("CARGO_MANIFEST_DIR"), "/app"))
.map_err(|e| anyhow::anyhow!(e))?
.expose("frontend", "timesheet.example.harmony.mcd")
.with(Postgres::managed()); // deploys a CNPG cluster + wires DATABASE_URL
harmony_cli::app::app_main( harmony_cli::app::app_main(
app, Timesheet,
ContextCatalog::new([Context { ContextCatalog::new([Context {
name: context_name!("local"), name: context_name!("local"),
namespace: "timesheet".parse()?, namespace: "timesheet".parse()?,

View File

@@ -0,0 +1,12 @@
[package]
name = "example-dx-accepts-kind"
edition = "2024"
version.workspace = true
readme.workspace = true
license.workspace = true
description = "DX experiment (ADR-029): Accepts matrix, kinded ports, Dev/Live atmosphere."
publish = false
[[bin]]
name = "dx-accepts-kind"
path = "src/main.rs"

View File

@@ -0,0 +1,9 @@
# DX: Accepts × atmosphere
Type-theory experiment. Empty matrix cells do not compile.
`bind!(Postgres => Exec)` and `bind_in!(Live, Mailhog => Image)` are left
commented. Backend ports are `Ref<Kind>`, not sibling names.
```bash
cargo check -p example-dx-accepts-kind
```

View File

@@ -0,0 +1,27 @@
use crate::harmony::{
Accepts, Bin, Component, Dev, Edge, Exec, Idp, Image, Inhabit, Live, Obj, Ref, Rel, Smtp,
};
pub struct Backend;
impl Component for Backend {
type Kind = Bin;
}
impl Accepts<Exec> for Backend {}
impl Accepts<Image> for Backend {}
impl Inhabit<Dev> for Backend {}
impl Inhabit<Live> for Backend {}
impl Backend {
pub fn wire(
edge: Ref<Edge>,
rel: Ref<Rel>,
idp: Ref<Idp>,
obj: Ref<Obj>,
smtp: Ref<Smtp>,
) -> Self {
let _ = (edge, rel, idp, obj, smtp);
Self
}
}

View File

@@ -0,0 +1,12 @@
use crate::harmony::{Accepts, Component, Dev, Image, Inhabit, Live, Managed, Obj};
pub struct Bucket;
impl Component for Bucket {
type Kind = Obj;
}
impl Accepts<Image> for Bucket {}
impl Accepts<Managed> for Bucket {}
impl Inhabit<Dev> for Bucket {}
impl Inhabit<Live> for Bucket {}

View File

@@ -0,0 +1,22 @@
use crate::harmony::{
Accepts, Bin, Cdn, Component, Dev, Edge, Exec, Idp, Image, Inhabit, Live, Ref,
};
pub struct Frontend;
impl Component for Frontend {
type Kind = Edge;
}
impl Accepts<Exec> for Frontend {}
impl Accepts<Image> for Frontend {}
impl Accepts<Cdn> for Frontend {}
impl Inhabit<Dev> for Frontend {}
impl Inhabit<Live> for Frontend {}
impl Frontend {
pub fn wire(api: Ref<Bin>, idp: Ref<Idp>) -> Self {
let _ = (api, idp);
Self
}
}

View File

@@ -0,0 +1,82 @@
//! Framework stand-in. Not `harmony_app`.
//!
//! Capability: `Accepts<Exec | Image | Managed | Cdn>`.
//! Atmosphere: `Inhabit<Dev>` / `Inhabit<Live>` — no blanket.
//! Kind on `Component`; ports are `Ref<K>`.
#![allow(dead_code)]
use std::marker::PhantomData;
pub trait Kind {}
pub struct Edge;
impl Kind for Edge {}
pub struct Bin;
impl Kind for Bin {}
pub struct Rel;
impl Kind for Rel {}
pub struct Idp;
impl Kind for Idp {}
pub struct Obj;
impl Kind for Obj {}
pub struct Smtp;
impl Kind for Smtp {}
pub struct Ref<K: Kind>(PhantomData<K>);
impl<K: Kind> Copy for Ref<K> {}
impl<K: Kind> Clone for Ref<K> {
fn clone(&self) -> Self {
*self
}
}
impl<K: Kind> Ref<K> {
pub const fn new() -> Self {
Self(PhantomData)
}
}
pub trait Runtime {}
pub struct Exec;
impl Runtime for Exec {}
pub struct Image;
impl Runtime for Image {}
pub struct Managed;
impl Runtime for Managed {}
pub struct Cdn;
impl Runtime for Cdn {}
pub trait Accepts<R: Runtime> {}
pub trait Component {
type Kind: Kind;
fn as_ref() -> Ref<Self::Kind> {
Ref::new()
}
}
pub struct Dev;
pub struct Live;
/// Who may inhabit this component. No blanket: Mailhog omits `Live`.
pub trait Inhabit<A> {}
#[macro_export]
macro_rules! bind {
($comp:ty => $rt:ty) => {{
fn _accepts<T: $crate::harmony::Accepts<R>, R: $crate::harmony::Runtime>() {}
_accepts::<$comp, $rt>();
}};
}
#[macro_export]
macro_rules! bind_in {
($atm:ty, $comp:ty => $rt:ty) => {{
fn _accepts<T: $crate::harmony::Accepts<R>, R: $crate::harmony::Runtime>() {}
fn _inhabit<T: $crate::harmony::Inhabit<A>, A>() {}
_accepts::<$comp, $rt>();
_inhabit::<$comp, $atm>();
}};
}

View File

@@ -0,0 +1,28 @@
use crate::backend::Backend;
use crate::bucket::Bucket;
use crate::frontend::Frontend;
use crate::harmony::{Component, Dev, Exec, Image};
use crate::mailhog::Mailhog;
use crate::postgres::Postgres;
use crate::zitadel::Zitadel;
use crate::bind_in;
pub fn bind() {
bind_in!(Dev, Frontend => Exec);
bind_in!(Dev, Backend => Exec);
bind_in!(Dev, Postgres => Image);
bind_in!(Dev, Zitadel => Image);
bind_in!(Dev, Bucket => Image);
bind_in!(Dev, Mailhog => Image);
// crate::bind!(Postgres => Exec); // E0277: Postgres: !Accepts<Exec>
let _ = Frontend::wire(Backend::as_ref(), Zitadel::as_ref());
let _ = Backend::wire(
Frontend::as_ref(),
Postgres::as_ref(),
Zitadel::as_ref(),
Bucket::as_ref(),
Mailhog::as_ref(),
);
let _ = Zitadel::wire(Frontend::as_ref());
}

View File

@@ -0,0 +1,10 @@
use crate::harmony::{Accepts, Component, Dev, Image, Inhabit, Smtp};
pub struct Mailhog;
impl Component for Mailhog {
type Kind = Smtp;
}
impl Accepts<Image> for Mailhog {}
impl Inhabit<Dev> for Mailhog {}

View File

@@ -0,0 +1,15 @@
mod backend;
mod bucket;
mod frontend;
mod harmony;
mod localdev;
mod mailhog;
mod postgres;
mod production;
mod ses;
mod zitadel;
fn main() {
localdev::bind();
production::bind();
}

View File

@@ -0,0 +1,12 @@
use crate::harmony::{Accepts, Component, Dev, Image, Inhabit, Live, Managed, Rel};
pub struct Postgres;
impl Component for Postgres {
type Kind = Rel;
}
impl Accepts<Image> for Postgres {}
impl Accepts<Managed> for Postgres {}
impl Inhabit<Dev> for Postgres {}
impl Inhabit<Live> for Postgres {}

View File

@@ -0,0 +1,29 @@
use crate::backend::Backend;
use crate::bucket::Bucket;
use crate::frontend::Frontend;
use crate::harmony::{Cdn, Component, Image, Live, Managed};
use crate::postgres::Postgres;
use crate::ses::Ses;
use crate::zitadel::Zitadel;
use crate::bind_in;
pub fn bind() {
bind_in!(Live, Frontend => Cdn);
bind_in!(Live, Backend => Image);
bind_in!(Live, Postgres => Managed);
bind_in!(Live, Zitadel => Managed);
bind_in!(Live, Bucket => Managed);
bind_in!(Live, Ses => Managed);
// bind_in!(Live, crate::mailhog::Mailhog => Image); // !Inhabit<Live>
// crate::bind!(Postgres => crate::harmony::Exec); // !Accepts<Exec>
let _ = Frontend::wire(Backend::as_ref(), Zitadel::as_ref());
let _ = Backend::wire(
Frontend::as_ref(),
Postgres::as_ref(),
Zitadel::as_ref(),
Bucket::as_ref(),
Ses::as_ref(),
);
let _ = Zitadel::wire(Frontend::as_ref());
}

View File

@@ -0,0 +1,10 @@
use crate::harmony::{Accepts, Component, Inhabit, Live, Managed, Smtp};
pub struct Ses;
impl Component for Ses {
type Kind = Smtp;
}
impl Accepts<Managed> for Ses {}
impl Inhabit<Live> for Ses {}

View File

@@ -0,0 +1,19 @@
use crate::harmony::{Accepts, Component, Dev, Edge, Idp, Image, Inhabit, Live, Managed, Ref};
pub struct Zitadel;
impl Component for Zitadel {
type Kind = Idp;
}
impl Accepts<Image> for Zitadel {}
impl Accepts<Managed> for Zitadel {}
impl Inhabit<Dev> for Zitadel {}
impl Inhabit<Live> for Zitadel {}
impl Zitadel {
pub fn wire(edge: Ref<Edge>) -> Self {
let _ = edge;
Self
}
}

View File

@@ -0,0 +1,12 @@
[package]
name = "example-dx-bind-origin"
edition = "2024"
version.workspace = true
readme.workspace = true
license.workspace = true
description = "DX experiment (ADR-029): bind::<T, command>(), origin as a Ref. Not a shippable deploy."
publish = false
[[bin]]
name = "dx-bind-origin"
path = "src/main.rs"

View File

@@ -0,0 +1,10 @@
# DX: bind + origin Ref
Frontend-shaped. Opening `frontend.rs` is the frontend. Contexts bind
command vs container; origin is a Ref. Vite proxy is Command-only.
```bash
cargo check -p example-dx-bind-origin
```
`.bind::<Postgres, command>()` does not compile (uncomment in `localdev.rs`).

View File

@@ -0,0 +1,43 @@
use crate::bucket::Bucket;
use crate::harmony::{Command, Container, Image, Launch, Origin, Ref};
use crate::postgres::Postgres;
pub struct Backend {
origin: Ref<Origin>,
issuer: Ref<Origin>,
db: Ref<Postgres>,
files: Ref<Bucket>,
}
impl Backend {
pub fn new(
origin: Ref<Origin>,
issuer: Ref<Origin>,
db: Ref<Postgres>,
files: Ref<Bucket>,
) -> Self {
Self {
origin,
issuer,
db,
files,
}
}
}
impl Command for Backend {
fn launch(&self) -> Launch {
Launch::sh("npm run start")
.cwd("api")
.env("FRONTEND_ORIGIN", self.origin)
.env("OIDC_ISSUER", self.issuer)
.env("DATABASE_URL", self.db)
.env("S3_ENDPOINT", self.files)
}
}
impl Container for Backend {
fn image(&self) -> Image {
Image::build("api")
}
}

View File

@@ -0,0 +1,19 @@
use crate::harmony::{Container, Image, Origin, Ref, Remote};
pub struct Bucket;
impl Bucket {
pub fn endpoint(&self) -> Ref<Self> {
Ref::new()
}
pub fn cors(&self, _: Ref<Origin>) {}
}
impl Container for Bucket {
fn image(&self) -> Image {
Image::build("s3")
}
}
impl Remote for Bucket {}

View File

@@ -0,0 +1,31 @@
use crate::harmony::{Command, Container, Image, Launch, Origin, Ref};
pub struct Frontend {
issuer: Ref<Origin>,
}
impl Frontend {
pub fn new(issuer: Ref<Origin>) -> Self {
Self { issuer }
}
pub fn origin(&self) -> Ref<Origin> {
Ref::new()
}
}
impl Command for Frontend {
fn launch(&self) -> Launch {
Launch::sh("npm run dev")
.cwd("web")
.env("VITE_OIDC_ISSUER", self.issuer)
.env("VITE_PROXY", "/v1")
.publish(self.origin(), 5173)
}
}
impl Container for Frontend {
fn image(&self) -> Image {
Image::build("web")
}
}

View File

@@ -0,0 +1,82 @@
//! Stand-in for framework types this experiment needs. Not `harmony_app`.
use std::marker::PhantomData;
pub struct Origin;
pub struct Ref<T>(PhantomData<T>);
impl<T> Copy for Ref<T> {}
impl<T> Clone for Ref<T> {
fn clone(&self) -> Self {
*self
}
}
impl<T> Ref<T> {
pub const fn new() -> Self {
Self(PhantomData)
}
}
pub trait EnvVal {}
impl<T> EnvVal for Ref<T> {}
impl EnvVal for &'static str {}
pub struct Launch;
impl Launch {
pub fn sh(_: &'static str) -> Self {
Self
}
pub fn cwd(self, _: &'static str) -> Self {
self
}
pub fn env(self, _: &'static str, _: impl EnvVal) -> Self {
self
}
pub fn publish(self, _: Ref<Origin>, _: u16) -> Self {
self
}
}
pub struct Image;
impl Image {
pub fn build(_: &'static str) -> Self {
Self
}
}
pub trait Command {
fn launch(&self) -> Launch;
}
pub trait Container {
fn image(&self) -> Image;
}
pub trait Remote {}
#[allow(non_camel_case_types)]
pub struct command;
#[allow(non_camel_case_types)]
pub struct container;
#[allow(non_camel_case_types)]
pub struct remote;
pub trait Accepts<R> {}
impl<T: Command> Accepts<command> for T {}
impl<T: Container> Accepts<container> for T {}
impl<T: Remote> Accepts<remote> for T {}
pub struct Context;
impl Context {
pub fn named(_: &'static str) -> Self {
Self
}
pub fn bind<C: Accepts<R>, R>(self) -> Self {
self
}
}

View File

@@ -0,0 +1,16 @@
use crate::backend::Backend;
use crate::bucket::Bucket;
use crate::frontend::Frontend;
use crate::harmony::{command, container, Context};
use crate::postgres::Postgres;
use crate::zitadel::Zitadel;
pub fn context() -> Context {
Context::named("localdev")
.bind::<Frontend, command>()
.bind::<Backend, command>()
.bind::<Postgres, container>()
.bind::<Zitadel, container>()
.bind::<Bucket, container>()
// .bind::<Postgres, command>()
}

View File

@@ -0,0 +1,34 @@
#![allow(dead_code)]
mod backend;
mod bucket;
mod frontend;
mod harmony;
mod localdev;
mod postgres;
mod production;
mod zitadel;
use crate::harmony::{Command, Container};
fn main() {
let db = postgres::Postgres;
let files = bucket::Bucket;
let id = zitadel::Zitadel;
let web = frontend::Frontend::new(id.issuer());
let api = backend::Backend::new(web.origin(), id.issuer(), db.url(), files.endpoint());
id.redirect(web.origin(), "/auth/callback");
files.cors(web.origin());
let _ = web.launch();
let _ = web.image();
let _ = api.launch();
let _ = api.image();
let _ = db.image();
let _ = id.image();
let _ = files.image();
let _ = localdev::context();
let _ = production::context();
}

View File

@@ -0,0 +1,15 @@
use crate::harmony::{Container, Image, Ref};
pub struct Postgres;
impl Postgres {
pub fn url(&self) -> Ref<Self> {
Ref::new()
}
}
impl Container for Postgres {
fn image(&self) -> Image {
Image::build("postgres:16")
}
}

View File

@@ -0,0 +1,15 @@
use crate::backend::Backend;
use crate::bucket::Bucket;
use crate::frontend::Frontend;
use crate::harmony::{container, remote, Context};
use crate::postgres::Postgres;
use crate::zitadel::Zitadel;
pub fn context() -> Context {
Context::named("production")
.bind::<Frontend, container>()
.bind::<Backend, container>()
.bind::<Postgres, container>()
.bind::<Zitadel, container>()
.bind::<Bucket, remote>()
}

View File

@@ -0,0 +1,17 @@
use crate::harmony::{Container, Image, Origin, Ref};
pub struct Zitadel;
impl Zitadel {
pub fn issuer(&self) -> Ref<Origin> {
Ref::new()
}
pub fn redirect(&self, _: Ref<Origin>, _: &'static str) {}
}
impl Container for Zitadel {
fn image(&self) -> Image {
Image::build("zitadel")
}
}

View File

@@ -0,0 +1,12 @@
[package]
name = "example-dx-guide-ship"
edition = "2024"
version.workspace = true
readme.workspace = true
license.workspace = true
description = "DX experiment (ADR-029): tutorial-shaped ship.rs inventory, Runtime enum binds."
publish = false
[[bin]]
name = "dx-guide-ship"
path = "src/main.rs"

View File

@@ -0,0 +1,39 @@
# Getting started: one file per component
Junior-shaped ship inventory (ADR-029). Opening `frontend.rs` *is* the
frontend. There is no `FrontendCommand` type.
```bash
cargo check -p example-dx-guide-ship
```
## Copy this layout
| File | What it is |
|------|------------|
| `src/main.rs` | Inventory: `.component::<Frontend>()` + contexts |
| `src/frontend.rs` | `impl Command` (`npm run dev`) and `impl Container` |
| `src/backend.rs` | Same, plus `env()` wiring via `Ref<Postgres>` etc. |
| `src/postgres.rs` | Container (or Remote in production). `PostgresScore` |
| `src/zitadel.rs` | `ZitadelScore` — do not invent `AppScore` |
| `src/bucket.rs` | `BucketScore` |
| `src/localdev.rs` | `Context::new("localdev").bind::<Frontend>(Runtime::Command)` |
| `src/production.rs` | Frontend/Backend as Container; managed bits as Remote |
| `src/harmony.rs` | Stub framework types. Not `harmony_app`. |
## Runtime is an enum
```rust
Context::new("localdev").bind::<Frontend>(Runtime::Command)
```
`Runtime` is `Command | Container | Remote`. Binding `Runtime::Command` on
Postgres **compiles**. The type system does not know Postgres has no
`impl Command`. That is the tradeoff: obvious API, few traits, illegal
binds are not a type error. (The other DX crates make that a type error.)
## Cycles
`Frontend::env` takes `Ref<Backend>` and `Backend::env` takes
`Ref<Frontend>`. Those URLs are allocated before either side starts.
A Ref is desired state, not a live connection.

View File

@@ -0,0 +1,42 @@
use crate::bucket::Bucket;
use crate::frontend::Frontend;
use crate::harmony::{Command, CommandSpec, Container, Env, Image, Ref};
use crate::postgres::Postgres;
use crate::zitadel::Zitadel;
/// THIS FILE IS THE BACKEND.
pub struct Backend;
impl Command for Backend {
fn command(&self) -> CommandSpec {
CommandSpec::new("npm").args(&["run", "start:dev"]).port(8080)
}
}
impl Container for Backend {
fn image(&self) -> Image {
Image::from_dockerfile("../backend/Dockerfile").port(8080)
}
}
impl Backend {
/// Circular with Frontend::env: URLs are allocated before either side starts.
/// A Ref is desired state, not a live connection.
pub fn env(
&self,
web: Ref<Frontend>,
db: Ref<Postgres>,
idp: Ref<Zitadel>,
files: Ref<Bucket>,
) -> Env {
Env::new()
.set("CORS_ORIGIN", web.public_url())
.set("DATABASE_URL", db.url())
.set("OIDC_ISSUER", idp.issuer())
.set("OIDC_CLIENT_ID", idp.client_id())
.set("S3_ENDPOINT", files.endpoint())
.set("S3_BUCKET", files.name())
.set("S3_ACCESS_KEY", files.access_key())
.set("S3_SECRET_KEY", files.secret_key())
}
}

View File

@@ -0,0 +1,24 @@
use crate::harmony::scores::BucketScore;
use crate::harmony::{Container, Image, Remote, Url};
/// THIS FILE IS THE FILE BUCKET.
pub struct Bucket;
// Harmony already has BucketScore. Do not invent AppScore for glue.
impl BucketScore for Bucket {
fn name(&self) -> &str {
"app-files"
}
}
impl Container for Bucket {
fn image(&self) -> Image {
Image::from_dockerfile("s3")
}
}
impl Remote for Bucket {
fn url(&self) -> Url {
Url::from_score(self)
}
}

View File

@@ -0,0 +1,24 @@
use crate::backend::Backend;
use crate::harmony::{Command, CommandSpec, Container, Env, Image, Ref};
/// THIS FILE IS THE FRONTEND. Not FrontendCommand / FrontendContainer.
pub struct Frontend;
impl Command for Frontend {
fn command(&self) -> CommandSpec {
CommandSpec::new("npm").args(&["run", "dev"]).port(5173)
}
}
impl Container for Frontend {
fn image(&self) -> Image {
Image::from_dockerfile("../frontend/Dockerfile").port(80)
}
}
impl Frontend {
/// Circular with Backend::env: URLs are allocated before either side starts.
pub fn env(&self, api: Ref<Backend>) -> Env {
Env::new().set("VITE_API_URL", api.public_url())
}
}

View File

@@ -0,0 +1,153 @@
//! Stand-in for framework types. Not `harmony_app`.
use std::marker::PhantomData;
/// How a context runs a component. An enum, not a type parameter —
/// `bind::<Postgres>(Runtime::Command)` is *not* a type error. See README.
pub enum Runtime {
Command,
Container,
Remote,
}
pub struct CommandSpec {
pub bin: &'static str,
}
impl CommandSpec {
pub fn new(bin: &'static str) -> Self {
Self { bin }
}
pub fn args(self, _: &[&str]) -> Self {
self
}
pub fn port(self, _: u16) -> Self {
self
}
}
pub struct Image;
impl Image {
pub fn from_dockerfile(_: &'static str) -> Self {
Self
}
pub fn port(self, _: u16) -> Self {
self
}
}
pub struct Env;
impl Env {
pub fn new() -> Self {
Self
}
pub fn set(self, _: &'static str, _: impl ToString) -> Self {
self
}
}
pub struct Url;
impl Url {
pub fn from_score<T>(_: &T) -> Self {
Self
}
}
impl std::fmt::Display for Url {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "url")
}
}
pub struct Ref<T>(PhantomData<T>);
impl<T> Copy for Ref<T> {}
impl<T> Clone for Ref<T> {
fn clone(&self) -> Self {
*self
}
}
impl<T> Ref<T> {
pub fn new() -> Self {
Self(PhantomData)
}
pub fn public_url(self) -> Url {
Url
}
pub fn url(self) -> Url {
Url
}
pub fn issuer(self) -> Url {
Url
}
pub fn client_id(self) -> &'static str {
"id"
}
pub fn endpoint(self) -> Url {
Url
}
pub fn name(self) -> &'static str {
"files"
}
pub fn access_key(self) -> &'static str {
"key"
}
pub fn secret_key(self) -> &'static str {
"secret"
}
}
pub trait Command {
fn command(&self) -> CommandSpec;
}
pub trait Container {
fn image(&self) -> Image;
}
pub trait Remote {
fn url(&self) -> Url {
Url
}
}
pub struct Context;
impl Context {
pub fn new(_: &'static str) -> Self {
Self
}
/// Not type-checked against Command/Container impls — see README.
pub fn bind<C>(self, _: Runtime) -> Self {
let _ = PhantomData::<C>;
self
}
}
pub struct Ship;
impl Ship {
pub fn component<C>(self) -> Self {
self
}
pub fn context(self, _: fn() -> Context) -> Self {
self
}
pub fn run(self) {}
}
pub fn ship() -> Ship {
Ship
}
pub mod scores {
/// Framework scores. App glue is `env()` on the component — do not invent `AppScore`.
pub trait PostgresScore {
fn name(&self) -> &str;
fn version(&self) -> &str {
"16"
}
}
pub trait ZitadelScore {
fn name(&self) -> &str;
}
pub trait BucketScore {
fn name(&self) -> &str;
}
}

View File

@@ -0,0 +1,16 @@
use crate::backend::Backend;
use crate::bucket::Bucket;
use crate::frontend::Frontend;
use crate::harmony::{Context, Runtime};
use crate::postgres::Postgres;
use crate::zitadel::Zitadel;
pub fn context() -> Context {
Context::new("localdev")
.bind::<Frontend>(Runtime::Command)
.bind::<Backend>(Runtime::Command)
.bind::<Postgres>(Runtime::Container)
.bind::<Bucket>(Runtime::Container)
.bind::<Zitadel>(Runtime::Container)
// .bind::<Postgres>(Runtime::Command) // compiles! Runtime is an enum. See README.
}

View File

@@ -0,0 +1,31 @@
//! Copy-paste inventory. One line per component, one line per context.
#![allow(dead_code)]
mod backend;
mod bucket;
mod frontend;
mod harmony;
mod localdev;
mod postgres;
mod production;
mod zitadel;
use backend::Backend;
use bucket::Bucket;
use frontend::Frontend;
use harmony::ship;
use postgres::Postgres;
use zitadel::Zitadel;
fn main() {
ship()
.component::<Frontend>()
.component::<Backend>()
.component::<Postgres>()
.component::<Zitadel>()
.component::<Bucket>()
.context(localdev::context)
.context(production::context)
.run();
}

View File

@@ -0,0 +1,24 @@
use crate::harmony::scores::PostgresScore;
use crate::harmony::{Container, Image, Remote, Url};
/// THIS FILE IS POSTGRES. No `impl Command` — but `Runtime::Command` still binds (see README).
pub struct Postgres;
// Harmony already has PostgresScore. Do not invent AppScore for glue.
impl PostgresScore for Postgres {
fn name(&self) -> &str {
"app-db"
}
}
impl Container for Postgres {
fn image(&self) -> Image {
Image::from_dockerfile("postgres:16")
}
}
impl Remote for Postgres {
fn url(&self) -> Url {
Url::from_score(self)
}
}

View File

@@ -0,0 +1,15 @@
use crate::backend::Backend;
use crate::bucket::Bucket;
use crate::frontend::Frontend;
use crate::harmony::{Context, Runtime};
use crate::postgres::Postgres;
use crate::zitadel::Zitadel;
pub fn context() -> Context {
Context::new("production")
.bind::<Frontend>(Runtime::Container)
.bind::<Backend>(Runtime::Container)
.bind::<Postgres>(Runtime::Remote)
.bind::<Bucket>(Runtime::Remote)
.bind::<Zitadel>(Runtime::Remote)
}

View File

@@ -0,0 +1,24 @@
use crate::harmony::scores::ZitadelScore;
use crate::harmony::{Container, Image, Remote, Url};
/// THIS FILE IS ZITADEL.
pub struct Zitadel;
// Harmony already has ZitadelScore. Do not invent AppScore for glue.
impl ZitadelScore for Zitadel {
fn name(&self) -> &str {
"idp"
}
}
impl Container for Zitadel {
fn image(&self) -> Image {
Image::from_dockerfile("zitadel")
}
}
impl Remote for Zitadel {
fn url(&self) -> Url {
Url::from_score(self)
}
}

View File

@@ -0,0 +1,12 @@
[package]
name = "example-dx-slot-secret"
edition = "2024"
version.workspace = true
readme.workspace = true
license.workspace = true
description = "DX experiment (ADR-029): Slot/Ref, Secret<T>, dialect types, exhaustive bind."
publish = false
[[bin]]
name = "dx-slot-secret"
path = "src/main.rs"

View File

@@ -0,0 +1,9 @@
# DX: Slot / Secret / dialect
Backend-shaped. `Slot<T>` is advertised; `Ref<T>` is held.
`Secret<Jdbc>` cannot sit on `WebEnv`. `db.jdbc()` is Java; `db.url()` is Node.
`bind! { t.db => Command }` does not compile.
```bash
cargo check -p example-dx-slot-secret
```

View File

@@ -0,0 +1,44 @@
use crate::harmony::{
CommandRuntime, ContainerRuntime, HttpUrl, Issuer, Jdbc, MachineKey, OAuthClient, Ref, S3,
Secret, Slot,
};
pub struct Api {
pub url: Slot<HttpUrl>,
pub client: Slot<OAuthClient>,
pub db: Ref<Secret<Jdbc>>,
pub issuer: Ref<Issuer>,
pub sa: Ref<Secret<MachineKey>>,
pub bucket: Ref<S3>,
}
pub struct ApiEnv {
pub db: Secret<Jdbc>,
pub issuer: Issuer,
pub sa: Secret<MachineKey>,
pub s3: S3,
}
impl Api {
pub fn env(&self) -> ApiEnv {
ApiEnv {
db: self.db.get(),
issuer: self.issuer.get(),
sa: self.sa.get(),
s3: self.bucket.get(),
}
}
}
impl CommandRuntime for Api {
fn listen(&self) -> u16 {
let _ = self.env();
8080
}
}
impl ContainerRuntime for Api {
fn image(&self) -> &'static str {
"api"
}
}

View File

@@ -0,0 +1,180 @@
use std::marker::PhantomData;
#[derive(Clone, Copy)]
pub struct Slot<T>(PhantomData<T>);
impl<T> Slot<T> {
pub const fn new() -> Self {
Self(PhantomData)
}
pub fn as_ref(self) -> Ref<T> {
Ref(PhantomData)
}
pub fn public(self) -> Ref<T> {
Ref(PhantomData)
}
pub fn join(self, _: &'static str) -> Ref<T> {
Ref(PhantomData)
}
}
impl<T> From<Slot<T>> for Ref<T> {
fn from(slot: Slot<T>) -> Self {
slot.as_ref()
}
}
#[derive(Clone, Copy)]
pub struct Ref<T>(PhantomData<T>);
impl<T> Ref<T> {
pub fn get(self) -> T
where
T: Default,
{
T::default()
}
}
#[derive(Clone, Copy, Default)]
pub struct Secret<T>(PhantomData<T>);
#[derive(Clone, Copy, Default)]
pub struct Jdbc;
#[derive(Clone, Copy, Default)]
pub struct PgUrl;
#[derive(Clone, Copy, Default)]
pub struct HttpUrl;
#[derive(Clone, Copy, Default)]
pub struct Issuer;
#[derive(Clone, Copy, Default)]
pub struct MachineKey;
#[derive(Clone, Copy, Default)]
pub struct S3;
#[derive(Clone, Copy)]
pub struct OAuthClient;
pub struct Command;
pub struct Container;
pub struct Remote;
pub trait RunsAs<R> {}
pub trait CommandRuntime {
fn listen(&self) -> u16;
}
pub trait ContainerRuntime {
fn image(&self) -> &'static str;
}
pub trait RemoteRuntime {}
impl<T: CommandRuntime> RunsAs<Command> for T {}
impl<T: ContainerRuntime> RunsAs<Container> for T {}
impl<T: RemoteRuntime> RunsAs<Remote> for T {}
#[derive(Clone, Copy)]
pub struct Postgres;
impl Postgres {
pub fn named(_: &'static str) -> Self {
Self
}
pub fn jdbc(&self) -> Ref<Secret<Jdbc>> {
Ref(PhantomData)
}
pub fn url(&self) -> Ref<Secret<PgUrl>> {
Ref(PhantomData)
}
}
impl ContainerRuntime for Postgres {
fn image(&self) -> &'static str {
"postgres:16"
}
}
impl RemoteRuntime for Postgres {}
#[derive(Clone, Copy)]
pub struct Identity;
impl Identity {
pub fn named(_: &'static str) -> Self {
Self
}
pub fn issuer(&self) -> Ref<Issuer> {
Ref(PhantomData)
}
pub fn machine_key(&self, _: Ref<OAuthClient>) -> Ref<Secret<MachineKey>> {
Ref(PhantomData)
}
pub fn redirect(&self, _: impl Into<Ref<HttpUrl>>) {}
pub fn invite_base(&self, _: Slot<HttpUrl>) {}
pub fn client(&self, _: Slot<OAuthClient>, _: ClientKind) {}
}
impl ContainerRuntime for Identity {
fn image(&self) -> &'static str {
"identity"
}
}
impl RemoteRuntime for Identity {}
pub enum ClientKind {
Machine,
}
#[derive(Clone, Copy)]
pub struct Bucket;
impl Bucket {
pub fn named(_: &'static str) -> Self {
Self
}
pub fn s3(&self) -> Ref<S3> {
Ref(PhantomData)
}
}
impl ContainerRuntime for Bucket {
fn image(&self) -> &'static str {
"bucket"
}
}
impl RemoteRuntime for Bucket {}
#[macro_export]
macro_rules! bind {
($($t:ident . $comp:ident => $rt:ty),+ $(,)?) => {{
$(
{
fn __runs_as<T: $crate::harmony::RunsAs<$rt>>(_: &T) {}
__runs_as(&$t.$comp);
}
)+
$crate::topology::Bound {
$($comp: &$t.$comp,)+
}
}};
}

View File

@@ -0,0 +1,14 @@
use crate::bind;
use crate::harmony::{Command, Container};
use crate::topology::Topology;
pub fn bind(t: &Topology) {
bind! {
t.api => Command,
t.web => Command,
t.db => Container,
t.idp => Container,
t.bucket => Container,
};
// bind! { t.db => Command } // does not compile
}

View File

@@ -0,0 +1,14 @@
#![allow(dead_code)]
mod api;
mod harmony;
mod local;
mod production;
mod topology;
mod web;
fn main() {
let t = topology::topology();
local::bind(&t);
production::bind(&t);
}

View File

@@ -0,0 +1,13 @@
use crate::bind;
use crate::harmony::{Container, Remote};
use crate::topology::Topology;
pub fn bind(t: &Topology) {
bind! {
t.api => Container,
t.web => Container,
t.db => Remote,
t.idp => Container,
t.bucket => Remote,
};
}

View File

@@ -0,0 +1,51 @@
use crate::api::Api;
use crate::harmony::{Bucket, ClientKind, Identity, Postgres, Slot};
use crate::web::Web;
pub struct Topology {
pub api: Api,
pub web: Web,
pub db: Postgres,
pub idp: Identity,
pub bucket: Bucket,
}
pub struct Bound<'a> {
pub api: &'a Api,
pub web: &'a Web,
pub db: &'a Postgres,
pub idp: &'a Identity,
pub bucket: &'a Bucket,
}
pub fn topology() -> Topology {
let db = Postgres::named("app");
let bucket = Bucket::named("files");
let idp = Identity::named("auth");
let client = Slot::new();
let url = Slot::new();
idp.client(client, ClientKind::Machine);
let api = Api {
url,
client,
db: db.jdbc(),
// db: db.url(), // Api is Java-shaped; url() is Node
issuer: idp.issuer(),
sa: idp.machine_key(client.as_ref()),
bucket: bucket.s3(),
};
let web = Web {
url: Slot::new(),
api: api.url.public(),
};
idp.redirect(api.url);
idp.redirect(web.url.join("/silent-renew"));
idp.invite_base(web.url);
Topology {
api,
web,
db,
idp,
bucket,
}
}

View File

@@ -0,0 +1,31 @@
use crate::harmony::{CommandRuntime, ContainerRuntime, HttpUrl, Ref, Slot};
pub struct Web {
pub url: Slot<HttpUrl>,
pub api: Ref<HttpUrl>,
}
pub struct WebEnv {
pub api: HttpUrl,
}
impl Web {
pub fn env(&self) -> WebEnv {
WebEnv {
api: self.api.get(),
}
}
}
impl CommandRuntime for Web {
fn listen(&self) -> u16 {
let _ = self.env();
5173
}
}
impl ContainerRuntime for Web {
fn image(&self) -> &'static str {
"web"
}
}

View File

@@ -124,7 +124,7 @@ struct Cli {
#[arg(long, requires = "openbao_secret_prefix")] #[arg(long, requires = "openbao_secret_prefix")]
openbao_url: Option<String>, openbao_url: Option<String>,
/// KV prefix containing this fleet's deployment and pull secrets. /// KV prefix containing this fleet's deployment secrets (including image pull).
#[arg(long, requires = "openbao_url")] #[arg(long, requires = "openbao_url")]
openbao_secret_prefix: Option<String>, openbao_secret_prefix: Option<String>,

31
examples/notes/Cargo.toml Normal file
View File

@@ -0,0 +1,31 @@
[package]
name = "example-notes"
edition = "2024"
version.workspace = true
readme.workspace = true
license.workspace = true
description = "ADR-029 notes app: frontend + backend + postgres, localdev vs cluster."
publish = false
[[bin]]
name = "notes"
path = "src/main.rs"
[[bin]]
name = "notes-api"
path = "src/bin/notes_api.rs"
[[bin]]
name = "notes-web"
path = "src/bin/notes_web.rs"
[dependencies]
anyhow.workspace = true
async-trait.workspace = true
harmony = { path = "../../harmony" }
harmony_app = { path = "../../harmony_app" }
harmony_cli = { path = "../../harmony_cli" }
harmony_macros = { path = "../../harmony_macros" }
harmony_types = { path = "../../harmony_types" }
serde_json.workspace = true
tokio = { workspace = true, features = ["full"] }

17
examples/notes/README.md Normal file
View File

@@ -0,0 +1,17 @@
# notes — ADR-029 example
Frontend, backend, postgres. One type per file. Contexts bind runtimes.
```bash
cargo build -p example-notes --bins
cargo run -p example-notes --bin notes -- ship --context localdev
# frontend http://127.0.0.1:15173 backend http://127.0.0.1:18080/ (200 + "ok" when DATABASE_URL is set)
cargo run -p example-notes --bin notes -- ship --context cluster
```
`localdev`: host processes + CNPG on k3d (NodePort).
`cluster`: all containers on the same k3d (nginx + http-echo + CNPG).
Context bind is a struct you fill in (`as_command` / `as_container`). Forget a
field or `postgres.as_command()` — compile error, no macro.

36
examples/notes/src/app.rs Normal file
View File

@@ -0,0 +1,36 @@
use crate::backend::Backend;
use crate::frontend::Frontend;
use crate::postgres::Postgres;
use harmony_app::dx::{BoundComp, HttpUrl, Slot};
pub struct Notes {
pub frontend: Frontend,
pub backend: Backend,
pub postgres: Postgres,
}
pub struct Bound<'a, F, B, P> {
pub frontend: BoundComp<'a, Frontend, F>,
pub backend: BoundComp<'a, Backend, B>,
pub postgres: BoundComp<'a, Postgres, P>,
}
pub fn notes() -> Notes {
let web_url = Slot::<HttpUrl>::new();
let api_url = Slot::<HttpUrl>::new();
let postgres = Postgres::named("notes-db");
let backend = Backend {
url: api_url,
origin: web_url.public(),
db: postgres.url(),
};
let frontend = Frontend {
url: web_url,
api: api_url.public(),
};
Notes {
frontend,
backend,
postgres,
}
}

View File

@@ -0,0 +1,20 @@
use harmony_app::dx::{Command, Container, HttpUrl, Image, Launch, PgUrl, Ref, Secret, Slot};
#[allow(dead_code)]
pub struct Backend {
pub url: Slot<HttpUrl>,
pub origin: Ref<HttpUrl>,
pub db: Ref<Secret<PgUrl>>,
}
impl Command for Backend {
fn launch(&self) -> Launch {
Launch::bin("notes-api").listen(18080)
}
}
impl Container for Backend {
fn image(&self) -> Image {
Image::from_registry("hashicorp/http-echo:1.0.0").port(5678)
}
}

View File

@@ -0,0 +1,21 @@
use std::io::Write;
use std::net::TcpListener;
fn main() {
let db = std::env::var("DATABASE_URL").unwrap_or_default();
let listener = TcpListener::bind("127.0.0.1:18080").expect("bind 18080");
eprintln!("notes-api listening 18080 DATABASE_URL_set={}", !db.is_empty());
for mut stream in listener.incoming().flatten() {
let body = if db.is_empty() {
"DATABASE_URL missing"
} else {
"ok"
};
let status = if db.is_empty() { "503" } else { "200" };
let resp = format!(
"HTTP/1.1 {status} OK\r\nContent-Type: text/plain\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
body.len()
);
let _ = stream.write_all(resp.as_bytes());
}
}

View File

@@ -0,0 +1,20 @@
use std::io::Write;
use std::net::TcpListener;
const PAGE: &str = r#"<!doctype html>
<title>notes</title>
<p>notes frontend</p>
<p><a href="http://127.0.0.1:18080/">backend</a></p>
"#;
fn main() {
let listener = TcpListener::bind("127.0.0.1:15173").expect("bind 15173");
eprintln!("notes-web listening 15173");
for mut stream in listener.incoming().flatten() {
let resp = format!(
"HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{PAGE}",
PAGE.len()
);
let _ = stream.write_all(resp.as_bytes());
}
}

View File

@@ -0,0 +1,36 @@
use crate::app::{Bound, Notes};
use harmony::modules::k8s::deployment::K8sDeploymentScore;
use harmony::modules::postgresql::K8sPostgreSQLScore;
use harmony::score::Score;
use harmony::topology::K8sAnywhereTopology;
use harmony_app::dx::{container, AsContainer, Container};
use serde_json::json;
pub fn bind(app: &Notes) -> Bound<'_, container, container, container> {
Bound {
frontend: app.frontend.as_container(),
backend: app.backend.as_container(),
postgres: app.postgres.as_container(),
}
}
pub fn scores(app: &Notes, namespace: &str) -> Vec<Box<dyn Score<K8sAnywhereTopology>>> {
let bound = bind(app);
let cluster = bound.postgres.inner.name;
let pg = K8sPostgreSQLScore::new(namespace).cluster_name(cluster);
let frontend = K8sDeploymentScore {
name: "frontend".into(),
image: bound.frontend.inner.image().name,
namespace: Some(namespace.into()),
env_vars: json!([]),
};
let backend = K8sDeploymentScore {
name: "backend".into(),
image: bound.backend.inner.image().name,
namespace: Some(namespace.into()),
env_vars: json!([
{ "name": "CORS_ORIGIN", "value": "http://frontend" }
]),
};
vec![Box::new(pg), Box::new(frontend), Box::new(backend)]
}

View File

@@ -0,0 +1,19 @@
use harmony_app::dx::{Command, Container, HttpUrl, Image, Launch, Ref, Slot};
#[allow(dead_code)]
pub struct Frontend {
pub url: Slot<HttpUrl>,
pub api: Ref<HttpUrl>,
}
impl Command for Frontend {
fn launch(&self) -> Launch {
Launch::bin("notes-web").listen(15173)
}
}
impl Container for Frontend {
fn image(&self) -> Image {
Image::from_registry("nginx:alpine").port(80)
}
}

View File

@@ -0,0 +1,39 @@
use crate::app::{Bound, Notes};
use harmony::modules::host_process::HostProcessScore;
use harmony::modules::postgresql::{K8sPostgreSQLScore, PostgresDebugRouteScore};
use harmony::score::Score;
use harmony::topology::K8sAnywhereTopology;
use harmony_app::dx::{command, container, AsCommand, AsContainer, Command, Launch};
pub fn bind(app: &Notes) -> Bound<'_, command, command, container> {
Bound {
frontend: app.frontend.as_command(),
backend: app.backend.as_command(),
postgres: app.postgres.as_container(),
// postgres: app.postgres.as_command(), // does not compile: no Command
}
}
pub fn scores(app: &Notes, namespace: &str) -> Vec<Box<dyn Score<K8sAnywhereTopology>>> {
let bound = bind(app);
let cluster = bound.postgres.inner.name;
let pg = K8sPostgreSQLScore::new(namespace).cluster_name(cluster);
let debug = PostgresDebugRouteScore::new(namespace, cluster).node_port();
let mut api = from_launch("backend", bound.backend.inner.launch());
api = api.env_postgres_uri("DATABASE_URL", namespace, cluster);
let web = from_launch("frontend", bound.frontend.inner.launch());
vec![Box::new(pg), Box::new(debug), Box::new(api), Box::new(web)]
}
fn from_launch(name: &str, launch: Launch) -> HostProcessScore {
let mut score = HostProcessScore::new(name, launch.program);
score.args = launch.args;
score.cwd = launch.cwd;
score.port = launch.port;
score.env = launch
.env
.into_iter()
.map(|(k, v)| (k, harmony::modules::host_process::ProcessEnv::Literal(v)))
.collect();
score
}

View File

@@ -0,0 +1,68 @@
mod app;
mod backend;
mod cluster;
mod frontend;
mod localdev;
mod postgres;
use async_trait::async_trait;
use harmony::topology::K8sAnywhereTopology;
use harmony_app::dx::container;
use harmony_app::{
AppContext, AppError, AppIdentity, Context, ContextCatalog, ContextSpec, HarmonyApp,
ImageRefs, LocalContext,
};
use harmony_macros::context_name;
struct NotesApp;
#[async_trait]
impl HarmonyApp<K8sAnywhereTopology> for NotesApp {
fn identity(&self, ctx: &AppContext) -> AppIdentity {
AppIdentity {
name: "notes".into(),
namespace: ctx.namespace().to_string(),
}
}
async fn scores(
&self,
ctx: &AppContext,
_images: &ImageRefs,
) -> Result<Vec<Box<dyn harmony::score::Score<K8sAnywhereTopology>>>, AppError> {
let notes = app::notes();
match ctx.name() {
"localdev" => {
let _ = localdev::bind(&notes);
Ok(localdev::scores(&notes, ctx.namespace()))
}
"cluster" => {
let _: app::Bound<container, container, container> = cluster::bind(&notes);
Ok(cluster::scores(&notes, ctx.namespace()))
}
other => Err(AppError::InvalidComposition(format!(
"unknown context '{other}'"
))),
}
}
}
#[tokio::main]
async fn main() -> anyhow::Result<()> {
harmony_cli::app::app_main(
NotesApp,
ContextCatalog::new([
Context {
name: context_name!("localdev"),
namespace: "notes".parse()?,
spec: ContextSpec::Local(LocalContext::ManagedK3d),
},
Context {
name: context_name!("cluster"),
namespace: "notes".parse()?,
spec: ContextSpec::Local(LocalContext::ManagedK3d),
},
])?,
)
.await
}

View File

@@ -0,0 +1,21 @@
use harmony_app::dx::{Container, Image, PgUrl, Ref, Secret, Slot};
pub struct Postgres {
pub name: &'static str,
}
impl Postgres {
pub fn named(name: &'static str) -> Self {
Self { name }
}
pub fn url(&self) -> Ref<Secret<PgUrl>> {
Slot::new().as_ref()
}
}
impl Container for Postgres {
fn image(&self) -> Image {
Image::from_registry("ghcr.io/cloudnative-pg/postgresql:16")
}
}

View File

@@ -4,9 +4,7 @@ use std::time::Duration;
use anyhow::{Result, anyhow, bail}; use anyhow::{Result, anyhow, bail};
use futures_util::StreamExt; use futures_util::StreamExt;
use harmony_reconciler_contracts::{ use harmony_reconciler_contracts::{PodmanService, PodmanV0Score, RestartPolicy, VolumeMount};
DEVICE_PULL_SECRET_PATH, PodmanService, PodmanV0Score, RestartPolicy, VolumeMount,
};
use harmony_secret::SecretStore; use harmony_secret::SecretStore;
use oci_client::Reference; use oci_client::Reference;
use podman_api::Podman; use podman_api::Podman;
@@ -420,13 +418,13 @@ impl PodmanRuntime {
service.name service.name
) )
})?; })?;
let namespace = format!("{}/{}", source.prefix, DEVICE_PULL_SECRET_PATH); let namespace = format!("{}/{deployment}", source.prefix);
let bytes = source let bytes = source
.store .store
.get_raw(&namespace, reference) .get_raw(&namespace, reference)
.await .await
.map_err(|error| { .map_err(|error| {
anyhow!("fetching image pull secret '{reference}': {error}") anyhow!("fetching image pull secret '{namespace}/{reference}': {error}")
})?; })?;
Some( Some(
serde_json::from_slice::<RegistryCredential>(&bytes).map_err(|error| { serde_json::from_slice::<RegistryCredential>(&bytes).map_err(|error| {

View File

@@ -0,0 +1,99 @@
use std::collections::BTreeMap;
use std::path::Path;
use anyhow::{Context, bail};
use harmony_app::RegistryCredentials;
use harmony_reconciler_contracts::upgrade::{AGENT_OCI_ARTIFACT_TYPE, AGENT_OCI_LAYER_MEDIA_TYPE};
use oci_client::annotations::ORG_OPENCONTAINERS_IMAGE_VERSION;
use oci_client::client::{Config, ImageLayer};
use oci_client::errors::{OciDistributionError, OciErrorCode};
use oci_client::manifest::OciImageManifest;
use oci_client::secrets::RegistryAuth;
use oci_client::{Client, Reference};
pub async fn publish_agent_artifact(
binary: &Path,
reference: &str,
version: &str,
credentials: &RegistryCredentials,
) -> anyhow::Result<String> {
let reference = Reference::try_from(reference).context("invalid agent OCI reference")?;
if reference.tag().is_none() || reference.digest().is_some() {
bail!("agent publication requires a tag reference");
}
let auth = RegistryAuth::Basic(credentials.username.clone(), credentials.token.clone());
let client = Client::default();
let layer = ImageLayer::new(
std::fs::read(binary)
.with_context(|| format!("reading agent binary {}", binary.display()))?,
AGENT_OCI_LAYER_MEDIA_TYPE.to_string(),
None,
);
match client.pull_manifest(&reference, &auth).await {
Ok((oci_client::manifest::OciManifest::Image(manifest), digest))
if manifest.artifact_type.as_deref() == Some(AGENT_OCI_ARTIFACT_TYPE)
&& manifest
.annotations
.as_ref()
.and_then(|annotations| annotations.get(ORG_OPENCONTAINERS_IMAGE_VERSION))
.map(String::as_str)
== Some(version)
&& matches!(manifest.layers.as_slice(), [existing]
if existing.media_type == AGENT_OCI_LAYER_MEDIA_TYPE
&& existing.digest == layer.sha256_digest()) =>
{
return Ok(format!(
"oci://{}/{}@{digest}",
reference.registry(),
reference.repository()
));
}
Ok(_) => bail!(
"agent artifact tag exists with different content: {}",
reference.whole()
),
Err(error) if manifest_is_missing(&error) => {}
Err(error) => return Err(error).context("checking agent artifact tag"),
}
let config = Config::new(
b"{}".to_vec(),
"application/vnd.oci.empty.v1+json".to_string(),
None,
);
let mut annotations = BTreeMap::new();
annotations.insert(
ORG_OPENCONTAINERS_IMAGE_VERSION.to_string(),
version.to_string(),
);
let mut manifest =
OciImageManifest::build(std::slice::from_ref(&layer), &config, Some(annotations));
manifest.artifact_type = Some(AGENT_OCI_ARTIFACT_TYPE.to_string());
client
.push(&reference, &[layer], config, &auth, Some(manifest))
.await
.context("publishing agent OCI artifact")?;
let (_, digest) = client
.pull_manifest(&reference, &auth)
.await
.context("resolving published agent manifest")?;
Ok(format!(
"oci://{}/{}@{digest}",
reference.registry(),
reference.repository()
))
}
fn manifest_is_missing(error: &OciDistributionError) -> bool {
matches!(error, OciDistributionError::ImageManifestNotFoundError(_))
|| matches!(
error,
OciDistributionError::RegistryError { envelope, .. }
if envelope.errors.iter().any(|error| matches!(
&error.code,
OciErrorCode::ManifestUnknown
| OciErrorCode::NameUnknown
| OciErrorCode::NotFound
))
)
}

View File

@@ -0,0 +1,39 @@
use std::path::PathBuf;
use anyhow::{Context, bail};
use clap::Parser;
use harmony_app::{AppContext, Context as DeployContext, RegistryCredentials};
use crate::publish_agent_artifact;
#[derive(Parser, Debug)]
struct Args {
#[arg(long)]
binary: PathBuf,
#[arg(long)]
version: String,
#[arg(long)]
arch: String,
}
pub async fn release_agent_cli(context: DeployContext) -> anyhow::Result<()> {
harmony_cli::cli_logger::init();
let args = Args::parse();
if args.arch != "x86_64" && args.arch != "aarch64" {
bail!("arch must be x86_64 or aarch64");
}
let ctx = AppContext::resolve(&context, &args.version, None).await?;
if ctx.registry().is_none() {
bail!("agent release requires a remote context");
}
let credentials: RegistryCredentials = ctx
.config_client()
.get()
.await
.context("loading RegistryCredentials from OpenBao")?;
let reference = format!("{}-{}", ctx.image("harmony-fleet-agent"), args.arch);
let artifact =
publish_agent_artifact(&args.binary, &reference, &args.version, &credentials).await?;
println!("agent={artifact}");
Ok(())
}

View File

@@ -1,15 +1,10 @@
use std::collections::BTreeMap;
use std::path::PathBuf; use std::path::PathBuf;
use anyhow::{Context, bail}; use anyhow::Context;
use clap::{Parser, Subcommand}; use clap::{Parser, Subcommand};
use harmony_app::{PublicationTopology, publish::build_images}; use harmony_app::{PublicationTopology, publish::build_images};
use harmony_fleet_deploy::FleetApp; use harmony_fleet_deploy::{FleetApp, publish_agent_artifact};
use harmony_reconciler_contracts::upgrade::{AGENT_OCI_ARTIFACT_TYPE, AGENT_OCI_LAYER_MEDIA_TYPE};
use oci_client::annotations::ORG_OPENCONTAINERS_IMAGE_VERSION;
use oci_client::client::{Config, ImageLayer};
use oci_client::errors::{OciDistributionError, OciErrorCode}; use oci_client::errors::{OciDistributionError, OciErrorCode};
use oci_client::manifest::OciImageManifest;
use oci_client::secrets::RegistryAuth; use oci_client::secrets::RegistryAuth;
use oci_client::{Client, Reference}; use oci_client::{Client, Reference};
@@ -46,7 +41,13 @@ async fn main() -> anyhow::Result<()> {
binary, binary,
reference, reference,
version, version,
} => publish_agent(binary, &reference, &version).await, } => {
let artifact =
publish_agent_artifact(&binary, &reference, &version, &registry_credentials()?)
.await?;
println!("agent={artifact}");
Ok(())
}
} }
} }
@@ -81,7 +82,9 @@ async fn publish_control(version: &str, push: bool) -> anyhow::Result<()> {
return Ok(()); return Ok(());
} }
if !existing.is_empty() { if !existing.is_empty() {
bail!("control-plane release is incomplete; refusing to overwrite existing tags"); anyhow::bail!(
"control-plane release is incomplete; refusing to overwrite existing tags"
);
} }
} }
let refs = build_images(&images, &registry)?; let refs = build_images(&images, &registry)?;
@@ -97,76 +100,6 @@ async fn publish_control(version: &str, push: bool) -> anyhow::Result<()> {
Ok(()) Ok(())
} }
async fn publish_agent(binary: PathBuf, reference: &str, version: &str) -> anyhow::Result<()> {
let reference = Reference::try_from(reference).context("invalid agent OCI reference")?;
if reference.tag().is_none() || reference.digest().is_some() {
bail!("agent publication requires a tag reference");
}
let auth = registry_auth()?;
let client = Client::default();
let layer = ImageLayer::new(
std::fs::read(&binary)
.with_context(|| format!("reading agent binary {}", binary.display()))?,
AGENT_OCI_LAYER_MEDIA_TYPE.to_string(),
None,
);
match client.pull_manifest(&reference, &auth).await {
Ok((oci_client::manifest::OciManifest::Image(manifest), digest))
if manifest.artifact_type.as_deref() == Some(AGENT_OCI_ARTIFACT_TYPE)
&& manifest
.annotations
.as_ref()
.and_then(|annotations| annotations.get(ORG_OPENCONTAINERS_IMAGE_VERSION))
.map(String::as_str)
== Some(version)
&& matches!(manifest.layers.as_slice(), [existing]
if existing.media_type == AGENT_OCI_LAYER_MEDIA_TYPE
&& existing.digest == layer.sha256_digest()) =>
{
println!(
"agent=oci://{}/{}@{digest}",
reference.registry(),
reference.repository()
);
return Ok(());
}
Ok(_) => bail!(
"agent artifact tag exists with different content: {}",
reference.whole()
),
Err(error) if manifest_is_missing(&error) => {}
Err(error) => return Err(error).context("checking agent artifact tag"),
}
let config = Config::new(
b"{}".to_vec(),
"application/vnd.oci.empty.v1+json".to_string(),
None,
);
let mut annotations = BTreeMap::new();
annotations.insert(
ORG_OPENCONTAINERS_IMAGE_VERSION.to_string(),
version.to_string(),
);
let mut manifest =
OciImageManifest::build(std::slice::from_ref(&layer), &config, Some(annotations));
manifest.artifact_type = Some(AGENT_OCI_ARTIFACT_TYPE.to_string());
client
.push(&reference, &[layer], config, &auth, Some(manifest))
.await
.context("publishing agent OCI artifact")?;
let (_, digest) = client
.pull_manifest(&reference, &auth)
.await
.context("resolving published agent manifest")?;
println!(
"agent=oci://{}/{}@{digest}",
reference.registry(),
reference.repository()
);
Ok(())
}
fn registry_auth() -> anyhow::Result<RegistryAuth> { fn registry_auth() -> anyhow::Result<RegistryAuth> {
let credentials = registry_credentials()?; let credentials = registry_credentials()?;
Ok(RegistryAuth::Basic(credentials.username, credentials.token)) Ok(RegistryAuth::Basic(credentials.username, credentials.token))

View File

@@ -11,12 +11,16 @@
//! it does not own provider operations or a Fleet-wide aggregate Score. //! it does not own provider operations or a Fleet-wide aggregate Score.
pub mod agent; pub mod agent;
pub mod agent_artifact;
mod agent_release;
mod app; mod app;
mod deployment; mod deployment;
mod device_setup; mod device_setup;
pub mod operator; pub mod operator;
pub use agent::{FleetAgentScore, PodTarget}; pub use agent::{FleetAgentScore, PodTarget};
pub use agent_artifact::publish_agent_artifact;
pub use agent_release::release_agent_cli;
pub use app::{FleetApp, FleetCrdsApp, FleetTenantProvisionApp}; pub use app::{FleetApp, FleetCrdsApp, FleetTenantProvisionApp};
pub use deployment::FleetDeploymentScore; pub use deployment::FleetDeploymentScore;
pub use device_setup::{ pub use device_setup::{

View File

@@ -34,7 +34,12 @@ Other:
Environment equivalents: Environment equivalents:
CONTROL_VERSION, AGENT_VERSION, AGENT_ARCH, FLEET_DEPLOY_MANIFEST, CONTROL_VERSION, AGENT_VERSION, AGENT_ARCH, FLEET_DEPLOY_MANIFEST,
FLEET_DEPLOY_BIN, FLEET_CONTEXT, FLEET_NAMESPACE, KUBECTL_CONTEXT, FLEET_DEPLOY_BIN, FLEET_CONTEXT, FLEET_NAMESPACE, KUBECTL_CONTEXT,
REGISTRY_USER, REGISTRY_TOKEN, OPENBAO_TOKEN, RUST_LOG. HARMONY_ZITADEL_KEY_JSON, REGISTRY_USER, REGISTRY_TOKEN, OPENBAO_TOKEN,
RUST_LOG.
Agent publication uses HARMONY_ZITADEL_KEY_JSON → OpenBao RegistryCredentials
when --deploy-manifest is set (tenant crate must provide bin release-agent).
Otherwise REGISTRY_USER / REGISTRY_TOKEN. Device patch stays kubectl.
EOF EOF
} }
@@ -102,7 +107,11 @@ require_command sha256sum
require_command stat require_command stat
[[ -z "$control_version" ]] || require_command docker [[ -z "$control_version" ]] || require_command docker
if [[ -n "$control_version" || -n "$agent_version" ]]; then agent_via_openbao=0
if [[ -n "$agent_version" && -n "${HARMONY_ZITADEL_KEY_JSON:-}" && -n "$deploy_manifest" ]]; then
agent_via_openbao=1
fi
if [[ -n "$control_version" ]] || { [[ -n "$agent_version" ]] && ((agent_via_openbao == 0)); }; then
: "${REGISTRY_USER:?REGISTRY_USER is required for publication}" : "${REGISTRY_USER:?REGISTRY_USER is required for publication}"
: "${REGISTRY_TOKEN:?REGISTRY_TOKEN is required for publication}" : "${REGISTRY_TOKEN:?REGISTRY_TOKEN is required for publication}"
fi fi
@@ -145,11 +154,18 @@ callout_ref=""
if [[ -n "$agent_version" ]]; then if [[ -n "$agent_version" ]]; then
printf '==> Publishing %s\n' "$agent_tag_ref" printf '==> Publishing %s\n' "$agent_tag_ref"
agent_release_log="$tmp/agent-release.log" agent_release_log="$tmp/agent-release.log"
if ((agent_via_openbao == 1)); then
RUST_LOG="${RUST_LOG:-info}" \
cargo run --release --manifest-path "$deploy_manifest" --bin release-agent -- \
--binary "$agent_binary" --version "$agent_version" --arch "$agent_arch" \
| tee "$agent_release_log"
else
REGISTRY_USER="$REGISTRY_USER" REGISTRY_TOKEN="$REGISTRY_TOKEN" \ REGISTRY_USER="$REGISTRY_USER" REGISTRY_TOKEN="$REGISTRY_TOKEN" \
RUST_LOG="${RUST_LOG:-info}" \ RUST_LOG="${RUST_LOG:-info}" \
cargo run --release -p harmony-fleet-deploy --bin harmony-fleet-release -- \ cargo run --release -p harmony-fleet-deploy --bin harmony-fleet-release -- \
agent --binary "$agent_binary" --reference "$agent_tag_ref" \ agent --binary "$agent_binary" --reference "$agent_tag_ref" \
--version "$agent_version" | tee "$agent_release_log" --version "$agent_version" | tee "$agent_release_log"
fi
agent_oci_ref="$(awk -F= '$1 == "agent" { value=$2 } END { print value }' "$agent_release_log")" agent_oci_ref="$(awk -F= '$1 == "agent" { value=$2 } END { print value }' "$agent_release_log")"
[[ "$agent_oci_ref" =~ @sha256:[0-9a-f]{64}$ ]] || fail "agent digest missing from release output" [[ "$agent_oci_ref" =~ @sha256:[0-9a-f]{64}$ ]] || fail "agent digest missing from release output"
fi fi

View File

@@ -45,8 +45,8 @@ pub use kv::{
system_upgrade_status_key, system_upgrade_status_key,
}; };
pub use podman::{ pub use podman::{
DEVICE_PULL_SECRET_PATH, EnvVar, PodmanService, PodmanV0Score, ReconcileScore, RestartPolicy, EnvVar, PodmanService, PodmanV0Score, ReconcileScore, RestartPolicy, SecretEnvVar, VolumeMount,
SecretEnvVar, VolumeMount, validate_image_pull_secret_reference, validate_image_pull_secret_reference,
}; };
pub use status::{InventorySnapshot, Phase}; pub use status::{InventorySnapshot, Phase};
pub use system_upgrade::{ pub use system_upgrade::{

View File

@@ -2,8 +2,6 @@ use schemars::JsonSchema;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256}; use sha2::{Digest, Sha256};
pub const DEVICE_PULL_SECRET_PATH: &str = "registry/device-pull";
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
pub struct EnvVar { pub struct EnvVar {
pub name: String, pub name: String,

View File

@@ -4,6 +4,7 @@ edition = "2024"
version.workspace = true version.workspace = true
readme.workspace = true readme.workspace = true
license.workspace = true license.workspace = true
description = "Infrastructure orchestration: Score, Topology, Interpret. Source of truth for applying desired state."
[features] [features]
default = ["podman"] default = ["podman"]

View File

@@ -1,3 +1,9 @@
//! Infrastructure orchestration: Scores (desired state), Topologies (where),
//! Interpret (how). Source of truth for applying that model on a topology
//! (Kubernetes, host process, Fleet device, …).
//!
//! Not application delivery (`harmony_app`) and not a UI (`harmony_cli`).
mod domain; mod domain;
pub use domain::*; pub use domain::*;
pub mod infra; pub mod infra;

View File

@@ -6,11 +6,9 @@ use serde::Serialize;
use crate::{executors::ExecutorError, topology::Topology}; use crate::{executors::ExecutorError, topology::Topology};
/// **Deprecated** (see [`ApplicationScore`](super::ApplicationScore)). Superseded /// **Deprecated** (see [`ApplicationScore`](super::ApplicationScore)).
/// by the `harmony_app` application layer + `.with(...)` capabilities (ADR-026); /// Superseded by `HarmonyApp` composing Scores (ADR-029). The trait itself
/// see `docs/guides/application-capabilities.md`. The trait itself is not yet /// is not yet `#[deprecated]` to avoid warning every internal impl.
/// `#[deprecated]` to avoid warning every internal impl, but it should not be
/// used in new code.
/// ///
/// An ApplicationFeature provided by harmony, such as Backups, Monitoring, MultisiteAvailability, /// An ApplicationFeature provided by harmony, such as Backups, Monitoring, MultisiteAvailability,
/// ContinuousIntegration, ContinuousDelivery /// ContinuousIntegration, ContinuousDelivery

View File

@@ -36,12 +36,8 @@ use schemars::JsonSchema;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::sync::Arc; use std::sync::Arc;
/// **Deprecated.** Use `harmony_app::capabilities::Monitoring` via /// **Deprecated.** Compose monitoring Scores from a `HarmonyApp` (ADR-029).
/// `.with(Monitoring::new().alert(...))` (ADR-026). See #[deprecated(note = "Use harmony_app::HarmonyApp and compose Scores")]
/// `docs/guides/application-capabilities.md`.
#[deprecated(
note = "Use harmony_app::capabilities::Monitoring (.with(...)). See docs/guides/application-capabilities.md"
)]
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct Monitoring { pub struct Monitoring {
pub application: Arc<dyn Application>, pub application: Arc<dyn Application>,

View File

@@ -50,12 +50,8 @@ use crate::{
/// - Harbor as artifact registru /// - Harbor as artifact registru
/// - ArgoCD to install/upgrade/rollback/inspect k8s resources /// - ArgoCD to install/upgrade/rollback/inspect k8s resources
/// - Kubernetes for runtime orchestration /// - Kubernetes for runtime orchestration
/// **Deprecated.** Use the `harmony_app` application layer — `ComposeDeploy` /// **Deprecated.** Use `HarmonyApp` and compose Scores (ADR-029).
/// publishes + deploys, capabilities attach add-ons (ADR-026). See #[deprecated(note = "Use harmony_app::HarmonyApp and compose Scores")]
/// `docs/guides/application-capabilities.md`.
#[deprecated(
note = "Use harmony_app: ComposeDeploy + .with(...) capabilities. See docs/guides/application-capabilities.md"
)]
#[derive(Debug, Default, Clone)] #[derive(Debug, Default, Clone)]
pub struct PackagingDeployment<A: OCICompliant + HelmPackage> { pub struct PackagingDeployment<A: OCICompliant + HelmPackage> {
pub application: Arc<A>, pub application: Arc<A>,

View File

@@ -20,14 +20,8 @@ use crate::{score::Score, topology::Topology};
use super::{Application, ApplicationFeature, ApplicationInterpret, HelmPackage, OCICompliant}; use super::{Application, ApplicationFeature, ApplicationInterpret, HelmPackage, OCICompliant};
/// **Deprecated.** Use the `harmony_app` application layer — /// **Deprecated.** Use `HarmonyApp` and compose Scores (ADR-029).
/// `ComposeDeploy`/`HarmonyApp` + `.with(...)` capabilities (ADR-026). See #[deprecated(note = "Use harmony_app::HarmonyApp and compose Scores")]
/// `docs/guides/application-capabilities.md`. The feature-menu model couples
/// delivery to a fixed feature set and the cancelled ArgoCD path and does not
/// compose with plain Scores.
#[deprecated(
note = "Use harmony_app: ComposeDeploy/HarmonyApp + .with(...) capabilities (ADR-026). See docs/guides/application-capabilities.md"
)]
#[derive(Debug, Serialize, Clone)] #[derive(Debug, Serialize, Clone)]
pub struct ApplicationScore<A: Application + Serialize, T: Topology + Clone + Serialize> pub struct ApplicationScore<A: Application + Serialize, T: Topology + Clone + Serialize>
where where

View File

@@ -0,0 +1,360 @@
//! Run a process on the machine that interprets the Score.
//!
//! Idempotent: same name + spec while the pid is alive → [`Outcome::noop`].
use std::fs;
use std::net::TcpStream;
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use std::time::{Duration, Instant};
use async_trait::async_trait;
use harmony_types::id::Id;
use serde::{Deserialize, Serialize};
use crate::data::Version;
use crate::domain::config::HARMONY_DATA_DIR;
use crate::interpret::{Interpret, InterpretError, InterpretName, InterpretStatus, Outcome};
use crate::inventory::Inventory;
use crate::score::Score;
use crate::topology::{K8sclient, Topology};
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub enum ProcessEnv {
Literal(String),
/// Read `{secret}` `{key}` after other Scores have created it.
/// If `node_port_service` is set, rewrite the URI host to 127.0.0.1 and
/// the port to that Service's nodePort (host processes talking to CNPG).
SecretUri {
namespace: String,
secret: String,
key: String,
node_port_service: Option<(String, String)>,
},
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct HostProcessScore {
pub name: String,
pub program: String,
pub args: Vec<String>,
pub cwd: Option<String>,
pub env: Vec<(String, ProcessEnv)>,
pub port: Option<u16>,
}
impl HostProcessScore {
pub fn new(name: impl Into<String>, program: impl Into<String>) -> Self {
Self {
name: name.into(),
program: program.into(),
args: Vec::new(),
cwd: None,
env: Vec::new(),
port: None,
}
}
pub fn arg(mut self, arg: impl Into<String>) -> Self {
self.args.push(arg.into());
self
}
pub fn cwd(mut self, cwd: impl Into<String>) -> Self {
self.cwd = Some(cwd.into());
self
}
pub fn env_lit(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
self.env
.push((key.into(), ProcessEnv::Literal(value.into())));
self
}
pub fn env_postgres_uri(
mut self,
key: impl Into<String>,
namespace: impl Into<String>,
cluster: impl Into<String>,
) -> Self {
let namespace = namespace.into();
let cluster = cluster.into();
self.env.push((
key.into(),
ProcessEnv::SecretUri {
node_port_service: Some((namespace.clone(), format!("{cluster}-rw-debug"))),
secret: format!("{cluster}-app"),
key: "uri".into(),
namespace,
},
));
self
}
pub fn port(mut self, port: u16) -> Self {
self.port = Some(port);
self
}
fn state_dir(&self) -> PathBuf {
HARMONY_DATA_DIR.join("host-process").join(&self.name)
}
}
impl<T: Topology + K8sclient + 'static> Score<T> for HostProcessScore {
fn create_interpret(&self) -> Box<dyn Interpret<T>> {
Box::new(HostProcessInterpret {
score: self.clone(),
})
}
fn name(&self) -> String {
format!("HostProcessScore({})", self.name)
}
}
#[derive(Debug)]
struct HostProcessInterpret {
score: HostProcessScore,
}
#[derive(Serialize, Deserialize)]
struct StateFile {
pid: u32,
spec: String,
}
#[async_trait]
impl<T: Topology + K8sclient> Interpret<T> for HostProcessInterpret {
async fn execute(
&self,
_inventory: &Inventory,
topology: &T,
) -> Result<Outcome, InterpretError> {
let dir = self.score.state_dir();
fs::create_dir_all(&dir).map_err(|e| InterpretError::new(e.to_string()))?;
let spec =
serde_json::to_string(&self.score).map_err(|e| InterpretError::new(e.to_string()))?;
let state_path = dir.join("state.json");
if let Some(state) = read_state(&state_path) {
if state.spec == spec && pid_alive(state.pid) && port_ready(self.score.port) {
return Ok(Outcome::noop(format!(
"host process '{}' already running (pid {})",
self.score.name, state.pid
)));
}
if pid_alive(state.pid) {
let _ = Command::new("kill").arg(state.pid.to_string()).status();
std::thread::sleep(Duration::from_millis(100));
}
}
if let Some(port) = self.score.port
&& port_ready(Some(port))
{
return Err(InterpretError::new(format!(
"127.0.0.1:{port} is already in use"
)));
}
let env = resolve_env(&self.score.env, topology).await?;
let program = resolve_program(&self.score.program)?;
let mut cmd = Command::new(&program);
cmd.args(&self.score.args)
.stdin(Stdio::null())
.stdout(fs::File::create(dir.join("stdout.log")).map_err(io)?)
.stderr(fs::File::create(dir.join("stderr.log")).map_err(io)?);
if let Some(cwd) = &self.score.cwd {
cmd.current_dir(cwd);
}
for (k, v) in &env {
cmd.env(k, v);
}
let child = cmd
.spawn()
.map_err(|e| InterpretError::new(format!("spawn {}: {e}", program.display())))?;
let pid = child.id();
fs::write(
&state_path,
serde_json::to_vec(&StateFile {
pid,
spec: spec.clone(),
})
.map_err(|e| InterpretError::new(e.to_string()))?,
)
.map_err(io)?;
if let Some(port) = self.score.port {
if let Err(e) = wait_port(port, Duration::from_secs(15)) {
let stderr = fs::read_to_string(dir.join("stderr.log")).unwrap_or_default();
return Err(InterpretError::new(format!(
"{e}; process alive={} stderr={stderr}",
pid_alive(pid)
)));
}
} else if !pid_alive(pid) {
let stderr = fs::read_to_string(dir.join("stderr.log")).unwrap_or_default();
return Err(InterpretError::new(format!(
"host process '{}' exited immediately: {stderr}",
self.score.name
)));
}
Ok(Outcome::success(format!(
"host process '{}' started (pid {pid})",
self.score.name
)))
}
fn get_name(&self) -> InterpretName {
InterpretName::Custom("HostProcess")
}
fn get_version(&self) -> Version {
Version::from("0.1.0").unwrap()
}
fn get_status(&self) -> InterpretStatus {
InterpretStatus::QUEUED
}
fn get_children(&self) -> Vec<Id> {
vec![]
}
}
fn io(e: std::io::Error) -> InterpretError {
InterpretError::new(e.to_string())
}
fn resolve_program(program: &str) -> Result<PathBuf, InterpretError> {
let p = PathBuf::from(program);
if p.exists() {
return Ok(p);
}
if let Ok(exe) = std::env::current_exe()
&& let Some(dir) = exe.parent()
{
let sibling = dir.join(program);
if sibling.exists() {
return Ok(sibling);
}
}
Ok(p)
}
fn read_state(path: &Path) -> Option<StateFile> {
serde_json::from_slice(&fs::read(path).ok()?).ok()
}
fn pid_alive(pid: u32) -> bool {
Command::new("kill")
.args(["-0", &pid.to_string()])
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.map(|s| s.success())
.unwrap_or(false)
}
fn port_ready(port: Option<u16>) -> bool {
match port {
None => true,
Some(p) => TcpStream::connect(("127.0.0.1", p)).is_ok(),
}
}
fn wait_port(port: u16, timeout: Duration) -> Result<(), InterpretError> {
let start = Instant::now();
while start.elapsed() < timeout {
if TcpStream::connect(("127.0.0.1", port)).is_ok() {
return Ok(());
}
std::thread::sleep(Duration::from_millis(50));
}
Err(InterpretError::new(format!(
"timed out waiting for 127.0.0.1:{port}"
)))
}
async fn resolve_env<T: Topology + K8sclient>(
env: &[(String, ProcessEnv)],
topology: &T,
) -> Result<Vec<(String, String)>, InterpretError> {
let mut out = Vec::new();
for (k, v) in env {
match v {
ProcessEnv::Literal(s) => out.push((k.clone(), s.clone())),
ProcessEnv::SecretUri {
namespace,
secret,
key,
node_port_service,
} => {
let client = topology
.k8s_client()
.await
.map_err(|e| InterpretError::new(format!("k8s client: {e}")))?;
let sec = client
.get_resource::<k8s_openapi::api::core::v1::Secret>(secret, Some(namespace))
.await
.map_err(|e| InterpretError::new(format!("get secret {secret}: {e}")))?
.ok_or_else(|| InterpretError::new(format!("secret {secret} missing")))?;
let bytes =
sec.data.as_ref().and_then(|d| d.get(key)).ok_or_else(|| {
InterpretError::new(format!("secret {secret} missing {key}"))
})?;
let mut uri = String::from_utf8(bytes.0.clone())
.map_err(|e| InterpretError::new(e.to_string()))?;
if let Some((svc_ns, svc_name)) = node_port_service {
let svc = client
.get_resource::<k8s_openapi::api::core::v1::Service>(svc_name, Some(svc_ns))
.await
.map_err(|e| InterpretError::new(format!("get service {svc_name}: {e}")))?
.ok_or_else(|| {
InterpretError::new(format!("service {svc_name} missing"))
})?;
let node_port = svc
.spec
.as_ref()
.and_then(|s| s.ports.as_ref())
.and_then(|p| p.first())
.and_then(|p| p.node_port)
.ok_or_else(|| {
InterpretError::new(format!("{svc_name} has no nodePort"))
})?;
uri = rewrite_uri_host_port(&uri, "127.0.0.1", node_port);
}
out.push((k.clone(), uri));
}
}
}
Ok(out)
}
fn rewrite_uri_host_port(uri: &str, host: &str, port: i32) -> String {
// postgres://user:pass@oldhost:5432/db → postgres://user:pass@host:port/db
if let Some(at) = uri.rfind('@') {
let (head, rest) = uri.split_at(at + 1);
if let Some(slash) = rest.find('/') {
format!("{head}{host}:{port}{}", &rest[slash..])
} else {
format!("{head}{host}:{port}")
}
} else {
uri.to_string()
}
}
#[cfg(test)]
mod tests {
use super::rewrite_uri_host_port;
#[test]
fn rewrites_cnpg_uri() {
let out = rewrite_uri_host_port(
"postgresql://app:secret@notes-db-rw.notes.svc:5432/app",
"127.0.0.1",
32001,
);
assert_eq!(out, "postgresql://app:secret@127.0.0.1:32001/app");
}
}

View File

@@ -8,6 +8,7 @@ pub mod dummy;
pub mod fleet; pub mod fleet;
pub mod github_runner; pub mod github_runner;
pub mod helm; pub mod helm;
pub mod host_process;
pub mod http; pub mod http;
pub mod inventory; pub mod inventory;
pub mod k3d; pub mod k3d;

View File

@@ -33,6 +33,9 @@ use crate::topology::{K8sclient, Topology};
pub struct PostgresDebugRouteScore { pub struct PostgresDebugRouteScore {
pub namespace: String, pub namespace: String,
pub cluster_name: String, pub cluster_name: String,
/// Create as NodePort when the Service does not exist yet (host processes).
#[serde(default)]
pub create_as_node_port: bool,
} }
impl PostgresDebugRouteScore { impl PostgresDebugRouteScore {
@@ -40,9 +43,15 @@ impl PostgresDebugRouteScore {
Self { Self {
namespace: namespace.into(), namespace: namespace.into(),
cluster_name: cluster_name.into(), cluster_name: cluster_name.into(),
create_as_node_port: false,
} }
} }
pub fn node_port(mut self) -> Self {
self.create_as_node_port = true;
self
}
fn service_name(&self) -> String { fn service_name(&self) -> String {
format!("{}-rw-debug", self.cluster_name) format!("{}-rw-debug", self.cluster_name)
} }
@@ -111,6 +120,7 @@ impl<T: Topology + K8sclient> Interpret<T> for PostgresDebugRouteInterpret {
.and_then(|p| p.node_port); .and_then(|p| p.node_port);
(t, np) (t, np)
} }
Ok(None) if self.score.create_as_node_port => ("NodePort".into(), None),
Ok(None) => ("ClusterIP".into(), None), Ok(None) => ("ClusterIP".into(), None),
Err(e) => { Err(e) => {
return Err(InterpretError::new(format!( return Err(InterpretError::new(format!(

View File

@@ -1,10 +1,9 @@
//! A reusable Score that materializes a `kubernetes.io/dockerconfigjson` Secret //! A reusable Score that materializes a `kubernetes.io/dockerconfigjson` Secret
//! so the kubelet can pull an app's images from a private registry. //! so the kubelet can pull an app's images from a private registry.
//! //!
//! The Secret is referenced by name from each pod's `imagePullSecrets` (see //! The Secret is referenced by name from each pod's `imagePullSecrets`. Keep
//! `harmony_app`'s `DeployConfig::image_pull_secrets`). Keep the credentials //! the credentials **pull-only** and load them from a vault (e.g. OpenBao
//! **pull-only** and load them from a vault (e.g. OpenBao `DeploySecrets`) — they //! `DeploySecrets`) — they never belong in code. The Secret is namespaced, so a
//! never belong in code or chart values. The Secret is namespaced, so a
//! namespace-scoped deployer can apply it without any cluster RBAC. //! namespace-scoped deployer can apply it without any cluster RBAC.
//! //!
//! ```ignore //! ```ignore
@@ -15,7 +14,7 @@
//! username: pull_user, //! username: pull_user,
//! token: pull_token, //! token: pull_token,
//! }; //! };
//! // add `pull` to scores(), and set deploy.image_pull_secrets = vec!["registry-pull"] //! // add `pull` to scores(); set imagePullSecrets on the workload Score
//! ``` //! ```
use std::collections::BTreeMap; use std::collections::BTreeMap;

View File

@@ -2,8 +2,9 @@
name = "harmony_app" name = "harmony_app"
edition = "2024" edition = "2024"
version.workspace = true version.workspace = true
readme.workspace = true readme = "README.md"
license.workspace = true license.workspace = true
description = "Application delivery: HarmonyApp composes Scores; ship/deploy interpret them. Not infrastructure orchestration."
[dependencies] [dependencies]
anyhow.workspace = true anyhow.workspace = true
@@ -19,8 +20,5 @@ serde_yaml = { workspace = true }
schemars = "0.8" schemars = "0.8"
tempfile.workspace = true tempfile.workspace = true
log.workspace = true log.workspace = true
reqwest.workspace = true
k8s-openapi.workspace = true k8s-openapi.workspace = true
thiserror.workspace = true thiserror.workspace = true
docker-compose-types = "0.24"
fqdn = "0.5.2"

64
harmony_app/README.md Normal file
View File

@@ -0,0 +1,64 @@
# harmony_app
Application delivery. A [`HarmonyApp`](src/app.rs) composes **Scores**; `ship` /
`deploy` / `status` / `logs` are glue that interpret them. This crate does **not**
orchestrate infrastructure — that is [`harmony`](../harmony).
## Architecture
| Crate | Job |
|---|---|
| **`harmony_cli`** | UI only. Parse argv, require `--context`, call verbs, render. Safe types and DX (digest-shaped flags, `--json`). No Scores, no image policy, no cluster mutation. |
| **`harmony_app`** | Application delivery: identity, image build/publish, context catalog, `HarmonyApp` → Scores, ship/deploy as interpret glue. Authoring DX in [`dx`](src/dx.rs) (one component, one file, one type). |
| **`harmony`** | Source of truth for infrastructure orchestration. Scores (desired state), Topologies (where), Interpret (how). Deploying Kubernetes resources, running containers, building the deployment model and applying it on a topology (k8s or otherwise). |
| **`harmony_config`** | Load, save, discover settings and secrets. Schema is Rust; state is a store. Default store is OpenBao (CNCF). |
Identity is one SSO (`https://sso.nationtech.io`). That identity is what may
read the tenant's config and environment in OpenBao, the OKD cluster, Harbor,
and later more.
**SSO is still WIP for Harbor and OKD.** Access secrets for those live in
OpenBao, which *is* SSO. Current flow:
1. Load one secret: `HARMONY_ZITADEL_KEY_JSON` (or `HARMONY_ZITADEL_KEY_PATH`).
2. Authenticate to OpenBao (JWT-bearer against Zitadel).
3. Load Harbor and kubeconfig from OpenBao.
4. Authenticate to Harbor and Kubernetes with those secrets.
Interactive humans use device-code OIDC to the same OpenBao instead of a
machine key. Same store, different first hop.
`--context` is required (or `HARMONY_CONTEXT`). There is no default.
`K8sAnywhereTopology` on `app_main` / `AppContext::topology()` is the
**control plane** (kube apply — including Fleet CRs). It is not a claim that
the workload runs on Kubernetes.
## Crate map
**UI:** `harmony_cli`, `harmony_tui`, `harmony_auth_cli`, `harmony_auth_ui`
**Application delivery:** `harmony_app`
**Orchestration:** `harmony`, `harmony-k8s`, `harmony-reconciler-contracts`,
`harmony_execution`, `k3d`, `fleet/*`
**Config & secrets:** `harmony_config`, `harmony_config_derive`, `harmony_secret`,
`harmony_secret_derive`
**Identity:** `harmony_zitadel_auth`, `harmony_zitadel_jwt`, `harmony_auth`
**Shared types:** `harmony_types`, `harmony_macros`
Infra-specific crates (OPNsense, NATS, agents, Brocade, …) live under `harmony`
modules or their own crates; they are Scores/topologies, not app delivery.
## Remaining leaks (honest)
- `status` / `logs` list Kubernetes Deployments/Pods via the control-plane
client. Operational verbs are allowed to read the cluster (ADR-026); they are
not yet Score-status.
- `harmony_cli::run` is a second frontend: a generic Score runner over Maestro.
Still UI. Not application delivery.
- `dx::Secret` is a stub. Secrets today are OpenBao-backed config structs, not
a `Secret<T>` field type.

View File

@@ -74,6 +74,21 @@ pub trait HarmonyApp<T: Topology>: Send + Sync {
} }
} }
pub fn build<T: Topology>(
app: &dyn HarmonyApp<T>,
ctx: &AppContext,
) -> Result<ImageRefs, AppError> {
app.build(ctx)
}
pub async fn publish<T: Topology>(
app: &dyn HarmonyApp<T>,
ctx: &AppContext,
images: &ImageRefs,
) -> Result<ImageRefs, AppError> {
app.publish(ctx, images).await
}
// ---- structured results (rendered by the front-end, never printed here) ---- // ---- structured results (rendered by the front-end, never printed here) ----
#[derive(Debug, Clone, serde::Serialize)] #[derive(Debug, Clone, serde::Serialize)]

File diff suppressed because it is too large Load Diff

View File

@@ -1,20 +0,0 @@
//! Topology-neutral application declarations. K8sAnywhere is the first adapter;
//! declarations are not deployable until an adapter exists for the target topology.
mod k8s_anywhere;
mod model;
mod resources;
mod validation;
pub use harmony::modules::zitadel::contract as zitadel;
pub use model::{
Application, Command, Cpu, FileRef, HealthCheck, Image, ImageBuild, ImageRef, ImageSource,
LogicalEndpoint, ManagedTls, Memory, Port, PortRef, Protocol, PublicEndpointRef,
ReadinessIntent, ResourceIntent, RolloutIntent, RolloutStrategy, Route, Service, ServiceRef,
ValueRef,
};
pub use resources::{
BucketRef, DatabaseRef, ManagedBucket, ManagedPostgres, ManagedResource, ManagedZitadel,
OidcRedirect, ZitadelRef,
};
pub use validation::ApplicationValidationError;

View File

@@ -1,562 +0,0 @@
use std::path::PathBuf;
use std::time::Duration;
use serde::Serialize;
use super::{ApplicationValidationError, BucketRef, DatabaseRef, ManagedResource, ZitadelRef};
use harmony::modules::zitadel::{ZitadelApplicationRef, ZitadelMachineRef, ZitadelProjectRef};
/// A topology-neutral application declaration. K8sAnywhere is currently its first adapter.
#[derive(Debug, Clone, Serialize)]
pub struct Application {
pub name: String,
pub images: Vec<Image>,
pub endpoints: Vec<LogicalEndpoint>,
pub resources: Vec<ManagedResource>,
pub services: Vec<Service>,
/// Routes are evaluated in declaration order.
pub routes: Vec<Route>,
pub rollout: RolloutIntent,
}
impl Application {
pub fn new(name: impl Into<String>) -> Self {
Self {
name: name.into(),
images: Vec::new(),
endpoints: Vec::new(),
resources: Vec::new(),
services: Vec::new(),
routes: Vec::new(),
rollout: RolloutIntent::default(),
}
}
pub fn image(mut self, image: Image) -> Self {
self.images.push(image);
self
}
pub fn service(mut self, service: Service) -> Self {
self.services.push(service);
self
}
pub fn endpoint(mut self, endpoint: LogicalEndpoint) -> Self {
self.endpoints.push(endpoint);
self
}
pub fn resource(mut self, resource: impl Into<ManagedResource>) -> Self {
self.resources.push(resource.into());
self
}
pub fn route(mut self, route: Route) -> Self {
self.routes.push(route);
self
}
pub fn rollout(mut self, rollout: RolloutIntent) -> Self {
self.rollout = rollout;
self
}
pub fn validate(&self) -> Result<(), ApplicationValidationError> {
super::validation::validate(self)
}
}
#[derive(Debug, Clone, Serialize)]
pub struct Image {
pub name: String,
pub source: ImageSource,
}
impl Image {
pub fn new(name: impl Into<String>, reference: impl Into<String>) -> Self {
Self {
name: name.into(),
source: ImageSource::Reference(reference.into()),
}
}
pub fn reference(&self) -> ImageRef {
ImageRef(self.name.clone())
}
pub fn build(name: impl Into<String>, context: impl Into<PathBuf>) -> Self {
Self {
name: name.into(),
source: ImageSource::Build(ImageBuild {
context: context.into(),
dockerfile: PathBuf::from("Dockerfile"),
platform: None,
build_args: Vec::new(),
}),
}
}
pub fn dockerfile(mut self, dockerfile: impl Into<PathBuf>) -> Self {
if let ImageSource::Build(build) = &mut self.source {
build.dockerfile = dockerfile.into();
}
self
}
pub fn platform(mut self, platform: impl Into<String>) -> Self {
if let ImageSource::Build(build) = &mut self.source {
build.platform = Some(platform.into());
}
self
}
pub fn build_arg(mut self, name: impl Into<String>, value: Option<impl Into<String>>) -> Self {
if let ImageSource::Build(build) = &mut self.source {
build.build_args.push((name.into(), value.map(Into::into)));
}
self
}
}
#[derive(Debug, Clone, Serialize)]
pub enum ImageSource {
Reference(String),
Build(ImageBuild),
}
#[derive(Debug, Clone, Serialize)]
pub struct ImageBuild {
pub context: PathBuf,
pub dockerfile: PathBuf,
pub platform: Option<String>,
pub build_args: Vec<(String, Option<String>)>,
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize)]
pub struct ImageRef(pub(crate) String);
impl ImageRef {
pub fn new(name: impl Into<String>) -> Self {
Self(name.into())
}
pub fn name(&self) -> &str {
&self.0
}
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize)]
pub struct ServiceRef(pub(crate) String);
impl ServiceRef {
pub fn new(name: impl Into<String>) -> Self {
Self(name.into())
}
pub fn name(&self) -> &str {
&self.0
}
pub fn port(&self, name: impl Into<String>) -> PortRef {
PortRef {
service: self.clone(),
port: name.into(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize)]
pub struct PortRef {
pub(crate) service: ServiceRef,
pub(crate) port: String,
}
impl PortRef {
pub fn new(service: ServiceRef, port: impl Into<String>) -> Self {
Self {
service,
port: port.into(),
}
}
pub fn service(&self) -> &ServiceRef {
&self.service
}
pub fn name(&self) -> &str {
&self.port
}
}
#[derive(Debug, Clone, Serialize)]
pub struct Service {
pub name: String,
pub image: ImageRef,
pub command: Option<Command>,
pub ports: Vec<Port>,
pub values: Vec<(String, ValueRef)>,
pub health: Option<HealthCheck>,
pub resources: ResourceIntent,
}
impl Service {
pub fn new(name: impl Into<String>, image: ImageRef) -> Self {
Self {
name: name.into(),
image,
command: None,
ports: Vec::new(),
values: Vec::new(),
health: None,
resources: ResourceIntent::default(),
}
}
pub fn reference(&self) -> ServiceRef {
ServiceRef(self.name.clone())
}
pub fn command(mut self, command: Command) -> Self {
self.command = Some(command);
self
}
pub fn port(mut self, port: Port) -> Self {
self.ports.push(port);
self
}
pub fn value(mut self, name: impl Into<String>, value: ValueRef) -> Self {
self.values.push((name.into(), value));
self
}
pub fn health(mut self, health: HealthCheck) -> Self {
self.health = Some(health);
self
}
pub fn resources(mut self, resources: ResourceIntent) -> Self {
self.resources = resources;
self
}
}
#[derive(Debug, Clone, Serialize)]
pub struct Command {
pub program: String,
pub args: Vec<String>,
}
impl Command {
pub fn new(
program: impl Into<String>,
args: impl IntoIterator<Item = impl Into<String>>,
) -> Self {
Self {
program: program.into(),
args: args.into_iter().map(Into::into).collect(),
}
}
}
#[derive(Debug, Clone, Serialize)]
pub struct Port {
pub name: String,
pub number: u16,
pub protocol: Protocol,
}
impl Port {
pub fn tcp(name: impl Into<String>, number: u16) -> Self {
Self {
name: name.into(),
number,
protocol: Protocol::Tcp,
}
}
pub fn udp(name: impl Into<String>, number: u16) -> Self {
Self {
name: name.into(),
number,
protocol: Protocol::Udp,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
pub enum Protocol {
Tcp,
Udp,
}
/// A value is either literal or resolved from desired-state semantics.
#[derive(Debug, Clone, Serialize)]
pub enum ValueRef {
Literal(String),
ServiceHost(ServiceRef),
ServicePort(PortRef),
ServiceUrl {
scheme: String,
port: PortRef,
},
PublicEndpointOrigin(PublicEndpointRef),
PublicEndpointUrl {
endpoint: PublicEndpointRef,
path: String,
},
DatabaseJdbcUrl(DatabaseRef),
DatabaseUsername(DatabaseRef),
DatabasePassword(DatabaseRef),
BucketEndpoint(BucketRef),
BucketName(BucketRef),
BucketAccessKey(BucketRef),
BucketSecretKey(BucketRef),
BucketRegion(BucketRef),
BucketPathStyle(BucketRef),
ZitadelIssuer(ZitadelRef),
ZitadelManagementUrl(ZitadelRef),
OidcProjectId {
zitadel: ZitadelRef,
project: ZitadelProjectRef,
},
OidcClientId {
zitadel: ZitadelRef,
application: ZitadelApplicationRef,
},
MachineClientId {
zitadel: ZitadelRef,
machine: ZitadelMachineRef,
},
MachineClientSecret {
zitadel: ZitadelRef,
machine: ZitadelMachineRef,
},
/// Mount the secret key at `path` and set the value to that path.
File(FileRef),
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub enum FileRef {
MachineJsonKey {
zitadel: ZitadelRef,
machine: ZitadelMachineRef,
path: String,
},
}
impl FileRef {
pub fn path(&self) -> &str {
match self {
Self::MachineJsonKey { path, .. } => path,
}
}
}
impl ValueRef {
pub fn literal(value: impl Into<String>) -> Self {
Self::Literal(value.into())
}
pub fn service_url(scheme: impl Into<String>, port: PortRef) -> Self {
Self::ServiceUrl {
scheme: scheme.into(),
port,
}
}
}
#[derive(Debug, Clone, Serialize)]
pub enum HealthCheck {
Http {
port: PortRef,
path: String,
interval: Duration,
timeout: Duration,
initial_delay: Duration,
},
Tcp {
port: PortRef,
interval: Duration,
timeout: Duration,
initial_delay: Duration,
},
}
impl HealthCheck {
pub fn http(port: PortRef, path: impl Into<String>) -> Self {
Self::Http {
port,
path: path.into(),
interval: Duration::from_secs(10),
timeout: Duration::from_secs(2),
initial_delay: Duration::from_secs(0),
}
}
pub fn tcp(port: PortRef) -> Self {
Self::Tcp {
port,
interval: Duration::from_secs(10),
timeout: Duration::from_secs(2),
initial_delay: Duration::from_secs(0),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
pub enum Cpu {
Millicores(u32),
Cores(u16),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
pub enum Memory {
Mebibytes(u32),
Gibibytes(u32),
}
/// Portable scheduler intent with typed CPU and memory units.
#[derive(Debug, Clone, Default, Serialize)]
pub struct ResourceIntent {
pub cpu_request: Option<Cpu>,
pub cpu_limit: Option<Cpu>,
pub memory_request: Option<Memory>,
pub memory_limit: Option<Memory>,
}
#[derive(Debug, Clone, Serialize)]
pub struct Route {
pub endpoint: PublicEndpointRef,
pub path: String,
pub target: PortRef,
pub smoke_check: bool,
}
impl Route {
pub fn new(endpoint: PublicEndpointRef, path: impl Into<String>, target: PortRef) -> Self {
Self {
endpoint,
path: path.into(),
target,
smoke_check: true,
}
}
pub fn smoke_check(mut self, enabled: bool) -> Self {
self.smoke_check = enabled;
self
}
}
#[derive(Debug, Clone, Serialize)]
pub struct LogicalEndpoint {
pub name: String,
pub tls: ManagedTls,
}
impl LogicalEndpoint {
pub fn new(name: impl Into<String>) -> Self {
Self {
name: name.into(),
tls: ManagedTls::Disabled,
}
}
pub fn managed_tls(mut self) -> Self {
self.tls = ManagedTls::Managed;
self
}
pub fn reference(&self) -> PublicEndpointRef {
PublicEndpointRef(self.name.clone())
}
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize)]
pub struct PublicEndpointRef(pub(crate) String);
impl PublicEndpointRef {
pub fn new(name: impl Into<String>) -> Self {
Self(name.into())
}
pub fn name(&self) -> &str {
&self.0
}
pub fn origin(&self) -> ValueRef {
ValueRef::PublicEndpointOrigin(self.clone())
}
pub fn url(&self, path: impl Into<String>) -> ValueRef {
ValueRef::PublicEndpointUrl {
endpoint: self.clone(),
path: path.into(),
}
}
}
impl From<super::ManagedPostgres> for ManagedResource {
fn from(value: super::ManagedPostgres) -> Self {
Self::Postgres(value)
}
}
impl From<super::ManagedBucket> for ManagedResource {
fn from(value: super::ManagedBucket) -> Self {
Self::Bucket(value)
}
}
impl From<super::ManagedZitadel> for ManagedResource {
fn from(value: super::ManagedZitadel) -> Self {
Self::Zitadel(value)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
pub enum ManagedTls {
Disabled,
Managed,
}
#[derive(Debug, Clone, Serialize)]
pub struct RolloutIntent {
pub replicas: u32,
pub strategy: RolloutStrategy,
pub readiness: ReadinessIntent,
}
impl Default for RolloutIntent {
fn default() -> Self {
Self {
replicas: 1,
strategy: RolloutStrategy::Rolling,
readiness: ReadinessIntent::default(),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
pub enum RolloutStrategy {
Rolling,
Replace,
}
#[derive(Debug, Clone, Serialize)]
pub struct ReadinessIntent {
pub wait: bool,
pub timeout: Duration,
}
impl Default for ReadinessIntent {
fn default() -> Self {
Self {
wait: true,
timeout: Duration::from_secs(180),
}
}
}

View File

@@ -1,266 +0,0 @@
use serde::Serialize;
use harmony::modules::zitadel::{
ZitadelApplicationRef, ZitadelContract, ZitadelMachineRef, ZitadelProjectRef,
};
use super::{FileRef, PublicEndpointRef, ValueRef};
#[derive(Debug, Clone, Serialize)]
pub enum ManagedResource {
Postgres(ManagedPostgres),
Bucket(ManagedBucket),
Zitadel(ManagedZitadel),
}
/// S3-compatible object bucket (Rook ObjectBucketClaim / ceph-bucket by default).
#[derive(Debug, Clone, Serialize)]
pub struct ManagedBucket {
pub name: String,
pub storage_class: String,
/// Rook `additionalConfig.maxSize` (e.g. `"10G"`).
pub max_size: String,
/// Public/browser endpoint override (e.g. context `object_storage_endpoint`).
/// When set, app credentials use this URL instead of cluster-internal RGW DNS.
pub endpoint: Option<String>,
/// Public app endpoints whose origins are allowed by bucket CORS.
pub cors: Vec<PublicEndpointRef>,
}
impl ManagedBucket {
pub fn new(name: impl Into<String>) -> Self {
Self {
name: name.into(),
storage_class: "ceph-bucket".into(),
max_size: "10G".into(),
endpoint: None,
cors: Vec::new(),
}
}
pub fn storage_class(mut self, storage_class: impl Into<String>) -> Self {
self.storage_class = storage_class.into();
self
}
pub fn max_size(mut self, max_size: impl Into<String>) -> Self {
self.max_size = max_size.into();
self
}
pub fn endpoint(mut self, endpoint: impl Into<String>) -> Self {
self.endpoint = Some(endpoint.into());
self
}
pub fn cors(mut self, endpoint: PublicEndpointRef) -> Self {
self.cors.push(endpoint);
self
}
pub fn reference(&self) -> BucketRef {
BucketRef(self.name.clone())
}
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize)]
pub struct BucketRef(pub(crate) String);
impl BucketRef {
pub fn new(name: impl Into<String>) -> Self {
Self(name.into())
}
pub fn name(&self) -> &str {
&self.0
}
pub fn endpoint(&self) -> ValueRef {
ValueRef::BucketEndpoint(self.clone())
}
pub fn bucket(&self) -> ValueRef {
ValueRef::BucketName(self.clone())
}
pub fn access_key(&self) -> ValueRef {
ValueRef::BucketAccessKey(self.clone())
}
pub fn secret_key(&self) -> ValueRef {
ValueRef::BucketSecretKey(self.clone())
}
pub fn region(&self) -> ValueRef {
ValueRef::BucketRegion(self.clone())
}
pub fn path_style(&self) -> ValueRef {
ValueRef::BucketPathStyle(self.clone())
}
}
#[derive(Debug, Clone, Serialize)]
pub struct ManagedPostgres {
pub name: String,
pub instances: u32,
pub version: Option<String>,
/// Companion Service `{name}-rw-debug` for VPN/debug (NodePort toggle).
/// Default **off** (`ClusterIP`). In the OKD console set `spec.type: NodePort` to enable.
/// Ships preserve live type/nodePort. (Not a TLS Route: PG16 lacks direct TLS/SNI for routers.)
pub debug_route: bool,
}
impl ManagedPostgres {
pub fn new(name: impl Into<String>) -> Self {
Self {
name: name.into(),
instances: 1,
version: None,
debug_route: false,
}
}
pub fn instances(mut self, instances: u32) -> Self {
self.instances = instances;
self
}
/// PostgreSQL major/minor tag (e.g. `"16"` / `"16.4"`) or a full
/// container image. Mapped to CNPG `spec.imageName`.
pub fn version(mut self, version: impl Into<String>) -> Self {
self.version = Some(version.into());
self
}
/// Declare a debug Service (ClusterIP off / NodePort on in the console).
pub fn debug_route(mut self) -> Self {
self.debug_route = true;
self
}
pub fn reference(&self) -> DatabaseRef {
DatabaseRef(self.name.clone())
}
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize)]
pub struct DatabaseRef(pub(crate) String);
impl DatabaseRef {
pub fn new(name: impl Into<String>) -> Self {
Self(name.into())
}
pub fn name(&self) -> &str {
&self.0
}
pub fn jdbc_url(&self) -> ValueRef {
ValueRef::DatabaseJdbcUrl(self.clone())
}
pub fn username(&self) -> ValueRef {
ValueRef::DatabaseUsername(self.clone())
}
pub fn password(&self) -> ValueRef {
ValueRef::DatabasePassword(self.clone())
}
}
#[derive(Debug, Clone, Serialize)]
pub struct ManagedZitadel {
pub name: String,
pub endpoint: PublicEndpointRef,
pub version: String,
pub contract: ZitadelContract,
pub redirects: Vec<OidcRedirect>,
}
impl ManagedZitadel {
pub fn new(name: impl Into<String>, endpoint: PublicEndpointRef) -> Self {
Self {
name: name.into(),
endpoint,
version: "v4.12.1".to_string(),
contract: ZitadelContract::default(),
redirects: Vec::new(),
}
}
pub fn version(mut self, version: impl Into<String>) -> Self {
self.version = version.into();
self
}
pub fn contract(mut self, contract: ZitadelContract) -> Self {
self.contract = contract;
self
}
pub fn redirect(
mut self,
application: ZitadelApplicationRef,
endpoint: PublicEndpointRef,
path: impl Into<String>,
) -> Self {
self.redirects.push(OidcRedirect {
application,
endpoint,
path: path.into(),
post_logout: false,
});
self
}
pub fn post_logout(
mut self,
application: ZitadelApplicationRef,
endpoint: PublicEndpointRef,
path: impl Into<String>,
) -> Self {
self.redirects.push(OidcRedirect {
application,
endpoint,
path: path.into(),
post_logout: true,
});
self
}
pub fn reference(&self) -> ZitadelRef {
ZitadelRef(self.name.clone())
}
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize)]
pub struct ZitadelRef(pub(crate) String);
impl ZitadelRef {
pub fn new(name: impl Into<String>) -> Self {
Self(name.into())
}
pub fn name(&self) -> &str {
&self.0
}
pub fn project_id(&self, project: ZitadelProjectRef) -> ValueRef {
ValueRef::OidcProjectId {
zitadel: self.clone(),
project,
}
}
pub fn oidc_client_id(&self, application: ZitadelApplicationRef) -> ValueRef {
ValueRef::OidcClientId {
zitadel: self.clone(),
application,
}
}
pub fn machine_client_id(&self, machine: ZitadelMachineRef) -> ValueRef {
ValueRef::MachineClientId {
zitadel: self.clone(),
machine,
}
}
pub fn machine_client_secret(&self, machine: ZitadelMachineRef) -> ValueRef {
ValueRef::MachineClientSecret {
zitadel: self.clone(),
machine,
}
}
pub fn machine_json_key(&self, machine: ZitadelMachineRef, path: impl Into<String>) -> FileRef {
FileRef::MachineJsonKey {
zitadel: self.clone(),
machine,
path: path.into(),
}
}
}
#[derive(Debug, Clone, Serialize)]
pub struct OidcRedirect {
pub application: ZitadelApplicationRef,
pub endpoint: PublicEndpointRef,
pub path: String,
pub post_logout: bool,
}

View File

@@ -1,597 +0,0 @@
use std::collections::{BTreeMap, BTreeSet};
use thiserror::Error;
use super::{
Application, FileRef, HealthCheck, ImageSource, ManagedResource, PortRef, Protocol, ValueRef,
};
#[derive(Debug, Error, PartialEq, Eq)]
pub enum ApplicationValidationError {
#[error("{field} must be non-empty")]
Empty { field: String },
#[error("duplicate {kind} '{name}'")]
Duplicate { kind: &'static str, name: String },
#[error("unknown image '{0}'")]
UnknownImage(String),
#[error("unknown {kind} '{name}'")]
UnknownResource { kind: &'static str, name: String },
#[error("unknown service '{0}'")]
UnknownService(String),
#[error("service '{0}' has no addressable ports")]
ServiceHasNoPorts(String),
#[error("unknown port '{service}.{port}'")]
UnknownPort { service: String, port: String },
#[error("route target '{service}.{port}' must use TCP")]
NonTcpRoute { service: String, port: String },
#[error("route '{host}{path}' must start with '/'")]
InvalidRoutePath { host: String, path: String },
#[error("rollout replicas must be greater than zero")]
ZeroReplicas,
#[error("file value path '{0}' must be absolute")]
RelativeFilePath(String),
#[error("health check for '{service}' references another service '{target}'")]
CrossServiceHealthCheck { service: String, target: String },
#[error("invalid Zitadel contract '{name}': {reason}")]
InvalidZitadelContract { name: String, reason: String },
#[error("machine identity '{machine}' does not produce {credential}")]
MissingMachineCredential {
machine: String,
credential: &'static str,
},
}
pub(crate) fn validate(app: &Application) -> Result<(), ApplicationValidationError> {
non_empty(&app.name, "application name")?;
if app.rollout.replicas == 0 {
return Err(ApplicationValidationError::ZeroReplicas);
}
if app.services.is_empty() {
return Err(ApplicationValidationError::Empty {
field: "application services".to_string(),
});
}
let mut images = BTreeSet::new();
for image in &app.images {
non_empty(&image.name, "image name")?;
match &image.source {
ImageSource::Reference(reference) => {
non_empty(reference, &format!("image '{}' reference", image.name))?
}
ImageSource::Build(build) => {
if build.context.as_os_str().is_empty() {
return Err(ApplicationValidationError::Empty {
field: format!("image '{}' build context", image.name),
});
}
}
}
if !images.insert(image.name.as_str()) {
return Err(ApplicationValidationError::Duplicate {
kind: "image",
name: image.name.clone(),
});
}
}
let endpoints: BTreeSet<_> = app
.endpoints
.iter()
.map(|endpoint| endpoint.name.as_str())
.collect();
if endpoints.len() != app.endpoints.len() {
return Err(ApplicationValidationError::Duplicate {
kind: "public endpoint",
name: "declaration".to_string(),
});
}
let mut databases = BTreeSet::new();
let mut buckets = BTreeSet::new();
let mut zitadels = BTreeMap::new();
for resource in &app.resources {
match resource {
ManagedResource::Postgres(database) => {
non_empty(&database.name, "database name")?;
if !databases.insert(database.name.as_str()) {
return Err(ApplicationValidationError::Duplicate {
kind: "database",
name: database.name.clone(),
});
}
}
ManagedResource::Bucket(bucket) => {
non_empty(&bucket.name, "bucket name")?;
non_empty(&bucket.storage_class, "bucket storage class")?;
non_empty(&bucket.max_size, "bucket max size")?;
if let Some(endpoint) = &bucket.endpoint {
non_empty(endpoint, "bucket endpoint")?;
}
for endpoint in &bucket.cors {
if !endpoints.contains(endpoint.name()) {
return Err(ApplicationValidationError::UnknownResource {
kind: "public endpoint",
name: endpoint.name().to_string(),
});
}
}
if !buckets.insert(bucket.name.as_str()) {
return Err(ApplicationValidationError::Duplicate {
kind: "bucket",
name: bucket.name.clone(),
});
}
}
ManagedResource::Zitadel(zitadel) => {
non_empty(&zitadel.name, "Zitadel name")?;
if zitadels.contains_key(zitadel.name.as_str()) {
return Err(ApplicationValidationError::Duplicate {
kind: "Zitadel",
name: zitadel.name.clone(),
});
}
zitadel.contract.validate().map_err(|reason| {
ApplicationValidationError::InvalidZitadelContract {
name: zitadel.name.clone(),
reason,
}
})?;
if !endpoints.contains(zitadel.endpoint.name()) {
return Err(ApplicationValidationError::UnknownResource {
kind: "public endpoint",
name: zitadel.endpoint.name().to_string(),
});
}
zitadels.insert(zitadel.name.as_str(), zitadel);
for redirect in &zitadel.redirects {
if !endpoints.contains(redirect.endpoint.name()) {
return Err(ApplicationValidationError::UnknownResource {
kind: "public endpoint",
name: redirect.endpoint.name().to_string(),
});
}
if !redirect.path.starts_with('/') {
return Err(ApplicationValidationError::InvalidRoutePath {
host: redirect.endpoint.name().to_string(),
path: redirect.path.clone(),
});
}
if !zitadel
.contract
.applications
.iter()
.any(|application| application.application == redirect.application)
{
return Err(ApplicationValidationError::UnknownResource {
kind: "OIDC application",
name: format!(
"{}/{}",
redirect.application.project().name(),
redirect.application.name()
),
});
}
}
}
}
}
let mut services = BTreeMap::new();
for service in &app.services {
non_empty(&service.name, "service name")?;
if !images.contains(service.image.name()) {
return Err(ApplicationValidationError::UnknownImage(
service.image.name().to_string(),
));
}
if services.insert(service.name.as_str(), service).is_some() {
return Err(ApplicationValidationError::Duplicate {
kind: "service",
name: service.name.clone(),
});
}
let mut ports = BTreeSet::new();
for port in &service.ports {
non_empty(&port.name, &format!("service '{}' port name", service.name))?;
if port.number == 0 {
return Err(ApplicationValidationError::Empty {
field: format!("service '{}' port number", service.name),
});
}
if !ports.insert(port.name.as_str()) {
return Err(ApplicationValidationError::Duplicate {
kind: "port",
name: format!("{}.{}", service.name, port.name),
});
}
}
}
let resolve_port = |reference: &PortRef| {
let service = services.get(reference.service.name()).ok_or_else(|| {
ApplicationValidationError::UnknownService(reference.service.name().to_string())
})?;
service
.ports
.iter()
.find(|port| port.name == reference.name())
.ok_or_else(|| ApplicationValidationError::UnknownPort {
service: reference.service.name().to_string(),
port: reference.name().to_string(),
})
};
for service in &app.services {
let mut values = BTreeSet::new();
for (name, value) in &service.values {
non_empty(name, &format!("service '{}' value name", service.name))?;
if !values.insert(name.as_str()) {
return Err(ApplicationValidationError::Duplicate {
kind: "value",
name: format!("{}.{}", service.name, name),
});
}
match value {
ValueRef::ServiceHost(reference) => match services.get(reference.name()) {
None => {
return Err(ApplicationValidationError::UnknownService(
reference.name().to_string(),
));
}
Some(service) if service.ports.is_empty() => {
return Err(ApplicationValidationError::ServiceHasNoPorts(
reference.name().to_string(),
));
}
Some(_) => {}
},
ValueRef::ServicePort(reference)
| ValueRef::ServiceUrl {
port: reference, ..
} => {
resolve_port(reference)?;
}
ValueRef::PublicEndpointOrigin(reference) => {
if !endpoints.contains(reference.name()) {
return Err(ApplicationValidationError::UnknownResource {
kind: "public endpoint",
name: reference.name().to_string(),
});
}
}
ValueRef::PublicEndpointUrl { endpoint, path } => {
if !endpoints.contains(endpoint.name()) {
return Err(ApplicationValidationError::UnknownResource {
kind: "public endpoint",
name: endpoint.name().to_string(),
});
}
if !path.starts_with('/') {
return Err(ApplicationValidationError::InvalidRoutePath {
host: endpoint.name().to_string(),
path: path.clone(),
});
}
}
ValueRef::DatabaseJdbcUrl(reference)
| ValueRef::DatabaseUsername(reference)
| ValueRef::DatabasePassword(reference) => {
if !databases.contains(reference.name()) {
return Err(ApplicationValidationError::UnknownResource {
kind: "database",
name: reference.name().to_string(),
});
}
}
ValueRef::BucketEndpoint(reference)
| ValueRef::BucketName(reference)
| ValueRef::BucketAccessKey(reference)
| ValueRef::BucketSecretKey(reference)
| ValueRef::BucketRegion(reference)
| ValueRef::BucketPathStyle(reference) => {
if !buckets.contains(reference.name()) {
return Err(ApplicationValidationError::UnknownResource {
kind: "bucket",
name: reference.name().to_string(),
});
}
}
ValueRef::ZitadelIssuer(reference) | ValueRef::ZitadelManagementUrl(reference) => {
validate_zitadel(&zitadels, reference.name())?;
}
ValueRef::OidcProjectId {
zitadel: producer,
project,
} => {
let zitadel = validate_zitadel(&zitadels, producer.name())?;
if !zitadel
.contract
.projects
.iter()
.any(|item| item.project == *project)
{
return Err(ApplicationValidationError::UnknownResource {
kind: "OIDC project",
name: project.name().to_string(),
});
}
}
ValueRef::OidcClientId {
zitadel: producer,
application,
} => {
let zitadel = validate_zitadel(&zitadels, producer.name())?;
if !zitadel
.contract
.applications
.iter()
.any(|item| item.application == *application)
{
return Err(ApplicationValidationError::UnknownResource {
kind: "OIDC application",
name: format!(
"{}/{}",
application.project().name(),
application.name()
),
});
}
}
ValueRef::MachineClientId { zitadel, machine } => {
let declaration = validate_machine(&zitadels, zitadel, machine)?;
if !declaration.client_secret {
return Err(ApplicationValidationError::MissingMachineCredential {
machine: machine.name().to_string(),
credential: "a client ID",
});
}
}
ValueRef::MachineClientSecret { zitadel, machine } => {
let declaration = validate_machine(&zitadels, zitadel, machine)?;
if !declaration.client_secret {
return Err(ApplicationValidationError::MissingMachineCredential {
machine: machine.name().to_string(),
credential: "a client secret",
});
}
}
ValueRef::File(reference) => {
let FileRef::MachineJsonKey {
zitadel, machine, ..
} = reference;
let declaration = validate_machine(&zitadels, zitadel, machine)?;
if declaration.key
!= Some(harmony::modules::zitadel::ZitadelMachineKeyDeclaration::Json)
{
return Err(ApplicationValidationError::MissingMachineCredential {
machine: machine.name().to_string(),
credential: "a JSON key",
});
}
if !reference.path().starts_with('/') {
return Err(ApplicationValidationError::RelativeFilePath(
reference.path().to_string(),
));
}
}
ValueRef::Literal(_) => {}
}
}
if let Some(health) = &service.health {
let reference = match health {
HealthCheck::Http { port, path, .. } => {
if !path.starts_with('/') {
return Err(ApplicationValidationError::InvalidRoutePath {
host: service.name.clone(),
path: path.clone(),
});
}
port
}
HealthCheck::Tcp { port, .. } => port,
};
resolve_port(reference)?;
if reference.service.name() != service.name {
return Err(ApplicationValidationError::CrossServiceHealthCheck {
service: service.name.clone(),
target: reference.service.name().to_string(),
});
}
}
}
for route in &app.routes {
if !endpoints.contains(route.endpoint.name()) {
return Err(ApplicationValidationError::UnknownResource {
kind: "public endpoint",
name: route.endpoint.name().to_string(),
});
}
if !route.path.starts_with('/') {
return Err(ApplicationValidationError::InvalidRoutePath {
host: route.endpoint.name().to_string(),
path: route.path.clone(),
});
}
let port = resolve_port(&route.target)?;
if port.protocol != Protocol::Tcp {
return Err(ApplicationValidationError::NonTcpRoute {
service: route.target.service.name().to_string(),
port: route.target.name().to_string(),
});
}
}
Ok(())
}
fn validate_zitadel<'a>(
zitadels: &'a BTreeMap<&str, &super::ManagedZitadel>,
name: &str,
) -> Result<&'a super::ManagedZitadel, ApplicationValidationError> {
zitadels
.get(name)
.copied()
.ok_or_else(|| ApplicationValidationError::UnknownResource {
kind: "Zitadel",
name: name.to_string(),
})
}
fn validate_machine<'a>(
zitadels: &'a BTreeMap<&str, &'a super::ManagedZitadel>,
producer: &super::ZitadelRef,
machine: &harmony::modules::zitadel::ZitadelMachineRef,
) -> Result<&'a harmony::modules::zitadel::ZitadelMachineDeclaration, ApplicationValidationError> {
let zitadel = validate_zitadel(zitadels, producer.name())?;
zitadel
.contract
.machines
.iter()
.find(|item| item.machine == *machine)
.ok_or_else(|| ApplicationValidationError::UnknownResource {
kind: "machine identity",
name: machine.name().to_string(),
})
}
fn non_empty(value: &str, field: &str) -> Result<(), ApplicationValidationError> {
if value.trim().is_empty() {
Err(ApplicationValidationError::Empty {
field: field.to_string(),
})
} else {
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::application::{Image, LogicalEndpoint, ManagedZitadel, Port, Route, Service};
use harmony::modules::zitadel::{
ZitadelContract, ZitadelMachineDeclaration, ZitadelMachineRef,
};
fn valid_app() -> Application {
let image = Image::new("web", "example/web:1");
let web = Service::new("web", image.reference()).port(Port::tcp("http", 8080));
let web_ref = web.reference();
let endpoint = LogicalEndpoint::new("web");
let endpoint_ref = endpoint.reference();
Application::new("example")
.image(image)
.endpoint(endpoint)
.service(web)
.route(Route::new(endpoint_ref, "/", web_ref.port("http")))
}
#[test]
fn accepts_typed_references() {
valid_app().validate().unwrap();
}
#[test]
fn rejects_unknown_route_port() {
let mut app = valid_app();
app.routes[0].target = app.services[0].reference().port("admin");
assert_eq!(
app.validate().unwrap_err(),
ApplicationValidationError::UnknownPort {
service: "web".to_string(),
port: "admin".to_string(),
}
);
}
#[test]
fn rejects_unknown_semantic_endpoint() {
let mut app = valid_app();
app.services[0].values.push((
"ORIGIN".to_string(),
super::super::PublicEndpointRef::new("missing").origin(),
));
assert_eq!(
app.validate().unwrap_err(),
ApplicationValidationError::UnknownResource {
kind: "public endpoint",
name: "missing".to_string(),
}
);
}
#[test]
fn rejects_unproduced_machine_client_secret() {
let mut app = valid_app();
let endpoint = LogicalEndpoint::new("identity");
let machine = ZitadelMachineRef::new("backend");
let identity = ManagedZitadel::new("identity", endpoint.reference()).contract(
ZitadelContract::default().machine(ZitadelMachineDeclaration {
machine: machine.clone(),
name: "Backend".to_string(),
key: None,
client_secret: false,
}),
);
app.services[0].values.push((
"CLIENT_SECRET".to_string(),
identity.reference().machine_client_secret(machine),
));
app.endpoints.push(endpoint);
app.resources.push(identity.into());
assert_eq!(
app.validate().unwrap_err(),
ApplicationValidationError::MissingMachineCredential {
machine: "backend".to_string(),
credential: "a client secret",
}
);
}
#[test]
fn rejects_unproduced_machine_client_id() {
let mut app = valid_app();
let endpoint = LogicalEndpoint::new("identity");
let machine = ZitadelMachineRef::new("backend");
let identity = ManagedZitadel::new("identity", endpoint.reference()).contract(
ZitadelContract::default().machine(ZitadelMachineDeclaration {
machine: machine.clone(),
name: "Backend".to_string(),
key: None,
client_secret: false,
}),
);
app.services[0].values.push((
"CLIENT_ID".to_string(),
identity.reference().machine_client_id(machine),
));
app.endpoints.push(endpoint);
app.resources.push(identity.into());
assert_eq!(
app.validate().unwrap_err(),
ApplicationValidationError::MissingMachineCredential {
machine: "backend".to_string(),
credential: "a client ID",
}
);
}
#[test]
fn rejects_duplicate_managed_zitadel() {
let mut app = valid_app();
let endpoint = LogicalEndpoint::new("identity");
let identity = ManagedZitadel::new("identity", endpoint.reference());
app.endpoints.push(endpoint);
app.resources.push(identity.clone().into());
app.resources.push(identity.into());
assert_eq!(
app.validate().unwrap_err(),
ApplicationValidationError::Duplicate {
kind: "Zitadel",
name: "identity".to_string(),
}
);
}
}

View File

@@ -1,386 +0,0 @@
//! Composable **capabilities** — the `.with(...)` menu. A capability
//! contributes its own Scores (e.g. a database) and injects env into the
//! app's containers, **wired by k8s reference** (service DNS, `secretKeyRef`),
//! so generated secrets never pass through Harmony and a published chart stays
//! value-free. This is the framework-level, discoverable home: `Postgres`
//! today; `Monitoring` and `ZitadelAuth` next.
//!
//! To add one: implement [`Capability`] — `scores()` (what it deploys) and/or
//! `env()` (how the app reaches it, by reference) — then `app.with(MyThing)`.
use harmony::modules::monitoring::alert_rule::prometheus_alert_rule::{
AlertManagerRuleGroup, PrometheusAlertRule,
};
use harmony::modules::monitoring::kube_prometheus::helm_prometheus_alert_score::HelmPrometheusAlertingScore;
use harmony::modules::monitoring::kube_prometheus::prometheus::KubePrometheus;
use harmony::modules::postgresql::K8sPostgreSQLScore;
use harmony::modules::postgresql::capability::PostgreSQLConfig;
use harmony::modules::zitadel::{
ZitadelAppType, ZitadelApplication, ZitadelClientIdExportScore, ZitadelSetupScore,
};
use harmony::score::Score;
use harmony::topology::oberservability::monitoring::AlertReceiver;
use harmony::topology::tenant::TenantManager;
use harmony::topology::{HelmCommand, K8sclient, Topology};
use k8s_openapi::api::core::v1::{ConfigMapKeySelector, EnvVar, EnvVarSource, SecretKeySelector};
use crate::Profile;
/// What a capability needs to know about the app it augments.
pub struct AppRef<'a> {
pub name: &'a str,
pub namespace: &'a str,
pub profile: Profile,
}
/// An add-on a deployment declares with `.with(...)`: it deploys its own
/// Scores and injects env wired by k8s reference, never by value. **Generic
/// over the target `Topology`** — a concrete capability is implemented only
/// for the topologies whose capabilities can host its Scores, so `.with(X)`
/// is a compile error on a topology that can't run `X`.
pub trait Capability<T: Topology>: Send + Sync {
fn scores(&self, _app: &AppRef) -> Vec<Box<dyn Score<T>>> {
vec![]
}
fn env(&self, _app: &AppRef) -> Vec<EnvVar> {
vec![]
}
}
/// A managed PostgreSQL database (CNPG). Deploys a cluster `<app>-db` and
/// wires `DATABASE_URL` into the app from CNPG's generated `<app>-db-app`
/// secret — by reference, so the password never passes through Harmony.
pub struct Postgres {
instances: u32,
}
impl Postgres {
pub fn managed() -> Self {
Self { instances: 1 }
}
pub fn instances(mut self, n: u32) -> Self {
self.instances = n;
self
}
fn cluster(app: &AppRef) -> String {
format!("{}-db", app.name)
}
}
impl<T: Topology + K8sclient + HelmCommand + 'static> Capability<T> for Postgres {
fn scores(&self, app: &AppRef) -> Vec<Box<dyn Score<T>>> {
let config = PostgreSQLConfig {
cluster_name: Self::cluster(app),
namespace: app.namespace.to_string(),
instances: self.instances,
..Default::default()
};
vec![Box::new(K8sPostgreSQLScore { config })]
}
fn env(&self, app: &AppRef) -> Vec<EnvVar> {
vec![EnvVar {
name: "DATABASE_URL".to_string(),
value_from: Some(EnvVarSource {
secret_key_ref: Some(SecretKeySelector {
name: format!("{}-app", Self::cluster(app)),
key: "uri".to_string(),
optional: Some(true),
}),
..Default::default()
}),
..Default::default()
}]
}
}
/// Application monitoring: a downtime alert routed to the given receivers.
/// The "is it up?" signal is kube-state-metrics deployment availability —
/// scoped to the app's namespace, so it needs no app-side `/metrics` and no
/// ServiceMonitor wiring. (kube-prometheus must be present on the cluster.)
pub struct Monitoring {
receivers: Vec<Box<dyn AlertReceiver<KubePrometheus>>>,
}
impl Default for Monitoring {
fn default() -> Self {
Self::new()
}
}
impl Monitoring {
pub fn new() -> Self {
Self { receivers: vec![] }
}
/// Route alerts to a receiver (e.g. `DiscordWebhook`). Chainable.
pub fn alert(mut self, receiver: impl AlertReceiver<KubePrometheus> + 'static) -> Self {
self.receivers.push(Box::new(receiver));
self
}
}
/// The downtime alert for an app: fires when its namespace has a Deployment
/// with no available replicas for 5m. Pure + testable.
fn downtime_alert(app: &AppRef) -> PrometheusAlertRule {
PrometheusAlertRule::new(
&format!("{}Down", app.name),
&format!(
"kube_deployment_status_replicas_available{{namespace=\"{}\"}} == 0",
app.namespace
),
)
.for_duration("5m")
.label("severity", "critical")
.annotation(
"summary",
&format!("{} has no available replicas", app.name),
)
}
impl<T: Topology + HelmCommand + TenantManager> Capability<T> for Monitoring {
fn scores(&self, app: &AppRef) -> Vec<Box<dyn Score<T>>> {
let group = AlertManagerRuleGroup::new(
&format!("{}-availability", app.name),
vec![downtime_alert(app)],
);
vec![Box::new(HelmPrometheusAlertingScore {
receivers: self.receivers.iter().map(|r| r.clone_box()).collect(),
rules: vec![Box::new(group)],
service_monitors: vec![],
})]
}
}
/// OIDC login via the tenant's Zitadel — a PKCE public client (no secret),
/// for SPA/web frontends. Provisions the app in Zitadel, publishes its
/// `client_id` to the `<app>-oidc` ConfigMap, and injects `OIDC_ISSUER`
/// (plain) + `OIDC_CLIENT_ID` (by `configMapKeyRef`) into the app — wired by
/// reference. For other config values, keep using `.secret(...)`.
pub struct ZitadelAuth {
issuer: String,
project: Option<String>,
redirect_uris: Vec<String>,
post_logout_redirect_uris: Vec<String>,
endpoint: Option<String>,
pat_namespace: String,
}
impl ZitadelAuth {
/// `issuer` is the app-facing OIDC issuer URL (e.g. the tenant's Zitadel).
pub fn oidc(issuer: impl Into<String>) -> Self {
Self {
issuer: issuer.into(),
project: None,
redirect_uris: vec![],
post_logout_redirect_uris: vec![],
endpoint: None,
pat_namespace: "zitadel".to_string(),
}
}
/// Zitadel project to create the app in (defaults to the app name).
pub fn project(mut self, project: impl Into<String>) -> Self {
self.project = Some(project.into());
self
}
pub fn redirect(mut self, uri: impl Into<String>) -> Self {
self.redirect_uris.push(uri.into());
self
}
pub fn post_logout(mut self, uri: impl Into<String>) -> Self {
self.post_logout_redirect_uris.push(uri.into());
self
}
/// Override how the provisioner reaches Zitadel's API (e.g. a local
/// `http://localhost:8080`); defaults to the issuer.
pub fn endpoint(mut self, endpoint: impl Into<String>) -> Self {
self.endpoint = Some(endpoint.into());
self
}
/// Namespace holding the `iam-admin-pat` secret (default `zitadel`).
pub fn pat_namespace(mut self, ns: impl Into<String>) -> Self {
self.pat_namespace = ns.into();
self
}
fn configmap(app: &AppRef) -> String {
format!("{}-oidc", app.name)
}
}
/// Hostname (no scheme/port/path) for a URL — Zitadel's `Host:` header.
fn host_of(url: &str) -> String {
let after_scheme = url.split_once("://").map(|(_, r)| r).unwrap_or(url);
after_scheme
.split('/')
.next()
.unwrap_or(after_scheme)
.split(':')
.next()
.unwrap_or(after_scheme)
.to_string()
}
impl<T: Topology + K8sclient> Capability<T> for ZitadelAuth {
fn scores(&self, app: &AppRef) -> Vec<Box<dyn Score<T>>> {
let project = self.project.clone().unwrap_or_else(|| app.name.to_string());
let setup = ZitadelSetupScore {
host: host_of(self.endpoint.as_deref().unwrap_or(&self.issuer)),
scheme: Default::default(),
port: None,
skip_tls: false,
endpoint: self.endpoint.clone(),
namespace: self.pat_namespace.clone(),
admin_org_id: None,
applications: vec![ZitadelApplication {
project_name: project,
app_name: app.name.to_string(),
app_type: ZitadelAppType::WebPkce {
redirect_uris: self.redirect_uris.clone(),
post_logout_redirect_uris: self.post_logout_redirect_uris.clone(),
// TODO this should be configurable
id_token_role_assertion: false,
},
}],
api_apps: vec![],
roles: vec![],
machine_users: vec![],
groups_claim_action: false,
..Default::default()
};
let export = ZitadelClientIdExportScore {
app_name: app.name.to_string(),
configmap_name: Self::configmap(app),
namespace: app.namespace.to_string(),
key: "OIDC_CLIENT_ID".to_string(),
};
vec![Box::new(setup), Box::new(export)]
}
fn env(&self, app: &AppRef) -> Vec<EnvVar> {
vec![
EnvVar {
name: "OIDC_ISSUER".to_string(),
value: Some(self.issuer.clone()),
..Default::default()
},
EnvVar {
name: "OIDC_CLIENT_ID".to_string(),
value_from: Some(EnvVarSource {
config_map_key_ref: Some(ConfigMapKeySelector {
name: Self::configmap(app),
key: "OIDC_CLIENT_ID".to_string(),
optional: Some(true),
}),
..Default::default()
}),
..Default::default()
},
]
}
}
#[cfg(test)]
mod tests {
use super::*;
use harmony::topology::K8sAnywhereTopology;
// Pin a concrete topology for the (topology-independent) assertions.
fn scores_of(
c: &impl Capability<K8sAnywhereTopology>,
a: &AppRef,
) -> Vec<Box<dyn Score<K8sAnywhereTopology>>> {
c.scores(a)
}
fn env_of(c: &impl Capability<K8sAnywhereTopology>, a: &AppRef) -> Vec<EnvVar> {
c.env(a)
}
fn app() -> AppRef<'static> {
AppRef {
name: "ts",
namespace: "ts",
profile: Profile::Local,
}
}
#[test]
fn postgres_wires_database_url_by_reference() {
let env = env_of(&Postgres::managed(), &app());
let db = env.iter().find(|e| e.name == "DATABASE_URL").unwrap();
let sel = db
.value_from
.as_ref()
.unwrap()
.secret_key_ref
.as_ref()
.unwrap();
assert_eq!(sel.name, "ts-db-app", "CNPG app secret, by convention");
assert_eq!(sel.key, "uri");
assert!(db.value.is_none(), "wired by reference, never a value");
}
#[test]
fn postgres_contributes_a_cluster_score() {
let scores = scores_of(&Postgres::managed(), &app());
assert_eq!(scores.len(), 1);
assert!(scores[0].name().contains("PostgreSQL"));
}
#[test]
fn monitoring_downtime_alert_is_namespace_scoped() {
let rule = downtime_alert(&app());
assert!(
rule.expr.contains("namespace=\"ts\""),
"scoped to the app namespace: {}",
rule.expr
);
assert!(rule.expr.contains("== 0"), "fires on zero replicas");
}
#[test]
fn monitoring_contributes_one_alerting_score() {
let scores = scores_of(&Monitoring::new(), &app());
assert_eq!(scores.len(), 1);
}
#[test]
fn zitadel_auth_wires_issuer_plain_and_client_id_by_reference() {
let env = env_of(
&ZitadelAuth::oidc("https://sso.example").redirect("https://ts.example/callback"),
&app(),
);
let issuer = env.iter().find(|e| e.name == "OIDC_ISSUER").unwrap();
assert_eq!(issuer.value.as_deref(), Some("https://sso.example"));
let cid = env.iter().find(|e| e.name == "OIDC_CLIENT_ID").unwrap();
let sel = cid
.value_from
.as_ref()
.unwrap()
.config_map_key_ref
.as_ref()
.unwrap();
assert_eq!(sel.name, "ts-oidc");
assert_eq!(sel.key, "OIDC_CLIENT_ID");
assert!(cid.value.is_none(), "client_id is referenced, not inlined");
}
#[test]
fn zitadel_auth_provisions_then_exports() {
// Two scores, in order: provision the OIDC app, then publish its
// client_id to the ConfigMap.
let scores = scores_of(&ZitadelAuth::oidc("https://sso.example"), &app());
assert_eq!(scores.len(), 2);
assert!(scores[0].name().contains("ZitadelSetup"));
assert!(scores[1].name().contains("ZitadelClientIdExport"));
}
#[test]
fn host_of_strips_scheme_port_path() {
assert_eq!(
host_of("https://sso.nationtech.io/oauth"),
"sso.nationtech.io"
);
assert_eq!(host_of("http://localhost:8080"), "localhost");
}
}

Some files were not shown because too many files have changed in this diff Show More