feat/declarative-app-dx #337

Merged
johnride merged 20 commits from feat/declarative-app-dx into master 2026-06-22 23:07:43 +00:00
44 changed files with 2544 additions and 426 deletions

45
Cargo.lock generated
View File

@@ -2785,10 +2785,6 @@ dependencies = [
"pin-project-lite",
]
[[package]]
name = "example"
version = "0.0.0"
[[package]]
name = "example-application-monitoring-with-tenant"
version = "0.1.0"
@@ -2835,22 +2831,9 @@ name = "example-compose-java-react"
version = "0.1.0"
dependencies = [
"anyhow",
"async-trait",
"clap",
"docker-compose-types",
"env_logger",
"fqdn",
"harmony",
"harmony_app",
"harmony_cli",
"harmony_types",
"k8s-openapi",
"log",
"serde",
"serde_yaml",
"tempfile",
"thiserror 2.0.18",
"tokio",
"toml",
]
[[package]]
@@ -4329,6 +4312,29 @@ dependencies = [
"url",
]
[[package]]
name = "harmony_app"
version = "0.1.0"
dependencies = [
"anyhow",
"async-trait",
"docker-compose-types",
"fqdn",
"harmony",
"harmony-k8s",
"harmony_config",
"harmony_types",
"k8s-openapi",
"log",
"schemars 0.8.22",
"serde",
"serde_yaml",
"tempfile",
"thiserror 2.0.18",
"tokio",
"toml",
]
[[package]]
name = "harmony_assets"
version = "0.1.0"
@@ -4359,11 +4365,13 @@ dependencies = [
name = "harmony_cli"
version = "0.1.0"
dependencies = [
"anyhow",
"assert_cmd",
"async-trait",
"clap",
"console",
"harmony",
"harmony_app",
"harmony_tui",
"indicatif",
"inquire 0.7.5",
@@ -4415,6 +4423,7 @@ dependencies = [
"tempfile",
"thiserror 2.0.18",
"tokio",
"toml",
]
[[package]]

View File

@@ -2,7 +2,6 @@
resolver = "2"
members = [
"examples/*",
"private_repos/*",
"harmony",
"harmony_zitadel_auth",
"harmony_zitadel_jwt",
@@ -29,6 +28,7 @@ members = [
"harmony_agent/deploy",
"harmony_node_readiness",
"harmony-k8s",
"harmony_app",
"harmony_assets", "opnsense-codegen", "opnsense-api",
"fleet/harmony-fleet-operator",
"fleet/harmony-fleet-agent",

View File

@@ -21,6 +21,7 @@
- [Developer Guide](./guides/developer-guide.md)
- [Application CLI — Use Cases & Commands](./guides/application-cli.md)
- [Application Capabilities — .with(...)](./guides/application-capabilities.md)
- [Writing a Score](./guides/writing-a-score.md)
- [Writing a Topology](./guides/writing-a-topology.md)
- [Adding Capabilities](./guides/adding-capabilities.md)

View File

@@ -0,0 +1,159 @@
# Architecture Decision Record: Multi-Tenant Cloud Identity
Initial Author: Jean-Gabriel Gill-Couture
Initial Date: 2026-06-20
Last Updated Date: 2026-06-20
## Status
Accepted. Phase 0 in use by the first tenant (`folk`); see the
[Folk CD setup guide](../../private_repos/folk_ci_deploy/SETUP.md) for the
concrete, worked example.
## Context
NationTech runs an open cloud: NationTech staff hold roles in one Zitadel
org (`cloud-admin`, `cloud-billing`, …), and customers create **tenants**.
A tenant owns a slice of every backing service — secrets under
`secret/data/<tenant>/*` in OpenBao, a Harbor project `<tenant>`, Ceph RGW
buckets `<tenant>-*`. The hard constraint:
> A user (or CI identity) created in Zitadel must gain access to its tenant's
> secrets, registry, and storage **without adding a new auth method to any
> service, and without modifying any app, per tenant.**
The open question that triggered this ADR was Zitadel structure: one
**org per tenant**, or one shared **project** with per-tenant roles? Phrased
that way it looks like a service-integration choice. It is not.
## Decision
**Every backing service trusts the *one* Zitadel issuer + *one* shared
audience exactly once, then authorizes on claims. Tenant scoping lives
entirely in a frozen claim contract; the org/project structure is an
internal isolation choice that can change behind it.**
### The claim contract (frozen — apps bind to this, never to tenant structure)
| Claim | Meaning |
|---|---|
| issuer | `https://sso.nationtech.io` — the single root of trust. |
| `aud` | Resource Id of the **shared `cloud` project** — the single audience every service pins as `bound_audiences`. Never per-tenant. |
| `groups` | string array of `<tenant>:<capability>` entries, e.g. `folk:deployer`. The tenant slug is the universal resource prefix; the capability is a fixed tier. |
Capability tiers are a fixed vocabulary: `owner` (full tenant control),
`deployer` (CI — push images, read deploy secrets, apply workloads),
`viewer` (read-only). The **tenant slug** is a human handle NationTech
assigns (`folk`), decoupled from any Zitadel UUID, and is the literal prefix
in OpenBao paths, the Harbor project name, and the Ceph bucket prefix.
### Zitadel structure (Phase 0 — single org, shared project)
- **One org**: `nationtech`. NationTech creates all identities for now.
- **One shared project**, `cloud`. Its Resource Id is the audience for
*everything*. One API application (Private Key JWT) under it makes that id
a valid `aud`.
- **Per-tenant = roles, not structure.** Each tenant contributes roles
`<slug>:owner` / `<slug>:deployer` / `<slug>:viewer` to the shared project.
Granting a user or service user one of these roles is the entire
"add user to tenant" operation.
- One project-level **Action** flattens granted role keys into the `groups`
claim (Zitadel's roles claim is a map; a string-array `groups` is what the
groups model below consumes — same Action as ADR-025).
### Service integration (reuses ADR-025's groups model verbatim)
Each service is configured **once**, never per tenant:
- **OpenBao** — JWT auth configured against the one issuer; **one** shared
jwt role pinning `bound_audiences = <cloud project id>` and
`groups_claim = groups`. Per tenant+capability there is one OpenBao
*external group* `<slug>:<cap>` and one policy granting its subtree
(`secret/data/<slug>/*`), attached to that group. A token carrying
`folk:deployer` resolves the policy at request time — no role edit, no
re-auth config. (Identical mechanism and write-cost properties to
ADR-025's fleet device access.)
- **Harbor / Ceph RGW** — same shape: bind the one OIDC issuer + audience
once; map `groups` membership to the tenant's project / bucket-prefix
policy.
Onboarding tenant N is therefore pure **provisioning** — add roles + Action
entry, add one external group + policy per service — with **zero** app code,
auth-method, or audience change. This is the constraint, satisfied.
### Phasing (the structure evolves; the contract does not)
| Phase | Trigger | Change | Claim contract |
|---|---|---|---|
| **0 (now)** | tenant #1 | single org, shared `cloud` project; tenant encoded as `<slug>:<cap>` role keys flattened into `groups`. Zero new code. | unchanged |
| **1** | tenants #23 | add a first-class `tenant` claim (Zitadel Action) + OpenBao `claim_mappings` + **one** templated policy `secret/data/{{…metadata.tenant}}/*`, collapsing per-tenant groups into one policy. One-time, not per-tenant. | `tenant` becomes a distinct claim; `groups` keeps capability |
| **2** | self-service onboarding | **org per tenant** + Zitadel project grants (B2B); slug becomes org metadata; audience stays the shared `cloud` project so services still bind one `aud`. | unchanged — apps never notice |
## Rationale
- **Org-vs-project is not a service question.** Because every service
validates one issuer + one audience and reads claims, the Zitadel
structure is free to change behind the claim contract. Picking the
*minimal* structure now (single org) and deferring org-per-tenant to
when self-service actually demands it is YAGNI applied to identity.
- **One audience is the whole trick.** If audience were per-tenant, every
service would need a new `bound_audiences` per tenant — exactly the
per-tenant service change the constraint forbids. A single shared-project
audience is what makes onboarding pure provisioning.
- **Reuse, don't reinvent.** The group→policy authorization, its O(1)
per-change write cost, and the roles→`groups` Action already exist and are
argued in ADR-025. Multi-tenant cloud access and fleet device access are
the same problem (signed group claim gates a prefixed secret subtree); they
share one mechanism.
- **Slug as prefix, not UUID.** Binding resources to a human slug keeps
OpenBao paths, Harbor projects, and buckets legible and stable across the
Phase 2 org migration, where the UUID *does* change.
## Consequences
**Pros**
- New tenant: provisioning only — no app, auth-config, or audience change.
- One issuer + one audience across all services; one place to reason about
trust.
- Authorization is human-auditable: role grants in Zitadel, group→resource
policies on each service.
- The Phase 2 org migration is invisible to apps (they read claims).
**Cons / accepted trade-offs**
- **Phase 0 isolation is logical, not org-hard.** All tenants share one org
and project; a Zitadel-admin compromise spans tenants. Acceptable while
NationTech creates every identity; Phase 2's org-per-tenant closes it.
- **Zitadel Action required** until first-class groups/claims GA — small,
version-controlled server-side JS in the token path (shared with ADR-025).
- **Capability tiers are coarse** (`owner`/`deployer`/`viewer`). Finer grants
mean more group→policy pairs; revisit only at a real third instance.
## Alternatives considered
- **Org per tenant from day one (Phase 2 now).** Correct end state, but
Zitadel org creation + project grants are unbuilt Scores and self-service
isn't needed yet — premature. Deferred, not rejected.
- **Per-tenant audience (a project per tenant).** Forces every service to
add a `bound_audiences` per tenant — the precise per-service change the
constraint forbids. Rejected.
- **Tenant in OpenBao only (out-of-band mapping).** Drops Zitadel as the
source of truth for who-belongs-to-what; reintroduces a second place to
manage membership. Rejected.
## Additional Notes
Verified Score gaps as of 2026-06-20 (build order tracks the phases): the
Zitadel Score supports projects, roles, single-org user grants, machine
users + keys, API apps, and the `groups` flatten Action — it lacks org
creation, project grants, and arbitrary custom claims. The OpenBao Score
supports JWT config + the external-groups model (ADR-025) — it lacks
`claim_mappings` and templated-policy metadata population. Phase 0 needs none
of the gaps; Phase 1 needs the OpenBao ones; Phase 2 needs the Zitadel ones.
Relationship to other ADRs — **ADR-025**: identical groups-as-boundary
mechanism, reused not forked. **ADR-020/020-1**: `harmony_config` /
OpenBao+Zitadel as the single config+secret entry point. **ADR-023**: all
Zitadel/OpenBao configuration lands via Scores. **ADR-011**: multi-tenant
cluster isolation, the workload-side counterpart of this identity-side split.

View File

@@ -57,6 +57,8 @@ Every ADR follows this structure:
| [024](./024-fleet-platform-capability-decomposition.md) | Fleet Platform Capability Decomposition | Accepted |
| [025](./025-fleet-device-secret-access.md) | Fleet Device Secret Access | Accepted |
| [025-3](./025-device-secret-access/025-3-groups-as-security-boundary.md) | Groups as the Security Boundary — Scale & Security Analysis | Accepted |
| [026](./026-application-lifecycle-cli.md) | Application Lifecycle CLI | Accepted |
| [027](./027-multi-tenant-identity.md) | Multi-Tenant Cloud Identity | Accepted |
## Contributing

View File

@@ -1,4 +1,10 @@
# Capabilities Catalog
# Capabilities Catalog (Topology)
> **Note:** this page lists **Topology** capabilities — what a *cluster* can
> do, exposed as trait bounds a `Score` requires. For **application**
> capabilities — add-ons you attach to an app with `.with(...)` (databases,
> 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`.

View File

@@ -0,0 +1,94 @@
# Application Capabilities — `.with(...)`
> **Status: in progress (feature branch).** The `harmony_app` application
> layer and capabilities described here are landing incrementally. The
> *decisions and rationale* live in
> [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,
monitoring, auth — by declaring it:
```rust
ComposeDeploy::from_dir("timesheet", "./app")?
.expose("frontend", "timesheet.example")
.with(Postgres::managed()) // a managed database
.with(Monitoring::new().alert(discord)); // a downtime alert
```
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
augmenting (and so derives names like `<app>-db`). Implement either method or
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.

159
docs/why-harmony.draft.md Normal file
View File

@@ -0,0 +1,159 @@
<!--
STATUS: PRIVATE DRAFT — not for publication.
This is a working articulation of motivation and differentiation, kept in the
working tree but uncommitted. The author controls if/when any of it is made
public. Companion to the essay "A Wonderful Future for Computers"
(nationtech.io/wonderful-future-for-computers), which makes the case for the
decentralized *hardware*; this makes the case for the *software* that makes it
humane and real.
-->
# Why Harmony
## A nagging feeling, continued
The first essay was about *where* computing should live: not in a handful of
enormous datacenters drinking water and land while sitting mostly idle, but
spread out — in homes, offices, clinics, community spaces — machines that heat
a room while they compute, infrastructure made to *sing*.
That's the body. This is about the nervous system.
Because decentralized hardware doesn't matter if the only people who can
operate it are the same large organizations that already run the centralized
kind. A micro-datacenter in a village is just expensive scrap if it takes a
platform team of ten and a year of glue to run a single service on it
reliably. The hardware vision is only liberating if the software to wield it
is within reach of ordinary people and small teams. **Infrastructure is the
root of nearly every software business, and right now the root is owned.** If
we want computing to serve people, we have to give people something that lets
them actually run it.
That something is Harmony.
## The motivation, plainly
I've spent a long career deploying distributed systems for some of the largest
companies in the world. The robustness those systems have — the ability to
survive a failed machine, a bad deploy, a 3 a.m. outage and keep serving — is
not magic. It's the product of expensive teams, hard-won patterns, and an
enormous amount of careful, repetitive work. It is real, and it is *rationed*.
Only the well-resourced get it.
I find that quietly unjust. Not because anyone is villainous — I don't think
the people at the top of the cloud are evil — but because the *system* is
shaped so that the value of computing flows, by default, upward and inward: to
whoever already owns the most infrastructure. Lock-in, pricing that rewards
scale, defaults that assume you're big — none of it is a conspiracy. It's just
an incline. Everything rolls toward the same few owners because the ground is
tilted.
This project comes from a simple, almost stubborn conviction: that I should
try to give the best of what I know back to the world, and that the best thing
I know is how to make infrastructure trustworthy. If that knowledge can be put
into code instead of into another consulting invoice, then the robustness that
today costs a platform team can become something every app simply *has* — for
free, by default, the way running water is supposed to be.
## What we believe
**Computing should serve people, not skew toward owners.** We are not against
the rich or the large — we will gladly serve a Fortune 100 and a rural clinic
with the same code. What we refuse is to *bake the skew in*. We remove the
tilt rather than tilt it the other way. The playing field, made actually
level, is already radical, because the ground today is sloped. So we serve
whoever comes, on their terms, rich or poor, and we let no one be the default.
**Robustness is a right, not a privilege.** The ability to deploy something and
have it actually keep working should not be reserved for organizations with an
SRE budget. It should come in the box.
**Freedom means you can leave.** If your infrastructure only runs on one
vendor's cloud, you don't own it; you rent your own existence. The same
definition should run on your laptop, a cluster in your office, bare metal in a
community space, or any cloud — and you should be able to move it whenever you
like, without a rewrite.
## What Harmony is
Harmony is one way to describe what you want running, and have it converge —
the same way — wherever you point it. You write your infrastructure as real
code, not as piles of YAML validated at runtime when it's already too late. A
*Score* says what should exist. A *Topology* is wherever you're putting it — a
laptop, a tenant cluster, bare metal — described by what it can actually *do*.
Ask a Topology for something it can't provide and the program doesn't compile;
the mistake is caught at your desk, not at 3 a.m. And a deploy isn't finished
when a command exits successfully — it's finished when the thing is actually
*working*, proven by a smoke test, the way a careful human would check before
walking away.
The same Score runs in all of those places. Only the Topology changes.
## How we differ — honestly, and without mysticism
There is no shortage of good infrastructure tools. We are not claiming to have
invented robustness or declarative systems; we stand on a generation of work
that did. What we've found is a *seam* that, as far as we can tell, no one is
standing in:
- Tools that turn bare metal into a running cluster stop at the cluster.
- Tools that deliver applications assume a cluster already exists.
- Tools that compose infrastructure live in the cloud and assume someone else
handled the metal.
- The one project closest to our "infrastructure and application in one real
language" idea targets only the big public clouds — and its company didn't
survive the attempt.
Nobody we can find spans the whole thing — **bare metal, to running
distributed service, to keeping it alive over time — in a single, coherent,
type-checked model that is honest about the physical machine.** That seam,
aimed first at *decentralized and sovereign* deployments rather than the
hyperscalers, is where Harmony lives.
The differences that make it possible aren't tricks; they're choices:
- **Capabilities are described as types**, so the compiler is a colleague that
catches whole classes of misconfiguration before anything runs.
- **One model from the metal to the service to day-two**, instead of four tools
with four mental models taped together.
- **Local equals production**, so "it worked on my machine" stops being a
confession and becomes a guarantee.
- **It's real code**, which means it's reviewable, testable, and yours — not a
configuration dialect you'll be fighting in two years.
## Where we honestly stand
We are early, and I want to be the first to say it. The incumbents are years
ahead of us on breadth — the sheer number of clouds and services they can talk
to is a real moat we have not crossed and may never fully cross. We're a small
project written in a language with a smaller pool of contributors than the
mainstream. Anyone who tells you a young tool has already won is selling
something.
So we don't intend to win by breadth. We intend to be *undeniably good* at one
thing first: running and operating real services on self-hosted and sovereign
clusters, with the robustness usually reserved for the big players, proven by
systems that actually work in the world. Our proof isn't a benchmark or a
funding round. It's a sovereign public-health data hub headed to a community in
Cameroon, with a university partner — a place where depending on a distant
hyperscaler isn't just expensive but a question of whose hands the data is in.
If Harmony can stand up *there*, robustly, owned locally, then the promise is
real. If it can't, no manifesto will save it.
## The ambition, kept humble
I don't think of this as building a company so much as removing a tax — the
quiet tax that infrastructure complexity levies on every person with an idea
and no platform team. If software is one of the great levers humans have for
helping each other, then the cost and lock-in of the ground it stands on is a
weight on that lever. Take the weight off, and you don't know in advance what
people will build — a greenhouse controller, a clinic's records, a town's own
small cloud — and that not-knowing is exactly the point. You don't pave a road
because you know who'll walk it. You pave it so they can.
That's all this is, really: an attempt to make the infrastructure sing — not
for a few, and not against them either, but for anyone who shows up — and to
leave behind a paved road to freedom for all software.
If we get it right, most people will never think about it. The water will just
run.

View File

@@ -0,0 +1,76 @@
<!--
STATUS: PRIVATE DRAFT — not for publication.
Aphoristic "manifesto cut" of why-harmony.draft.md. Companion to the essay
"A Wonderful Future for Computers". Author controls if/when published.
-->
# Why Harmony
The first essay was about *where* computing should live. This is about who gets
to run it.
Decentralized hardware means nothing if only the giants can operate it. A
micro-datacenter in a village is scrap if it takes ten engineers and a year of
glue to run one service reliably. Infrastructure is the root of nearly every
software business — and the root is owned.
I've spent a career making distributed systems survive failed machines, bad
deploys, 3 a.m. outages. That robustness isn't magic — it's expensive teams and
hard-won patterns. It is real, and it is *rationed*. Only the well-resourced
get it.
That's the quiet injustice. Not villainy — an incline. Lock-in, pricing that
rewards scale, defaults that assume you're big: nobody had to conspire. The
ground is tilted, and everything rolls toward the same few owners.
So the conviction is stubborn and simple: put what I know into code instead of
another invoice, and the robustness that costs a platform team becomes
something every app just *has* — for free, by default, like running water.
**What we believe.** Computing should serve people, not skew toward owners.
We're not against the rich or the large — we'll serve a Fortune 100 and a rural
clinic with the same code. We just refuse to bake the skew in. We remove the
tilt rather than reverse it; a level field is already radical when the ground
is sloped. Robustness is a right, not a privilege — deploy something and have
it keep working shouldn't require an SRE budget. And freedom means you can
leave: if it only runs on one vendor's cloud, you don't own it, you rent your
own existence.
**What it is.** You describe what should run, as real code, and it converges —
the same way — wherever you point it. Ask the target for something it can't do,
and it won't compile: the mistake is caught at your desk, not at 3 a.m. A
deploy isn't done when a command exits; it's done when the thing actually
works, proven, the way a careful human checks before walking away.
**How we differ — without mysticism.** We didn't invent robustness; we stand on
a generation that did. What we found is a seam no one occupies. Tools that turn
metal into a cluster stop at the cluster. Tools that ship apps assume a cluster
exists. Tools that compose infrastructure live in the cloud and assume someone
handled the metal. The project closest to "infrastructure and app in one real
language" served only the big clouds — and its company didn't survive. No one
spans the whole of it — bare metal, to running service, to keeping it alive —
in one coherent, type-checked model that's honest about the physical machine.
Aimed first at the sovereign and the self-hosted. That seam is where we live.
**Where we honestly stand.** Early. The incumbents are years ahead on breadth,
and that's a real moat. We're small. Anyone claiming a young tool has already
won is selling something. So we won't win by breadth — we'll be undeniably good
at one thing: running real services on self-hosted and sovereign clusters, with
the robustness usually reserved for the giants. Our proof isn't a benchmark or
a funding round. It's a sovereign health-data hub headed to a community in
Cameroon — a place where depending on a distant cloud is a question of whose
hands the data is in. If it stands up there, the promise is real. If it can't,
no manifesto will save it.
**The ambition, kept humble.** This is less a company than the removal of a tax
— the quiet tax infrastructure levies on everyone with an idea and no platform
team. Software is one of our great levers for helping each other; the cost and
lock-in of the ground beneath it is a weight on that lever. Take the weight off
and you can't predict what people will build. That's the point. You don't pave
a road because you know who'll walk it — you pave it so they can.
Make the infrastructure sing — not for a few, not against them, but for anyone
who shows up. A paved road to freedom for all software.
If we get it right, most people will never think about it. The water will just
run.

View File

@@ -0,0 +1,6 @@
# Deploy contexts for this example (checked in — zero config).
# k3d cluster create compose-local && kubectl create ns timesheet
# compose-deploy ship --context local
[contexts.local]
profile = "local"
k3d = "compose-local"

View File

@@ -4,38 +4,14 @@ edition = "2024"
version.workspace = true
readme.workspace = true
license.workspace = true
description = "Deploy a Java+React app to Kubernetes with Harmony by importing its existing docker-compose (publish + deploy, fleet pattern). See ADR-026."
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."
[lib]
name = "example_compose_java_react"
path = "src/lib.rs"
# `deploy` — converge the app onto a cluster (helm upgrade --install).
[[bin]]
name = "compose-deploy"
path = "src/main.rs"
# `publish` — build + push the per-service images and the hydrated chart.
[[bin]]
name = "compose-publish"
path = "src/bin/publish.rs"
[dependencies]
harmony = { path = "../../harmony" }
harmony_app = { path = "../../harmony_app" }
harmony_cli = { path = "../../harmony_cli" }
harmony_types = { path = "../../harmony_types" }
docker-compose-types = "0.24"
anyhow = { workspace = true }
async-trait = { workspace = true }
clap = { workspace = true }
fqdn = "0.5.2"
k8s-openapi = { workspace = true }
log = { workspace = true }
env_logger = { workspace = true }
serde = { workspace = true }
serde_yaml = { workspace = true }
tempfile = "3"
thiserror = { workspace = true }
tokio = { workspace = true, features = ["full"] }
toml = { workspace = true }

View File

@@ -1,9 +1,9 @@
# Deploy a Java + React app from its `docker-compose.yml`
Demonstrates deploying an existing containerized app to Kubernetes with
Harmony by **importing its `docker-compose.yml`** — no hand-written
manifests, no Argo. The compose file stays the source of truth for the
app's *base* shape; deploy-only concerns live in typed Rust.
Deploys an existing containerized app to Kubernetes with Harmony by
**importing its `docker-compose.yml`** — no hand-written manifests, no Argo.
The compose file stays the source of truth for the app's *base* shape;
deploy-only concerns are typed Rust.
```
app/ the "customer" project (their existing repo)
@@ -11,59 +11,70 @@ app/ the "customer" project (their existing repo)
backend/ (Java + SQLite, Dockerfile)
frontend/ (React + nginx, Dockerfile)
Harmony.toml identity only — the app name (ADR-026 §3)
.harmony/contexts.toml deploy targets (local / prod), checked in
src/
compose.rs import docker-compose → typed model (loud on the unsupported)
chart.rs model + deploy knobs → a hydrated helm chart (typed k8s)
publish.rs docker build/push each service + helm package/push the chart
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
main.rs `compose-deploy` bin
bin/publish `compose-publish` bin
deploy.rs ComposeDeploy — the declarative builder (impls HarmonyApp)
main.rs the whole app, declared
```
## 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
harmony_cli::app::app_main(app).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, Score = deploy knobs.** The importer reads only
images, ports, env, and volumes. Replicas, RWX storage class, ingress
host, and rolling strategy are *not* in compose (or `Harmony.toml`) —
they are typed fields on the deploy `Score` (ADR-026 §6). A behavioral
knob in compose or `Harmony.toml` is a defect.
- **Build / publish / deploy split — the fleet pattern.** `publish`
builds + pushes the per-service images and a hydrated helm chart;
`deploy` is a `Score` that `helm upgrade --install`s it and layers the
public Ingress. No `ApplicationScore`, no ArgoCD.
- **Same Scores local → prod.** Only the topology changes
(`K8sAnywhereTopology::from_env`): local k3d to test, a tenant in prod.
- **Loud, never lossy.** Bind mounts and unparseable ports are hard
errors; `depends_on`/`command`/`restart` are warned, never silently
dropped.
- **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 (build, don't push; render the chart from compose):
## Run it (local k3d)
```sh
cargo run -p example-compose-java-react --bin compose-publish -- --no-push
cargo run -p example-compose-java-react --bin compose-deploy -- \
--host timesheet.local --storage-class <rwx-class> --yes
k3d cluster create compose-local --servers 1 --wait
kubectl --context k3d-compose-local create namespace timesheet
cd examples/compose_java_react # so the CLI finds .harmony/contexts.toml
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
```
CD / prod (push to the registry, then install the published chart):
Running from elsewhere? Point at the contexts file with
`--config <path>/.harmony/contexts.toml` (a context is always required — there
is no default, so you never hit the wrong cluster).
```sh
compose-publish --version 0.1.0
compose-deploy --published --version 0.1.0 \
--namespace <tenant>-timesheet --host timesheet.<tenant>.example \
--storage-class cephfs --cluster-issuer letsencrypt --yes
```
**Production** is the same verbs against a prod context (cluster + creds
brokered from OpenBao); see the `folk_ci_deploy` setup for the full tenant
wiring.
`compose-deploy --help` lists every deploy knob.
## Storage note (SQLite)
## ⚠️ SQLite + RollingUpdate + RWX
This demo ships `--rolling` (RollingUpdate) with a `ReadWriteMany` PVC, as
requested. During a rollout two pods briefly share the same SQLite file,
which SQLite does not make safe for concurrent writers over a networked
filesystem. It is tolerable only for an effectively single-writer,
low-traffic app. For true safe zero-downtime, either deploy with
`--recreate` (one writer at a time, brief downtime) or migrate the store
to Postgres.
`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,56 +0,0 @@
//! `compose-publish` — build + push the per-service images and the
//! hydrated chart from the imported compose. `docker`/`helm` must be on
//! PATH and logged in to the registry.
use std::path::PathBuf;
use anyhow::{Context, Result};
use clap::Parser;
use example_compose_java_react::{ComposeApp, DeployConfig, HarmonyManifest, publish::publish};
#[derive(Parser, Debug)]
#[command(
name = "compose-publish",
about = "Build + push the app images and chart"
)]
struct Cli {
#[arg(long, default_value = env!("CARGO_MANIFEST_DIR"))]
project_dir: PathBuf,
#[arg(long)]
compose_dir: Option<PathBuf>,
#[arg(long, default_value = "hub.nationtech.io")]
registry: String,
#[arg(long, default_value = "harmony")]
project: String,
#[arg(long, default_value = "0.1.0")]
version: String,
/// Build + package only; skip both pushes (local k3d smoke-test).
#[arg(long)]
no_push: bool,
}
fn main() -> Result<()> {
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init();
let cli = Cli::parse();
let manifest = HarmonyManifest::from_dir(&cli.project_dir)?;
let compose_dir = cli
.compose_dir
.unwrap_or_else(|| cli.project_dir.join("app"));
let app = ComposeApp::from_dir(&compose_dir).context("importing docker-compose")?;
let deploy = DeployConfig {
app_name: manifest.app.name,
chart_version: cli.version.clone(),
registry: cli.registry,
project: cli.project,
version: cli.version,
storage_class: None,
volume_size: "1Gi".to_string(),
replicas: 1,
rolling: true,
};
publish(&app, &deploy, !cli.no_push)
}

View File

@@ -1,40 +0,0 @@
//! Deploy a Java+React app to Kubernetes by importing its existing
//! `docker-compose.yml` (ADR-026). The compose file is the source of
//! truth for the app's base shape; deploy-only knobs live in
//! [`DeployConfig`]/[`ComposeAppScore`]. Build/publish/deploy follow the
//! fleet pattern — no `ApplicationScore`, no ArgoCD.
pub mod chart;
pub mod compose;
pub mod publish;
pub mod score;
pub use chart::DeployConfig;
pub use compose::ComposeApp;
pub use score::{ComposeAppScore, PublicEndpoint, PublishedChart};
use std::path::Path;
use anyhow::{Context, Result};
use serde::Deserialize;
/// Identity-only project manifest (ADR-026 §3): the app *name*, nothing
/// behavioral. A behavioral knob here is a defect — it belongs in a Score.
#[derive(Debug, Deserialize)]
pub struct HarmonyManifest {
pub app: AppIdentity,
}
#[derive(Debug, Deserialize)]
pub struct AppIdentity {
pub name: String,
}
impl HarmonyManifest {
pub fn from_dir(dir: &Path) -> Result<Self> {
let path = dir.join("Harmony.toml");
let raw = std::fs::read_to_string(&path)
.with_context(|| format!("reading {}", path.display()))?;
toml::from_str(&raw).with_context(|| format!("parsing {}", path.display()))
}
}

View File

@@ -1,127 +1,23 @@
//! `compose-deploy` — converge the compose-imported app onto a cluster
//! (`helm upgrade --install`). The same Scores run against local k3d and
//! a remote tenant; only the topology (selected by env) changes.
//! `compose-deploy` — deploy this Java+React app to any context.
//!
//! The whole app, declared:
//!
//! compose-deploy ship --context local # build + import to k3d + deploy
//! compose-deploy status --context local
//! compose-deploy logs --context local
//!
//! Contexts live in `.harmony/contexts.toml`. `ship`/`deploy`/`status`/`logs`
//! and the context model all come from `harmony_app` — this file only
//! *declares* the app.
use std::path::PathBuf;
use anyhow::{Context, Result};
use clap::Parser;
use harmony::inventory::Inventory;
use harmony::topology::K8sAnywhereTopology;
use harmony_cli::Args as HarmonyCliArgs;
use example_compose_java_react::{
ComposeApp, ComposeAppScore, DeployConfig, HarmonyManifest, PublicEndpoint, PublishedChart,
};
#[derive(Parser, Debug)]
#[command(
name = "compose-deploy",
about = "Deploy a docker-compose app to k8s via Harmony"
)]
struct Cli {
/// Project root holding `Harmony.toml` (defaults to this example).
#[arg(long, default_value = env!("CARGO_MANIFEST_DIR"))]
project_dir: PathBuf,
/// Directory holding `docker-compose.yml` (defaults to `<project>/app`).
#[arg(long)]
compose_dir: Option<PathBuf>,
#[arg(long, default_value = "timesheet")]
namespace: String,
#[arg(long, default_value = "hub.nationtech.io")]
registry: String,
#[arg(long, default_value = "harmony")]
project: String,
#[arg(long, default_value = "0.1.0")]
version: String,
/// RWX storage class (e.g. ceph `cephfs`). Omit for the cluster default.
#[arg(long)]
storage_class: Option<String>,
#[arg(long, default_value = "1Gi")]
volume_size: String,
#[arg(long, default_value_t = 2)]
replicas: i32,
/// Recreate instead of RollingUpdate — safe for single-writer sqlite.
#[arg(long)]
recreate: bool,
/// Install the published OCI chart instead of rendering from compose.
#[arg(long)]
published: bool,
/// Compose service to expose via Ingress.
#[arg(long, default_value = "frontend")]
public_service: String,
/// Ingress host; omit to keep the app cluster-internal.
#[arg(long)]
host: Option<String>,
/// cert-manager ClusterIssuer for TLS; omit for plain HTTP.
#[arg(long)]
cluster_issuer: Option<String>,
#[command(flatten)]
harmony_cli: HarmonyCliArgs,
}
use harmony_app::{ComposeDeploy, Postgres};
#[tokio::main]
async fn main() -> Result<()> {
harmony_cli::cli_logger::init();
let cli = Cli::parse();
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
let manifest = HarmonyManifest::from_dir(&cli.project_dir)?;
let compose_dir = cli
.compose_dir
.unwrap_or_else(|| cli.project_dir.join("app"));
let app = ComposeApp::from_dir(&compose_dir).context("importing docker-compose")?;
let deploy = DeployConfig {
app_name: manifest.app.name.clone(),
chart_version: cli.version.clone(),
registry: cli.registry.clone(),
project: cli.project.clone(),
version: cli.version.clone(),
storage_class: cli.storage_class,
volume_size: cli.volume_size,
replicas: cli.replicas,
rolling: !cli.recreate,
};
// The host is a deploy-only knob; the port is read from the compose
// service so it stays the single source of truth for the app shape.
let public_endpoint = cli.host.map(|host| -> Result<PublicEndpoint> {
let svc = app
.service(&cli.public_service)
.with_context(|| format!("no compose service '{}'", cli.public_service))?;
let port = svc
.ports
.first()
.with_context(|| format!("service '{}' exposes no port", cli.public_service))?
.container;
Ok(PublicEndpoint {
service: cli.public_service.clone(),
port,
host,
cluster_issuer: cli.cluster_issuer,
})
});
let public_endpoint = public_endpoint.transpose()?;
let score = ComposeAppScore {
namespace: cli.namespace,
release_name: manifest.app.name,
published_chart: cli.published.then_some(PublishedChart {
registry: cli.registry,
project: cli.project,
version: cli.version,
}),
public_endpoint,
app,
deploy,
};
harmony_cli::run(
Inventory::autoload(),
K8sAnywhereTopology::from_env(),
vec![Box::new(score)],
Some(cli.harmony_cli),
)
.await
.map_err(|e| anyhow::anyhow!("{e}"))
harmony_cli::app::app_main(app).await
}

View File

@@ -1,96 +0,0 @@
//! Build + push the per-service images and the hydrated chart.
//!
//! The fleet `release.rs` pattern, generalized over the compose
//! services: each service that builds from source is built then pushed
//! with docker; prebuilt `image:` services are left alone; then the
//! chart is hydrated, `helm package`d, and pushed to the OCI registry.
//! Plain `anyhow` — this is binary glue, not library API.
use std::path::{Path, PathBuf};
use std::process::Command;
use anyhow::{Context, Result, bail};
use crate::chart::{DeployConfig, build_chart, service_image};
use crate::compose::ComposeApp;
/// Build + push every built service image and the chart. With
/// `push = false`, builds + packages only (a local k3d smoke-test).
/// `docker` and `helm` must be on PATH and logged in to the registry.
pub fn publish(app: &ComposeApp, cfg: &DeployConfig, push: bool) -> Result<()> {
for svc in &app.services {
let Some(context) = &svc.build_context else {
log::info!("service '{}' uses prebuilt image, skipping build", svc.name);
continue;
};
let image = service_image(cfg, svc);
log::info!("docker build {image}");
let mut cmd = Command::new("docker");
cmd.arg("build").arg("-t").arg(&image);
if let Some(dockerfile) = &svc.dockerfile {
cmd.arg("-f").arg(context.join(dockerfile));
}
cmd.arg(context);
run(cmd)?;
if push {
log::info!("docker push {image}");
run({
let mut c = Command::new("docker");
c.arg("push").arg(&image);
c
})?;
}
}
let tmp = tempfile::tempdir().context("chart tempdir")?;
let chart_dir =
build_chart(app, cfg, tmp.path()).map_err(|e| anyhow::anyhow!("hydrate chart: {e}"))?;
let tgz = helm_package(&chart_dir, tmp.path())?;
if push {
let oci_repo = format!("oci://{}/{}", cfg.registry, cfg.project);
log::info!("helm push {} {oci_repo}", tgz.display());
run({
let mut c = Command::new("helm");
c.arg("push").arg(&tgz).arg(&oci_repo);
c
})?;
}
log::info!(
"published app={} version={} pushed={push}",
cfg.app_name,
cfg.version
);
Ok(())
}
fn run(mut cmd: Command) -> Result<()> {
let status = cmd
.status()
.with_context(|| format!("spawn {cmd:?} (is it installed and in PATH?)"))?;
if !status.success() {
bail!("`{cmd:?}` failed ({status})");
}
Ok(())
}
fn helm_package(chart_dir: &Path, out_dir: &Path) -> Result<PathBuf> {
let output = Command::new("helm")
.args(["package", path_str(chart_dir)?, "-d", path_str(out_dir)?])
.output()
.context("spawn helm package")?;
if !output.status.success() {
bail!("helm package failed ({})", output.status);
}
// helm prints "…saved it to: <path>"; the last token is the path.
String::from_utf8_lossy(&output.stdout)
.split_whitespace()
.last()
.map(PathBuf::from)
.context("helm package printed no path")
}
fn path_str(p: &Path) -> Result<&str> {
p.to_str()
.with_context(|| format!("path not utf-8: {}", p.display()))
}

View File

@@ -3,7 +3,7 @@ use std::time::Duration;
use k8s_openapi::api::core::v1::Pod;
use kube::{
Error,
api::{Api, AttachParams, ListParams},
api::{Api, AttachParams, ListParams, LogParams},
error::DiscoveryError,
runtime::reflector::Lookup,
};
@@ -32,6 +32,24 @@ impl ExecOutput {
}
impl K8sClient {
/// Recent logs for a pod. `tail_lines` bounds the output (None = all).
pub async fn pod_logs(
&self,
namespace: &str,
pod: &str,
tail_lines: Option<i64>,
) -> Result<String, Error> {
let api: Api<Pod> = Api::namespaced(self.client.clone(), namespace);
api.logs(
pod,
&LogParams {
tail_lines,
..Default::default()
},
)
.await
}
pub async fn get_pod(&self, name: &str, namespace: Option<&str>) -> Result<Option<Pod>, Error> {
let api: Api<Pod> = match namespace {
Some(ns) => Api::namespaced(self.client.clone(), ns),

View File

@@ -1139,6 +1139,22 @@ pub struct K8sAnywhereConfig {
}
impl K8sAnywhereConfig {
/// Talk to an existing cluster via an explicit kubeconfig file — no env
/// vars, no local-k3d management. For programmatic callers (the app/UX
/// layer, TUI, web) that resolve the target themselves and must not
/// mutate process-global env to select a cluster.
pub fn kubeconfig(path: impl Into<String>, k8s_context: Option<String>) -> Self {
Self {
kubeconfig: Some(path.into()),
use_system_kubeconfig: false,
autoinstall: false,
use_local_k3d: false,
harmony_profile: "dev".to_string(),
k8s_context,
public_domain: None,
}
}
/// Reads an environment variable `env_var` and parses its content :
/// Comma-separated `key=value` pairs, e.g.,
/// `kubeconfig=/path/to/primary.kubeconfig,context=primary-ctx`

View File

@@ -6,6 +6,12 @@ use serde::Serialize;
use crate::{executors::ExecutorError, topology::Topology};
/// **Deprecated** (see [`ApplicationScore`](super::ApplicationScore)). Superseded
/// by the `harmony_app` application layer + `.with(...)` capabilities (ADR-026);
/// see `docs/guides/application-capabilities.md`. The trait itself is not yet
/// `#[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,
/// ContinuousIntegration, ContinuousDelivery
#[async_trait]

View File

@@ -1,3 +1,7 @@
// This whole feature is deprecated (see the `Monitoring` struct); silence
// self-references to the deprecated types within the module.
#![allow(deprecated)]
use crate::modules::application::{
Application, ApplicationFeature, InstallationError, InstallationOutcome,
};
@@ -32,6 +36,12 @@ use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use std::sync::Arc;
/// **Deprecated.** Use `harmony_app::capabilities::Monitoring` via
/// `.with(Monitoring::new().alert(...))` (ADR-026). See
/// `docs/guides/application-capabilities.md`.
#[deprecated(
note = "Use harmony_app::capabilities::Monitoring (.with(...)). See docs/guides/application-capabilities.md"
)]
#[derive(Debug, Clone)]
pub struct Monitoring {
pub application: Arc<dyn Application>,

View File

@@ -1,3 +1,7 @@
// Deprecated feature (see the `PackagingDeployment` struct); silence
// self-references to the deprecated type within the module.
#![allow(deprecated)]
use std::{io::Write, process::Command, sync::Arc};
use async_trait::async_trait;
@@ -46,6 +50,12 @@ use crate::{
/// - Harbor as artifact registru
/// - ArgoCD to install/upgrade/rollback/inspect k8s resources
/// - Kubernetes for runtime orchestration
/// **Deprecated.** Use the `harmony_app` application layer — `ComposeDeploy`
/// publishes + deploys, capabilities attach add-ons (ADR-026). See
/// `docs/guides/application-capabilities.md`.
#[deprecated(
note = "Use harmony_app: ComposeDeploy + .with(...) capabilities. See docs/guides/application-capabilities.md"
)]
#[derive(Debug, Default, Clone)]
pub struct PackagingDeployment<A: OCICompliant + HelmPackage> {
pub application: Arc<A>,

View File

@@ -20,6 +20,14 @@ use crate::{score::Score, topology::Topology};
use super::{Application, ApplicationFeature, ApplicationInterpret, HelmPackage, OCICompliant};
/// **Deprecated.** Use the `harmony_app` application layer —
/// `ComposeDeploy`/`HarmonyApp` + `.with(...)` capabilities (ADR-026). See
/// `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)]
pub struct ApplicationScore<A: Application + Serialize, T: Topology + Clone + Serialize>
where
@@ -29,6 +37,7 @@ where
pub application: Arc<A>,
}
#[allow(deprecated)] // the Score impl for the deprecated type stays until removal
impl<
A: Application + Serialize + Clone + 'static,
T: Topology + std::fmt::Debug + Clone + Serialize + 'static,

View File

@@ -6,8 +6,8 @@ pub use admin_auth::{ADMIN_API_SCOPES, DeviceCodeError, DeviceCodeFlowConfig, de
pub use device_groups::ZitadelDeviceGroups;
pub use setup::{
MachineKeyType, MintedDeviceCredentials, ZitadelApiApp, ZitadelAppType, ZitadelApplication,
ZitadelClientConfig, ZitadelMachineUser, ZitadelRole, ZitadelScheme, ZitadelSetupScore,
mint_device_credentials,
ZitadelClientConfig, ZitadelClientIdExportScore, ZitadelMachineUser, ZitadelRole,
ZitadelScheme, ZitadelSetupScore, mint_device_credentials,
};
use harmony_k8s::KubernetesDistribution;

View File

@@ -79,6 +79,12 @@ pub enum ZitadelAppType {
/// OAuth 2.0 Device Authorization Grant (RFC 8628).
/// For CLI tools, SSH sessions, containers, and headless environments.
DeviceCode,
/// OIDC Authorization Code + PKCE — a public client (no secret), for
/// SPAs / web frontends (`appType=USER_AGENT`, `authMethod=NONE`).
WebPkce {
redirect_uris: Vec<String>,
post_logout_redirect_uris: Vec<String>,
},
}
/// An OIDC application to create in a Zitadel project.
@@ -930,16 +936,15 @@ impl ZitadelSetupInterpret {
})
}
async fn create_device_code_app(
/// POST an OIDC app `body` and return its `clientId`. Shared by the
/// DeviceCode and WebPkce builders.
async fn create_oidc_app(
&self,
client: &reqwest::Client,
pat: &str,
project_id: &str,
app_name: &str,
body: serde_json::Value,
) -> Result<String, String> {
let mut body = self.device_code_oidc_config_body();
body["name"] = serde_json::json!(app_name);
let resp = self
.post(
client,
@@ -965,6 +970,51 @@ impl ZitadelSetupInterpret {
.ok_or_else(|| "No clientId in app response".to_string())
}
async fn create_device_code_app(
&self,
client: &reqwest::Client,
pat: &str,
project_id: &str,
app_name: &str,
) -> Result<String, String> {
let mut body = self.device_code_oidc_config_body();
body["name"] = serde_json::json!(app_name);
self.create_oidc_app(client, pat, project_id, body).await
}
/// OIDC config for a PKCE public client (SPA / web frontend). Validated
/// against a live Zitadel: `USER_AGENT` + auth method `NONE` is
/// Authorization Code + PKCE with no client secret.
fn web_pkce_oidc_config_body(
&self,
redirect_uris: &[String],
post_logout_redirect_uris: &[String],
) -> serde_json::Value {
serde_json::json!({
"redirectUris": redirect_uris,
"postLogoutRedirectUris": post_logout_redirect_uris,
"responseTypes": ["OIDC_RESPONSE_TYPE_CODE"],
"grantTypes": ["OIDC_GRANT_TYPE_AUTHORIZATION_CODE", "OIDC_GRANT_TYPE_REFRESH_TOKEN"],
"appType": "OIDC_APP_TYPE_USER_AGENT",
"authMethodType": "OIDC_AUTH_METHOD_TYPE_NONE",
"idTokenUserinfoAssertion": true
})
}
async fn create_web_pkce_app(
&self,
client: &reqwest::Client,
pat: &str,
project_id: &str,
app_name: &str,
redirect_uris: &[String],
post_logout_redirect_uris: &[String],
) -> Result<String, String> {
let mut body = self.web_pkce_oidc_config_body(redirect_uris, post_logout_redirect_uris);
body["name"] = serde_json::json!(app_name);
self.create_oidc_app(client, pat, project_id, body).await
}
/// Reconciles an existing app's OIDC config to the desired shape.
/// `ZitadelSetupScore` owns the app definition declaratively, so any
/// drift (manual UI edits, schema changes between harmony versions)
@@ -977,12 +1027,14 @@ impl ZitadelSetupInterpret {
/// config byte-for-byte. We treat that as the success case — the
/// declared state already holds — instead of letting reconciliation
/// fail every re-run after the first.
async fn update_device_code_oidc_config(
/// PUT an app's OIDC config to `body`. Shared by DeviceCode/WebPkce.
async fn update_oidc_config(
&self,
client: &reqwest::Client,
pat: &str,
project_id: &str,
app_id: &str,
body: serde_json::Value,
) -> Result<(), String> {
let resp = self
.put(
@@ -990,7 +1042,7 @@ impl ZitadelSetupInterpret {
&format!("/management/v1/projects/{project_id}/apps/{app_id}/oidc_config"),
)
.bearer_auth(pat)
.json(&self.device_code_oidc_config_body())
.json(&body)
.send()
.await
.map_err(|e| format!("Failed to update OIDC config: {e}"))?;
@@ -1007,6 +1059,23 @@ impl ZitadelSetupInterpret {
Err(format!("Update OIDC config failed: {body}"))
}
async fn update_device_code_oidc_config(
&self,
client: &reqwest::Client,
pat: &str,
project_id: &str,
app_id: &str,
) -> Result<(), String> {
self.update_oidc_config(
client,
pat,
project_id,
app_id,
self.device_code_oidc_config_body(),
)
.await
}
async fn ensure_app(
&self,
client: &reqwest::Client,
@@ -1036,6 +1105,19 @@ impl ZitadelSetupInterpret {
.update_device_code_oidc_config(client, pat, &project_id, &found.id)
.await
.map_err(InterpretError::new)?,
ZitadelAppType::WebPkce {
redirect_uris,
post_logout_redirect_uris,
} => self
.update_oidc_config(
client,
pat,
&project_id,
&found.id,
self.web_pkce_oidc_config_body(redirect_uris, post_logout_redirect_uris),
)
.await
.map_err(InterpretError::new)?,
}
config
.apps
@@ -1048,6 +1130,20 @@ impl ZitadelSetupInterpret {
.create_device_code_app(client, pat, &project_id, &app.app_name)
.await
.map_err(InterpretError::new)?,
ZitadelAppType::WebPkce {
redirect_uris,
post_logout_redirect_uris,
} => self
.create_web_pkce_app(
client,
pat,
&project_id,
&app.app_name,
redirect_uris,
post_logout_redirect_uris,
)
.await
.map_err(InterpretError::new)?,
};
info!(
@@ -1869,10 +1965,161 @@ impl<T: Topology + K8sclient> Interpret<T> for ZitadelSetupInterpret {
}
}
// ---------------------------------------------------------------------------
// ZitadelClientIdExportScore — publish a provisioned client_id in-cluster
// ---------------------------------------------------------------------------
/// Reads a provisioned OIDC app's `client_id` from the Zitadel client cache
/// (written by [`ZitadelSetupScore`] earlier in the same run) and writes it
/// to a ConfigMap, so the deployed app can reference its client_id by
/// `configMapKeyRef`. The client_id is non-secret (PKCE), so a ConfigMap is
/// the right home. This is the Zitadel→app bridge the `ZitadelAuth`
/// capability composes after provisioning.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ZitadelClientIdExportScore {
/// OIDC app name whose client_id to export (matches the provisioned app).
pub app_name: String,
pub configmap_name: String,
pub namespace: String,
/// Key under which to store the client_id (e.g. `OIDC_CLIENT_ID`).
pub key: String,
}
impl<T: Topology + K8sclient> Score<T> for ZitadelClientIdExportScore {
fn name(&self) -> String {
format!("ZitadelClientIdExport({})", self.app_name)
}
fn create_interpret(&self) -> Box<dyn Interpret<T>> {
Box::new(ZitadelClientIdExportInterpret {
score: self.clone(),
})
}
}
#[derive(Debug, Clone)]
struct ZitadelClientIdExportInterpret {
score: ZitadelClientIdExportScore,
}
#[async_trait]
impl<T: Topology + K8sclient> Interpret<T> for ZitadelClientIdExportInterpret {
async fn execute(
&self,
inventory: &Inventory,
topology: &T,
) -> Result<Outcome, InterpretError> {
use crate::modules::k8s::resource::K8sResourceScore;
use k8s_openapi::api::core::v1::ConfigMap;
use k8s_openapi::apimachinery::pkg::apis::meta::v1::ObjectMeta;
use std::collections::BTreeMap;
let s = &self.score;
let config = ZitadelClientConfig::load().ok_or_else(|| {
InterpretError::new(
"Zitadel client cache not found — run ZitadelSetupScore first".to_string(),
)
})?;
let client_id = config.client_id(&s.app_name).ok_or_else(|| {
InterpretError::new(format!(
"client_id for app '{}' not in cache — was it provisioned?",
s.app_name
))
})?;
let mut data = BTreeMap::new();
data.insert(s.key.clone(), client_id.clone());
let cm = ConfigMap {
metadata: ObjectMeta {
name: Some(s.configmap_name.clone()),
namespace: Some(s.namespace.clone()),
..Default::default()
},
data: Some(data),
..Default::default()
};
K8sResourceScore::single(cm, Some(s.namespace.clone()))
.interpret(inventory, topology)
.await?;
Ok(Outcome::success(format!(
"Exported client_id for '{}' to ConfigMap {}/{}",
s.app_name, s.namespace, s.configmap_name
)))
}
fn get_name(&self) -> InterpretName {
InterpretName::Custom("ZitadelClientIdExport")
}
fn get_version(&self) -> Version {
Version::from("0.1.0").expect("static version")
}
fn get_status(&self) -> InterpretStatus {
InterpretStatus::QUEUED
}
fn get_children(&self) -> Vec<Id> {
vec![]
}
}
#[cfg(test)]
mod tests {
use super::*;
/// Live validation against a local Zitadel (docker) + k3d. Provisions a
/// PKCE app and exports its client_id to a ConfigMap, then the caller
/// checks both via the Zitadel API / `kubectl`. Run:
/// HARMONY_TEST_KUBECONFIG=<k3d kubeconfig> cargo test -p harmony \
/// pkce_provision_and_export_live -- --ignored --nocapture
#[tokio::test]
#[ignore = "needs live Zitadel at :8080 + k3d with iam-admin-pat seeded in ns timesheet"]
async fn pkce_provision_and_export_live() {
use crate::score::Score;
use crate::topology::{K8sAnywhereConfig, K8sAnywhereTopology, Topology};
let kubeconfig =
std::env::var("HARMONY_TEST_KUBECONFIG").expect("set HARMONY_TEST_KUBECONFIG");
let topo =
K8sAnywhereTopology::with_config(K8sAnywhereConfig::kubeconfig(kubeconfig, None));
topo.ensure_ready().await.expect("prepare topology");
let inv = crate::inventory::Inventory::autoload();
let setup = ZitadelSetupScore {
host: "localhost".to_string(),
scheme: Default::default(),
port: None,
skip_tls: false,
endpoint: Some("http://localhost:8080".to_string()),
namespace: "timesheet".to_string(),
admin_org_id: None,
applications: vec![ZitadelApplication {
project_name: "folk".to_string(),
app_name: "timesheet".to_string(),
app_type: ZitadelAppType::WebPkce {
redirect_uris: vec!["https://timesheet.nationtech.io/callback".to_string()],
post_logout_redirect_uris: vec![],
},
}],
api_apps: vec![],
roles: vec![],
machine_users: vec![],
groups_claim_action: false,
};
setup
.interpret(&inv, &topo)
.await
.expect("provision PKCE app");
let export = ZitadelClientIdExportScore {
app_name: "timesheet".to_string(),
configmap_name: "timesheet-oidc".to_string(),
namespace: "timesheet".to_string(),
key: "OIDC_CLIENT_ID".to_string(),
};
export
.interpret(&inv, &topo)
.await
.expect("export client_id");
}
#[test]
fn user_grant_key_round_trips_uniquely() {
let k1 = ZitadelClientConfig::user_grant_key("alice", "fleet");

25
harmony_app/Cargo.toml Normal file
View File

@@ -0,0 +1,25 @@
[package]
name = "harmony_app"
edition = "2024"
version.workspace = true
readme.workspace = true
license.workspace = true
[dependencies]
harmony = { path = "../harmony" }
harmony-k8s = { path = "../harmony-k8s" }
harmony_config = { path = "../harmony_config" }
harmony_types = { path = "../harmony_types" }
async-trait.workspace = true
anyhow.workspace = true
tokio.workspace = true
serde = { workspace = true }
serde_yaml = { workspace = true }
schemars = "0.8"
tempfile.workspace = true
toml.workspace = true
log.workspace = true
k8s-openapi.workspace = true
thiserror.workspace = true
docker-compose-types = "0.24"
fqdn = "0.5.2"

171
harmony_app/src/app.rs Normal file
View File

@@ -0,0 +1,171 @@
//! The [`HarmonyApp`] trait and the lifecycle **verbs** as plain async
//! functions returning structured results — the UI-agnostic core every
//! front-end (CLI, TUI, web) drives.
use anyhow::{Result, anyhow};
use async_trait::async_trait;
use harmony::inventory::Inventory;
use harmony::maestro::Maestro;
use harmony::score::Score;
use harmony::topology::Topology;
use k8s_openapi::api::apps::v1::Deployment;
use k8s_openapi::api::core::v1::Pod;
use crate::context::AppContext;
/// Identity-only description of an app (ADR-026 §3): what it's called and
/// where it runs. No behavioral knobs — those come from the context/profile.
#[derive(Debug, Clone)]
pub struct AppIdentity {
pub name: String,
pub namespace: String,
}
/// A deployable application, **generic over its target `Topology`**. An app
/// crate implements this once; every UI drives it through the verbs below.
/// The Scores it returns carry their own capability bounds, so an app is
/// deployable to any topology that can host them — a missing capability is a
/// compile error, not a runtime surprise.
#[async_trait]
pub trait HarmonyApp<T: Topology>: Send + Sync {
fn identity(&self) -> AppIdentity;
/// The desired state for this context — the app composes its Scores,
/// branching on `ctx.profile()`. Called by `deploy`/`ship`.
async fn scores(&self, ctx: &AppContext) -> Result<Vec<Box<dyn Score<T>>>>;
/// Build + distribute images for `ctx` (push to a registry, or import to
/// k3d). Default: nothing to build. Called by `ship`.
async fn publish(&self, _ctx: &AppContext) -> Result<()> {
Ok(())
}
}
// ---- structured results (rendered by the front-end, never printed here) ----
#[derive(Debug, Clone, serde::Serialize)]
pub struct StepOutcome {
pub name: String,
pub message: String,
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct DeployReport {
pub steps: Vec<StepOutcome>,
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct WorkloadStatus {
pub name: String,
pub ready: i32,
pub desired: i32,
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct StatusReport {
pub namespace: String,
pub workloads: Vec<WorkloadStatus>,
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct PodLogs {
pub pod: String,
pub logs: String,
}
// ---- the verbs ----
/// Converge the app's Scores onto `topology` (no build).
pub async fn deploy<T: Topology + Send + Sync + 'static>(
app: &dyn HarmonyApp<T>,
topology: T,
ctx: &AppContext,
) -> Result<DeployReport> {
let scores = app.scores(ctx).await?;
let to_run: Vec<Box<dyn Score<T>>> = scores.iter().map(|s| s.clone_box()).collect();
let mut maestro = Maestro::new_without_initialization(Inventory::autoload(), topology);
maestro.register_all(scores);
maestro
.prepare_topology()
.await
.map_err(|e| anyhow!("topology preparation failed: {e}"))?;
let mut steps = Vec::new();
for s in to_run {
let name = s.name();
let outcome = maestro
.interpret(s)
.await
.map_err(|e| anyhow!("{name}: {e}"))?;
steps.push(StepOutcome {
name,
message: outcome.message,
});
}
Ok(DeployReport { steps })
}
/// Build + publish, then deploy (ADR-026 §4).
pub async fn ship<T: Topology + Send + Sync + 'static>(
app: &dyn HarmonyApp<T>,
topology: T,
ctx: &AppContext,
) -> Result<DeployReport> {
app.publish(ctx).await?;
deploy(app, topology, ctx).await
}
/// Workload readiness in the app's namespace (operational, read-only).
pub async fn status<T: Topology>(
app: &dyn HarmonyApp<T>,
ctx: &AppContext,
) -> Result<StatusReport> {
let id = app.identity();
let client = ctx.k8s_client().await?;
let deployments = client
.list_resources::<Deployment>(Some(&id.namespace), None)
.await
.map_err(|e| anyhow!("listing deployments: {e}"))?;
let workloads = deployments
.items
.iter()
.map(|d| WorkloadStatus {
name: d.metadata.name.clone().unwrap_or_default(),
desired: d.spec.as_ref().and_then(|s| s.replicas).unwrap_or(0),
ready: d
.status
.as_ref()
.and_then(|s| s.ready_replicas)
.unwrap_or(0),
})
.collect();
Ok(StatusReport {
namespace: id.namespace,
workloads,
})
}
/// Recent logs from each pod in the app's namespace (operational, read-only).
pub async fn logs<T: Topology>(
app: &dyn HarmonyApp<T>,
ctx: &AppContext,
tail: Option<i64>,
) -> Result<Vec<PodLogs>> {
let id = app.identity();
let client = ctx.k8s_client().await?;
let pods = client
.list_resources::<Pod>(Some(&id.namespace), None)
.await
.map_err(|e| anyhow!("listing pods: {e}"))?;
let mut out = Vec::new();
for p in pods.items {
let pod = p.metadata.name.unwrap_or_default();
let logs = client
.pod_logs(&id.namespace, &pod, tail)
.await
.unwrap_or_else(|e| format!("<logs unavailable: {e}>"));
out.push(PodLogs { pod, logs });
}
Ok(out)
}

View File

@@ -0,0 +1,383 @@
//! 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(),
},
}],
api_apps: vec![],
roles: vec![],
machine_users: vec![],
groups_claim_action: false,
};
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");
}
}

View File

@@ -2,8 +2,9 @@
//!
//! Typed `k8s_openapi` resources serialized at build time (ADR-018, the
//! fleet operator chart pattern) — no `{{ .Values }}` templating. One
//! Deployment + Service per compose service, one RWX PVC per mounted
//! named volume. Ingress is layered at deploy time by the
//! Deployment + Service per compose service, one PVC per mounted named
//! volume (RWX under RollingUpdate, RWO under Recreate). Ingress is
//! layered at deploy time by the
//! [`Score`](crate::score), not baked here, so the host stays a deploy
//! knob.
@@ -14,15 +15,17 @@ use k8s_openapi::api::apps::v1::{
Deployment, DeploymentSpec, DeploymentStrategy, RollingUpdateDeployment,
};
use k8s_openapi::api::core::v1::{
Container, ContainerPort, EnvVar, PersistentVolumeClaim, PersistentVolumeClaimSpec,
PersistentVolumeClaimVolumeSource, PodSpec, PodTemplateSpec, Service, ServicePort, ServiceSpec,
Volume, VolumeMount, VolumeResourceRequirements,
Container, ContainerPort, EnvFromSource, EnvVar, PersistentVolumeClaim,
PersistentVolumeClaimSpec, PersistentVolumeClaimVolumeSource, PodSpec, PodTemplateSpec,
SecretEnvSource, Service, ServicePort, ServiceSpec, Volume, VolumeMount,
VolumeResourceRequirements,
};
use k8s_openapi::apimachinery::pkg::api::resource::Quantity;
use k8s_openapi::apimachinery::pkg::apis::meta::v1::LabelSelector;
use k8s_openapi::apimachinery::pkg::apis::meta::v1::ObjectMeta;
use k8s_openapi::apimachinery::pkg::util::intstr::IntOrString;
use crate::Profile;
use harmony::modules::application::helm::{HelmChart, HelmResourceKind};
use crate::compose::{ComposeApp, ComposeService};
@@ -45,9 +48,48 @@ pub struct DeployConfig {
/// `true` → RollingUpdate (needs RWX so old+new pods share volumes);
/// `false` → Recreate (safe for single-writer stores like sqlite).
pub rolling: bool,
/// Extra env injected into every service container by capabilities —
/// wired by reference (e.g. a DB URL via `secretKeyRef`), never by value,
/// so the chart stays publishable.
pub extra_env: Vec<EnvVar>,
}
impl DeployConfig {
/// Map the generic [`Profile`] tag to this app's k8s knobs — prod
/// replicates on RWX `cephfs`; local is single-writer RWO `local-path`.
/// This is where "what a profile means" lives (ADR-026 §7), per app.
pub fn for_profile(
app_name: impl Into<String>,
registry: impl Into<String>,
project: impl Into<String>,
version: impl Into<String>,
profile: Profile,
) -> Self {
let prod = profile == Profile::Prod;
let version = version.into();
Self {
app_name: app_name.into(),
chart_version: version.clone(),
registry: registry.into(),
project: project.into(),
version,
storage_class: Some(if prod { "cephfs" } else { "local-path" }.to_string()),
volume_size: "1Gi".to_string(),
replicas: if prod { 2 } else { 1 },
rolling: prod,
extra_env: Vec::new(),
}
}
}
/// cert-manager ClusterIssuer for a profile: prod terminates TLS, local
/// serves plain HTTP.
pub fn cluster_issuer_for(profile: Profile) -> Option<String> {
(profile == Profile::Prod).then(|| "letsencrypt-prod".to_string())
}
const ACCESS_MODE_RWX: &str = "ReadWriteMany";
const ACCESS_MODE_RWO: &str = "ReadWriteOnce";
/// The published image ref a service's Deployment should pull: built
/// services get our registry coordinates, prebuilt `image:` services are
@@ -65,6 +107,14 @@ pub fn service_image(cfg: &DeployConfig, svc: &ComposeService) -> String {
}
}
/// Name of the per-app Opaque Secret the deploy applies (from OpenBao, at
/// deploy time) and every container loads via `envFrom`. The chart only
/// *references* it (optional), so secret values never enter a published
/// chart — the [`Score`](crate::score) applies the Secret separately.
pub fn app_secret_name(app_name: &str) -> String {
format!("{app_name}-secrets")
}
/// Build + write the chart to `out_dir`; returns the chart directory
/// `helm install <path>` wants.
pub fn build_chart(
@@ -127,7 +177,7 @@ fn deployment(svc: &ComposeService, cfg: &DeployConfig) -> Deployment {
})
.collect();
let env: Vec<EnvVar> = svc
let mut env: Vec<EnvVar> = svc
.env
.iter()
.map(|(k, v)| EnvVar {
@@ -136,6 +186,8 @@ fn deployment(svc: &ComposeService, cfg: &DeployConfig) -> Deployment {
..Default::default()
})
.collect();
// Capability-contributed env (e.g. a DB connection by `secretKeyRef`).
env.extend(cfg.extra_env.iter().cloned());
let volume_mounts: Vec<VolumeMount> = svc
.mounts
@@ -200,6 +252,16 @@ fn deployment(svc: &ComposeService, cfg: &DeployConfig) -> Deployment {
image_pull_policy: Some("IfNotPresent".to_string()),
ports: (!ports.is_empty()).then_some(ports),
env: (!env.is_empty()).then_some(env),
// App secrets load here; `optional` so a secretless app
// needs no Secret. One app-wide bag — per-service
// targeting is deferred (Rule of Three).
env_from: Some(vec![EnvFromSource {
secret_ref: Some(SecretEnvSource {
name: app_secret_name(&cfg.app_name),
optional: Some(true),
}),
..Default::default()
}]),
volume_mounts: (!volume_mounts.is_empty()).then_some(volume_mounts),
..Default::default()
}],
@@ -253,7 +315,18 @@ fn pvc(name: &str, cfg: &DeployConfig) -> PersistentVolumeClaim {
..Default::default()
},
spec: Some(PersistentVolumeClaimSpec {
access_modes: Some(vec![ACCESS_MODE_RWX.to_string()]),
// RollingUpdate keeps old+new pods alive together, so the volume
// must be shared (RWX); Recreate runs one pod at a time, so RWO
// suffices — and RWO is what non-distributed stores (k3d
// local-path) actually offer.
access_modes: Some(vec![
if cfg.rolling {
ACCESS_MODE_RWX
} else {
ACCESS_MODE_RWO
}
.to_string(),
]),
storage_class_name: cfg.storage_class.clone(),
resources: Some(VolumeResourceRequirements {
requests: Some(BTreeMap::from([(
@@ -286,6 +359,7 @@ mod tests {
volume_size: "2Gi".to_string(),
replicas: 2,
rolling: true,
extra_env: vec![],
};
let tmp = tempfile::tempdir().unwrap();
build_chart(&app, &cfg, tmp.path()).unwrap();
@@ -354,6 +428,17 @@ mod tests {
assert!(pvc.contains("2Gi"), "{pvc}");
}
#[test]
fn pvc_is_readwriteonce_when_recreate() {
let (app, mut cfg, _t) = fixture();
cfg.rolling = false;
let tmp = tempfile::tempdir().unwrap();
build_chart(&app, &cfg, tmp.path()).unwrap();
let pvc = read(&tmp, "pvc-data.yaml");
assert!(pvc.contains("ReadWriteOnce"), "{pvc}");
assert!(!pvc.contains("ReadWriteMany"), "{pvc}");
}
#[test]
fn rolling_strategy_and_replicas_render() {
let (_, _, tmp) = fixture();

View File

@@ -380,7 +380,7 @@ volumes:
/// source of truth and a contract for the chart tests.
#[test]
fn the_examples_compose_file_imports() {
let dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("app");
let dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("../examples/compose_java_react/app");
let app = ComposeApp::from_dir(&dir).expect("import example compose");
assert_eq!(app.services.len(), 2);
assert_eq!(app.mounted_volumes(), vec!["timesheet-data".to_string()]);

194
harmony_app/src/context.rs Normal file
View File

@@ -0,0 +1,194 @@
//! [`AppContext`] — the selected deploy target (ADR-026 §1/§10). Contexts are
//! defined **in-repo** at `.harmony/contexts.toml` (found by walking up from
//! the cwd), so collaborators and CI need zero local setup. A context owns
//! *cluster access* — either a local k3d cluster, or a kubeconfig brokered
//! from OpenBao via `harmony_config` (Zitadel-authenticated, the CI path).
//! App *secrets* are separate (the app loads those); the context only says
//! how to reach the cluster.
use std::ffi::OsString;
use std::io::Write;
use std::path::{Path, PathBuf};
use std::process::Command;
use anyhow::{Context, Result, bail};
use harmony::topology::{K8sAnywhereConfig, K8sAnywhereTopology};
use harmony_config::{Config, ConfigClient};
use harmony_k8s::K8sClient;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use tempfile::NamedTempFile;
use crate::profile::Profile;
/// `.harmony/contexts.toml`: `[contexts.<name>]` blocks.
#[derive(Debug, Deserialize)]
struct ContextsFile {
contexts: std::collections::HashMap<String, ContextDef>,
}
/// One named context. Cluster access is exactly one of `k3d` (local) or
/// `openbao_namespace` (kubeconfig from OpenBao, CI/prod).
#[derive(Debug, Deserialize)]
struct ContextDef {
profile: Profile,
k3d: Option<String>,
openbao_namespace: Option<String>,
}
/// Cluster credential brokered from OpenBao — its own secret
/// (`secret/<ns>/ClusterAccess`), separate from the app's secrets, so
/// "how to reach the cluster" and "the app's config" stay distinct.
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Config)]
pub struct ClusterAccess {
#[config(secret)]
pub kubeconfig: String,
}
pub struct AppContext {
name: String,
profile: Profile,
version: String,
local_config_dir: Option<PathBuf>,
k3d_cluster: Option<String>,
kubeconfig: PathBuf,
/// The kubeconfig file must outlive every client/topology built from it.
_kubeconfig_guard: NamedTempFile,
}
impl AppContext {
/// Resolve a context by name from the in-repo `.harmony/contexts.toml`.
pub async fn resolve(
name: &str,
version: impl Into<String>,
local_config_dir: Option<PathBuf>,
contexts_file: Option<PathBuf>,
) -> Result<Self> {
let path = match contexts_file {
Some(p) => {
if !p.is_file() {
bail!("contexts file not found: {}", p.display());
}
p
}
None => find_contexts_file()?,
};
let raw = std::fs::read_to_string(&path)
.with_context(|| format!("reading {}", path.display()))?;
let file: ContextsFile =
toml::from_str(&raw).with_context(|| format!("parsing {}", path.display()))?;
let def = file.contexts.get(name).with_context(|| {
format!(
"context '{name}' not in {} (have: {})",
path.display(),
file.contexts.keys().cloned().collect::<Vec<_>>().join(", ")
)
})?;
let guard = match (&def.k3d, &def.openbao_namespace) {
(Some(cluster), None) => k3d_kubeconfig(cluster)?,
(None, Some(ns)) => {
let access: ClusterAccess = ConfigClient::for_namespace(ns)
.await
.get()
.await
.with_context(|| {
format!(
"loading cluster kubeconfig from OpenBao ({ns}/ClusterAccess) \
— are the Zitadel key + OPENBAO_* env vars set?)"
)
})?;
write_kubeconfig(access.kubeconfig.as_bytes())?
}
_ => bail!("context '{name}' must set exactly one of `k3d` or `openbao_namespace`"),
};
Ok(Self {
name: name.to_string(),
profile: def.profile,
version: version.into(),
local_config_dir,
k3d_cluster: def.k3d.clone(),
kubeconfig: guard.path().to_path_buf(),
_kubeconfig_guard: guard,
})
}
pub fn name(&self) -> &str {
&self.name
}
pub fn profile(&self) -> Profile {
self.profile
}
pub fn version(&self) -> &str {
&self.version
}
pub fn local_config_dir(&self) -> Option<&Path> {
self.local_config_dir.as_deref()
}
pub fn k3d_cluster(&self) -> Option<&str> {
self.k3d_cluster.as_deref()
}
/// The converge target — built from the context's kubeconfig, no env.
pub fn topology(&self) -> K8sAnywhereTopology {
K8sAnywhereTopology::with_config(K8sAnywhereConfig::kubeconfig(
self.kubeconfig.to_string_lossy().to_string(),
None,
))
}
/// A read client for operational verbs (status/logs) — no topology prep.
pub async fn k8s_client(&self) -> Result<K8sClient> {
K8sClient::from_kubeconfig(&self.kubeconfig.to_string_lossy())
.await
.context("building k8s client from the context kubeconfig")
}
}
/// Walk up from the cwd for `.harmony/contexts.toml` (git/cargo style).
fn find_contexts_file() -> Result<PathBuf> {
let mut dir = std::env::current_dir().context("current dir")?;
loop {
let candidate = dir.join(".harmony").join("contexts.toml");
if candidate.is_file() {
return Ok(candidate);
}
if !dir.pop() {
bail!(
"no .harmony/contexts.toml found (searched up from the cwd) — \
run from the project dir or pass --config <path>"
);
}
}
}
fn write_kubeconfig(contents: &[u8]) -> Result<NamedTempFile> {
let mut file =
NamedTempFile::with_prefix("harmony-ctx-").context("create kubeconfig tempfile")?;
file.write_all(contents).context("write kubeconfig")?;
file.flush().context("flush kubeconfig")?;
Ok(file)
}
/// The k3d cluster's kubeconfig, written to a temp file. Prefers harmony's
/// managed k3d binary, falling back to a `k3d` on PATH.
fn k3d_kubeconfig(cluster: &str) -> Result<NamedTempFile> {
let managed = harmony::config::HARMONY_DATA_DIR.join("k3d").join("k3d");
let k3d: OsString = if managed.exists() {
managed.into_os_string()
} else {
"k3d".into()
};
let out = Command::new(&k3d)
.args(["kubeconfig", "get", cluster])
.output()
.context("spawn k3d (installed and on PATH?)")?;
if !out.status.success() {
bail!(
"k3d kubeconfig get {cluster} failed: {}",
String::from_utf8_lossy(&out.stderr)
);
}
write_kubeconfig(&out.stdout)
}

278
harmony_app/src/deploy.rs Normal file
View File

@@ -0,0 +1,278 @@
//! [`ComposeDeploy`] — declarative authoring for a compose app. You *declare*
//! intent (import compose, expose a service, name a profile's worth of knobs)
//! and it implements [`HarmonyApp`], so `app_main` gives you
//! ship/deploy/status/logs over any context. The mature foundation
//! (composable Scores, contexts, profiles) with manifest-style DX.
use std::collections::BTreeMap;
use std::path::Path;
use crate::{AppContext, AppIdentity, AppRef, Capability, HarmonyApp, Profile};
use anyhow::{Result, anyhow};
use async_trait::async_trait;
use harmony::score::Score;
use harmony::topology::{HelmCommand, K8sAnywhereTopology, K8sclient, Topology};
use crate::chart::{DeployConfig, cluster_issuer_for};
use crate::compose::ComposeApp;
use crate::score::{ComposeAppScore, PublicEndpoint, PublishedChart};
struct Endpoint {
service: String,
host: String,
}
/// A compose app, declared. Build it fluently, then hand it to `app_main`.
/// Generic over the target `Topology` (defaults to `K8sAnywhereTopology`, what
/// `app_main` drives); `.with(cap)` only compiles for capabilities the chosen
/// topology can host.
pub struct ComposeDeploy<T: Topology = K8sAnywhereTopology> {
name: String,
namespace: String,
project: String,
registry: String,
app: ComposeApp,
expose: Option<Endpoint>,
app_secrets: BTreeMap<String, String>,
capabilities: Vec<Box<dyn Capability<T>>>,
}
impl<T: Topology> ComposeDeploy<T> {
/// Import the app from a compose dir. `namespace`/`project` default to the
/// name; override fluently.
pub fn from_dir(name: impl Into<String>, dir: impl AsRef<Path>) -> Result<Self, String> {
let app = ComposeApp::from_dir(dir.as_ref()).map_err(|e| e.to_string())?;
Ok(Self::from_compose(name, app))
}
pub fn from_compose(name: impl Into<String>, app: ComposeApp) -> Self {
let name = name.into();
Self {
namespace: name.clone(),
project: name.clone(),
registry: "localhost".to_string(),
name,
app,
expose: None,
app_secrets: BTreeMap::new(),
capabilities: Vec::new(),
}
}
pub fn namespace(mut self, ns: impl Into<String>) -> Self {
self.namespace = ns.into();
self
}
pub fn project(mut self, p: impl Into<String>) -> Self {
self.project = p.into();
self
}
pub fn registry(mut self, r: impl Into<String>) -> Self {
self.registry = r.into();
self
}
/// Route a compose service through an Ingress (the port comes from compose).
pub fn expose(mut self, service: impl Into<String>, host: impl Into<String>) -> Self {
self.expose = Some(Endpoint {
service: service.into(),
host: host.into(),
});
self
}
pub fn secret(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
self.app_secrets.insert(key.into(), value.into());
self
}
/// Add a capability (e.g. `Postgres::managed()`): it deploys its own
/// Scores and wires itself into the app by reference.
pub fn with(mut self, capability: impl Capability<T> + 'static) -> Self {
self.capabilities.push(Box::new(capability));
self
}
fn app_ref(&self, profile: Profile) -> AppRef<'_> {
AppRef {
name: &self.name,
namespace: &self.namespace,
profile,
}
}
fn deploy_config(&self, profile: Profile, version: &str) -> DeployConfig {
DeployConfig::for_profile(&self.name, &self.registry, &self.project, version, profile)
}
/// Derive the deploy Score for a profile (pure — the testable core).
pub fn score(&self, profile: Profile, version: &str) -> Result<ComposeAppScore, String> {
let public_endpoint = match &self.expose {
Some(e) => Some(PublicEndpoint::from_compose(
&self.app,
&e.service,
&e.host,
cluster_issuer_for(profile),
)?),
None => None,
};
let mut deploy = self.deploy_config(profile, version);
deploy.extra_env = self
.capabilities
.iter()
.flat_map(|c| c.env(&self.app_ref(profile)))
.collect();
Ok(ComposeAppScore {
namespace: self.namespace.clone(),
release_name: self.name.clone(),
// Prod installs the published OCI chart; local renders from compose.
published_chart: (profile == Profile::Prod).then(|| PublishedChart {
registry: self.registry.clone(),
project: self.project.clone(),
version: version.to_string(),
}),
public_endpoint,
app: self.app.clone(),
deploy,
app_secrets: self.app_secrets.clone(),
})
}
}
#[async_trait]
impl<T: Topology + HelmCommand + K8sclient> HarmonyApp<T> for ComposeDeploy<T> {
fn identity(&self) -> AppIdentity {
AppIdentity {
name: self.name.clone(),
namespace: self.namespace.clone(),
}
}
async fn scores(&self, ctx: &AppContext) -> Result<Vec<Box<dyn Score<T>>>> {
let app_score = self
.score(ctx.profile(), ctx.version())
.map_err(|e| anyhow!(e))?;
let mut scores: Vec<Box<dyn Score<T>>> = vec![Box::new(app_score)];
let app_ref = self.app_ref(ctx.profile());
for capability in &self.capabilities {
scores.extend(capability.scores(&app_ref));
}
Ok(scores)
}
async fn publish(&self, ctx: &AppContext) -> Result<()> {
use crate::publish::{PublishTarget, publish};
let target = match ctx.profile() {
Profile::Local => PublishTarget::K3dImport {
cluster: ctx.k3d_cluster().unwrap_or(&self.name).to_string(),
},
// Push needs registry creds; for this example they come from env.
// (A prod app would load them from its secret store.)
Profile::Prod => PublishTarget::Registry {
user: std::env::var("REGISTRY_USER")
.map_err(|_| anyhow!("REGISTRY_USER required to push"))?,
token: std::env::var("REGISTRY_TOKEN")
.map_err(|_| anyhow!("REGISTRY_TOKEN required to push"))?,
},
};
publish(
&self.app,
&self.deploy_config(ctx.profile(), ctx.version()),
&target,
)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::Postgres;
// The pure score-building logic is topology-independent; pin a concrete
// topology so the tests don't have to annotate every call.
type CD = ComposeDeploy<K8sAnywhereTopology>;
fn fixture() -> ComposeApp {
ComposeApp::parse(
"name: t\nservices:\n frontend:\n build: .\n ports: [\"8081:80\"]\n",
Path::new("/p"),
)
.unwrap()
}
#[test]
fn namespace_and_project_default_to_name() {
let s = CD::from_compose("timesheet", fixture())
.score(Profile::Local, "0.1.0")
.unwrap();
assert_eq!(s.namespace, "timesheet");
assert_eq!(s.release_name, "timesheet");
}
#[test]
fn local_profile_renders_locally_rwo_http() {
let s = CD::from_compose("ts", fixture())
.expose("frontend", "ts.local")
.score(Profile::Local, "1.0.0")
.unwrap();
assert!(s.published_chart.is_none(), "local renders from compose");
assert_eq!(s.deploy.replicas, 1);
assert!(!s.deploy.rolling);
let ep = s.public_endpoint.unwrap();
assert_eq!(ep.service, "frontend");
assert_eq!(ep.host, "ts.local");
assert_eq!(ep.port, 80, "port read from compose");
assert!(ep.cluster_issuer.is_none(), "local = plain HTTP");
}
#[test]
fn prod_profile_publishes_replicated_tls() {
let s = CD::from_compose("ts", fixture())
.registry("hub.x")
.project("p")
.expose("frontend", "ts.x")
.score(Profile::Prod, "2.0.0")
.unwrap();
let pc = s.published_chart.expect("prod installs published chart");
assert_eq!(pc.registry, "hub.x");
assert_eq!(pc.project, "p");
assert_eq!(pc.version, "2.0.0");
assert_eq!(s.deploy.replicas, 2);
assert!(s.deploy.rolling);
assert_eq!(
s.public_endpoint.unwrap().cluster_issuer.as_deref(),
Some("letsencrypt-prod")
);
}
#[test]
fn secrets_are_carried_to_the_score() {
let s = CD::from_compose("ts", fixture())
.secret("DB_PASSWORD", "dev")
.score(Profile::Local, "0.1.0")
.unwrap();
assert_eq!(s.app_secrets.get("DB_PASSWORD").unwrap(), "dev");
}
#[test]
fn postgres_capability_wires_database_url_by_reference() {
let s = CD::from_compose("ts", fixture())
.with(Postgres::managed())
.score(Profile::Local, "0.1.0")
.unwrap();
let db = s
.deploy
.extra_env
.iter()
.find(|e| e.name == "DATABASE_URL")
.expect("DATABASE_URL injected");
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");
}
}

37
harmony_app/src/lib.rs Normal file
View File

@@ -0,0 +1,37 @@
//! The Harmony **application** layer — the lifecycle of a deployable app
//! (build, publish, deploy, ship, status, logs) expressed once,
//! **independent of any UI** (ADR-026).
//!
//! This is the home the old `modules::application` feature was looking for:
//! not a Score, and not bound to the CLI. A [`HarmonyApp`] describes *what an
//! app is* (identity + how to build its Scores for a given context); the
//! free functions [`ship`]/[`deploy`]/[`status`]/[`logs`] are the verbs,
//! and they return **structured results**, never printed output. The CLI,
//! a future TUI, and a web UI are all just front-ends that call these verbs
//! and render the results — none of them owns the logic.
//!
//! A [`Context`](context::AppContext) is the selected target + profile
//! (ADR-026 §1/§10): the verbs converge the *same* Scores wherever the
//! context points; only the context changes between local and prod.
pub mod app;
pub mod capabilities;
pub mod chart;
pub mod compose;
pub mod context;
pub mod deploy;
pub mod profile;
pub mod publish;
pub mod score;
pub use app::{
AppIdentity, DeployReport, HarmonyApp, PodLogs, StatusReport, StepOutcome, WorkloadStatus,
deploy, logs, ship, status,
};
pub use capabilities::{AppRef, Capability, Monitoring, Postgres, ZitadelAuth};
pub use chart::{DeployConfig, cluster_issuer_for, service_image};
pub use compose::ComposeApp;
pub use context::{AppContext, ClusterAccess};
pub use deploy::ComposeDeploy;
pub use profile::Profile;
pub use score::{ComposeAppScore, PublicEndpoint, PublishedChart};

View File

@@ -0,0 +1,13 @@
//! The deploy profile — the typed environment tag a [`Context`] carries
//! (ADR-026 §7). The app's `scores()` branches on it; the *meaning* of each
//! profile (storage class, replicas, TLS, …) lives in the app/deploy code,
//! not here, so this stays a small generic tag.
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Profile {
/// Local k3d: single writer, ReadWriteOnce, plain HTTP.
Local,
/// A tenant cluster: replicated, RWX shared storage, TLS.
Prod,
}

188
harmony_app/src/publish.rs Normal file
View File

@@ -0,0 +1,188 @@
//! Build the per-service images, then distribute them to a target: push to
//! an OCI registry (CD) or import into a local k3d cluster (dev). The build
//! is identical either way — only distribution differs (ADR-026 §4). Plain
//! `anyhow` — binary glue, not library API.
use std::io::Write;
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use anyhow::{Context, Result, bail};
use crate::chart::{DeployConfig, build_chart, service_image};
use crate::compose::ComposeApp;
/// Where built images (and, for a registry, the chart) are distributed.
pub enum PublishTarget {
/// Push images + chart to `cfg.registry` (CD path).
Registry { user: String, token: String },
/// Import images into a k3d cluster; the chart is rendered at deploy
/// time (no OCI chart), so nothing is pushed (local dev).
K3dImport { cluster: String },
}
/// Build the images, then distribute them to `target`.
pub fn publish(app: &ComposeApp, cfg: &DeployConfig, target: &PublishTarget) -> Result<()> {
build_images(app, cfg)?;
match target {
PublishTarget::Registry { user, token } => push_to_registry(app, cfg, user, token),
PublishTarget::K3dImport { cluster } => import_to_k3d(app, cfg, cluster),
}
}
/// `docker build` each service that builds from source; prebuilt `image:`
/// services are left for the kubelet to pull.
pub fn build_images(app: &ComposeApp, cfg: &DeployConfig) -> Result<()> {
for svc in &app.services {
let Some(context) = &svc.build_context else {
log::info!("service '{}' uses prebuilt image, skipping build", svc.name);
continue;
};
let image = service_image(cfg, svc);
log::info!("docker build {image}");
let mut cmd = Command::new("docker");
cmd.arg("build").arg("-t").arg(&image);
if let Some(dockerfile) = &svc.dockerfile {
cmd.arg("-f").arg(context.join(dockerfile));
}
cmd.arg(context);
run(cmd)?;
}
Ok(())
}
/// Log in, push each built image, then package + push the hydrated chart.
pub fn push_to_registry(
app: &ComposeApp,
cfg: &DeployConfig,
user: &str,
token: &str,
) -> Result<()> {
registry_login(&cfg.registry, user, token)?;
for svc in &app.services {
if svc.build_context.is_none() {
continue;
}
let image = service_image(cfg, svc);
log::info!("docker push {image}");
run({
let mut c = Command::new("docker");
c.arg("push").arg(&image);
c
})?;
}
let tmp = tempfile::tempdir().context("chart tempdir")?;
let chart_dir =
build_chart(app, cfg, tmp.path()).map_err(|e| anyhow::anyhow!("hydrate chart: {e}"))?;
let tgz = helm_package(&chart_dir, tmp.path())?;
let oci_repo = format!("oci://{}/{}", cfg.registry, cfg.project);
log::info!("helm push {} {oci_repo}", tgz.display());
run({
let mut c = Command::new("helm");
c.arg("push").arg(&tgz).arg(&oci_repo);
c
})
}
/// Load locally-built images into a k3d cluster's nodes — k3d has no
/// registry, so without this the `IfNotPresent` pulls find nothing.
pub fn import_to_k3d(app: &ComposeApp, cfg: &DeployConfig, cluster: &str) -> Result<()> {
let images: Vec<String> = app
.services
.iter()
.filter(|s| s.build_context.is_some())
.map(|s| service_image(cfg, s))
.collect();
if images.is_empty() {
return Ok(());
}
// Prefer harmony's managed k3d (the local-dev install location); fall
// back to a `k3d` on PATH for runners that ship their own.
let managed = harmony::config::HARMONY_DATA_DIR.join("k3d").join("k3d");
let k3d: &std::ffi::OsStr = if managed.exists() {
managed.as_os_str()
} else {
"k3d".as_ref()
};
log::info!("k3d image import {} -> {cluster}", images.join(" "));
let status = Command::new(k3d)
.args(["image", "import", "-c", cluster])
.args(&images)
.status()
.context("spawn k3d (installed and on PATH?)")?;
if !status.success() {
bail!("k3d image import failed ({status})");
}
Ok(())
}
fn registry_login(registry: &str, user: &str, token: &str) -> Result<()> {
login(
"docker",
&["login", registry, "-u", user, "--password-stdin"],
token,
)?;
login(
"helm",
&[
"registry",
"login",
registry,
"-u",
user,
"--password-stdin",
],
token,
)
}
/// Feed the secret on stdin so it never lands in argv or the process table.
fn login(bin: &str, args: &[&str], password: &str) -> Result<()> {
let mut child = Command::new(bin)
.args(args)
.stdin(Stdio::piped())
.spawn()
.with_context(|| format!("spawn {bin} (installed and on PATH?)"))?;
child
.stdin
.take()
.context("child stdin unavailable")?
.write_all(password.as_bytes())
.with_context(|| format!("writing password to {bin}"))?;
let status = child.wait().with_context(|| format!("{bin} login"))?;
if !status.success() {
bail!("{bin} registry login failed ({status})");
}
Ok(())
}
fn run(mut cmd: Command) -> Result<()> {
let status = cmd
.status()
.with_context(|| format!("spawn {cmd:?} (is it installed and in PATH?)"))?;
if !status.success() {
bail!("`{cmd:?}` failed ({status})");
}
Ok(())
}
fn helm_package(chart_dir: &Path, out_dir: &Path) -> Result<PathBuf> {
let output = Command::new("helm")
.args(["package", path_str(chart_dir)?, "-d", path_str(out_dir)?])
.output()
.context("spawn helm package")?;
if !output.status.success() {
bail!("helm package failed ({})", output.status);
}
// helm prints "…saved it to: <path>"; the last token is the path.
String::from_utf8_lossy(&output.stdout)
.split_whitespace()
.last()
.map(PathBuf::from)
.context("helm package printed no path")
}
fn path_str(p: &Path) -> Result<&str> {
p.to_str()
.with_context(|| format!("path not utf-8: {}", p.display()))
}

View File

@@ -7,10 +7,13 @@
//! `ApplicationScore`, no ArgoCD — the same build/publish/deploy split the
//! fleet stack uses.
use std::collections::BTreeMap;
use std::str::FromStr;
use async_trait::async_trait;
use harmony_types::id::Id;
use k8s_openapi::api::core::v1::Secret;
use k8s_openapi::apimachinery::pkg::apis::meta::v1::ObjectMeta;
use log::info;
use serde::Serialize;
@@ -19,10 +22,11 @@ use harmony::interpret::{Interpret, InterpretError, InterpretName, InterpretStat
use harmony::inventory::Inventory;
use harmony::modules::helm::chart::{HelmChartScore, NonBlankString};
use harmony::modules::k8s::ingress::K8sIngressScore;
use harmony::modules::k8s::resource::K8sResourceScore;
use harmony::score::Score;
use harmony::topology::{HelmCommand, K8sclient, Topology};
use crate::chart::{DeployConfig, build_chart};
use crate::chart::{DeployConfig, app_secret_name, build_chart};
use crate::compose::ComposeApp;
/// The already-published OCI chart to install (the CD path). When set,
@@ -47,6 +51,31 @@ pub struct PublicEndpoint {
pub cluster_issuer: Option<String>,
}
impl PublicEndpoint {
/// Route `host` to a compose `service`; the port is read from compose
/// (single source of truth for the app shape), TLS issuer passed in.
pub fn from_compose(
app: &ComposeApp,
service: &str,
host: impl Into<String>,
cluster_issuer: Option<String>,
) -> Result<Self, String> {
let port = app
.service(service)
.ok_or_else(|| format!("no compose service '{service}'"))?
.ports
.first()
.ok_or_else(|| format!("service '{service}' exposes no port"))?
.container;
Ok(Self {
service: service.to_string(),
port,
host: host.into(),
cluster_issuer,
})
}
}
#[derive(Debug, Clone, Serialize)]
pub struct ComposeAppScore {
pub namespace: String,
@@ -57,6 +86,12 @@ pub struct ComposeAppScore {
/// (dev/e2e); `Some` installs the published OCI chart (CD).
pub published_chart: Option<PublishedChart>,
pub public_endpoint: Option<PublicEndpoint>,
/// App secrets (from OpenBao) applied as one Opaque `<app>-secrets`
/// Secret before the workload, loaded by every container via `envFrom`.
/// Empty → no Secret (the chart's reference is optional). `skip`: secret
/// values must never reach Score display/logs.
#[serde(skip)]
pub app_secrets: BTreeMap<String, String>,
}
impl<T: Topology + HelmCommand + K8sclient> Score<T> for ComposeAppScore {
@@ -85,6 +120,25 @@ impl<T: Topology + HelmCommand + K8sclient> Interpret<T> for ComposeAppInterpret
) -> Result<Outcome, InterpretError> {
let s = &self.score;
// App secrets first, so the workload's `envFrom` can load them. The
// values come from OpenBao at deploy time — never baked into the
// (possibly published) chart, which only references the Secret.
if !s.app_secrets.is_empty() {
info!("Applying {} app secret(s)", s.app_secrets.len());
let secret = Secret {
metadata: ObjectMeta {
name: Some(app_secret_name(&s.deploy.app_name)),
namespace: Some(s.namespace.clone()),
..Default::default()
},
string_data: Some(s.app_secrets.clone()),
..Default::default()
};
K8sResourceScore::single(secret, Some(s.namespace.clone()))
.interpret(inventory, topology)
.await?;
}
// Install from the published OCI chart (CD) or a chart rendered
// from the imported compose (dev/e2e). Each branch keeps its
// tempdir alive across the install.

View File

@@ -6,7 +6,7 @@
use std::path::Path;
use std::process::Command;
use example_compose_java_react::{ComposeApp, DeployConfig, chart::build_chart};
use harmony_app::{ComposeApp, DeployConfig, chart::build_chart};
fn helm_present() -> bool {
Command::new("helm")
@@ -23,7 +23,7 @@ fn generated_chart_passes_helm_lint_and_template() {
return;
}
let app_dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("app");
let app_dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("../examples/compose_java_react/app");
let app = ComposeApp::from_dir(&app_dir).expect("import compose");
let cfg = DeployConfig {
app_name: "timesheet".to_string(),
@@ -35,6 +35,7 @@ fn generated_chart_passes_helm_lint_and_template() {
volume_size: "1Gi".to_string(),
replicas: 2,
rolling: true,
extra_env: vec![],
};
let tmp = tempfile::tempdir().unwrap();

View File

@@ -11,8 +11,10 @@ tui = ["dep:harmony_tui"]
[dependencies]
assert_cmd = "2.0.17"
anyhow = { workspace = true }
clap = { version = "4.5.35", features = ["derive"] }
harmony = { path = "../harmony" }
harmony_app = { path = "../harmony_app" }
harmony_tui = { path = "../harmony_tui", optional = true }
inquire.workspace = true
tokio.workspace = true

99
harmony_cli/src/app.rs Normal file
View File

@@ -0,0 +1,99 @@
//! `app_main` — the **CLI front-end** for the app lifecycle (ADR-026 §11
//! `harmony app <verb>`). It only parses argv, resolves a context, calls the
//! UI-agnostic verbs in [`harmony_app`], and renders the structured result.
//! A TUI or web UI is a sibling front-end over the same verbs — no logic
//! lives here.
use std::path::PathBuf;
use anyhow::{Context, Result};
use clap::{Parser, Subcommand};
use harmony::topology::K8sAnywhereTopology;
use harmony_app::{AppContext, HarmonyApp};
#[derive(Parser, Debug)]
#[command(version)]
struct AppCli {
#[command(subcommand)]
verb: Verb,
/// Target cluster context (required; there is no default, so you never
/// deploy to the wrong cluster). Defined in `.harmony/contexts.toml`.
#[arg(long, global = true)]
context: Option<String>,
/// Release tag for the images + chart (build/publish/deploy).
#[arg(long, global = true, default_value = "0.1.0")]
tag: String,
/// Directory holding a local context's offline secrets file
/// (`<dir>/DeploySecrets.toml`).
#[arg(long, global = true)]
local_config: Option<PathBuf>,
/// Path to the contexts file. Default: the nearest
/// `.harmony/contexts.toml` found by walking up from the current dir.
#[arg(long, short = 'C', global = true, value_name = "FILE")]
config: Option<PathBuf>,
}
#[derive(Subcommand, Debug)]
enum Verb {
/// Build + publish + deploy.
Ship,
/// Converge the app's Scores (no build).
Deploy,
/// Workload readiness in the app's namespace.
Status,
/// Recent logs from the app's pods.
Logs {
#[arg(long, default_value_t = 50)]
tail: i64,
},
}
/// Entry point for a per-app deploy binary: `fn main() { app_main(MyApp) }`.
/// The CLI front-end drives `K8sAnywhereTopology`; the app layer itself is
/// topology-generic (a different front-end can drive another topology).
pub async fn app_main<A: HarmonyApp<K8sAnywhereTopology> + 'static>(app: A) -> Result<()> {
crate::cli_logger::init();
crate::cli_reporter::init();
let cli = AppCli::parse();
let context = cli
.context
.context("--context is required (there is no default context)")?;
let ctx = AppContext::resolve(&context, cli.tag, cli.local_config, cli.config).await?;
match cli.verb {
Verb::Ship => render_deploy(harmony_app::ship(&app, ctx.topology(), &ctx).await?),
Verb::Deploy => render_deploy(harmony_app::deploy(&app, ctx.topology(), &ctx).await?),
Verb::Status => render_status(harmony_app::status(&app, &ctx).await?),
Verb::Logs { tail } => render_logs(harmony_app::logs(&app, &ctx, Some(tail)).await?),
}
Ok(())
}
fn render_deploy(report: harmony_app::DeployReport) {
println!("\n🚀 Deployed:");
for step in report.steps {
println!("{}{}", step.name, step.message);
}
}
fn render_status(report: harmony_app::StatusReport) {
println!("\n📊 {} workloads:", report.namespace);
if report.workloads.is_empty() {
println!(" (none)");
}
for w in report.workloads {
let mark = if w.ready == w.desired { "" } else { "" };
println!(" {mark} {} {}/{}", w.name, w.ready, w.desired);
}
}
fn render_logs(pods: Vec<harmony_app::PodLogs>) {
for p in pods {
println!("\n=== {} ===\n{}", p.pod, p.logs.trim_end());
}
}

View File

@@ -7,6 +7,7 @@ use harmony::{score::Score, topology::Topology};
use inquire::Confirm;
use tracing::debug;
pub mod app;
pub mod cli_logger; // FIXME: Don't make me pub
mod cli_reporter;
pub mod progress;

View File

@@ -10,6 +10,7 @@ harmony_secret = { version = "0.1.0", path = "../harmony_secret" }
harmony_config_derive = { version = "0.1.0", path = "../harmony_config_derive" }
serde = { version = "1.0.209", features = ["derive", "rc"] }
serde_json = "1.0.127"
toml.workspace = true
thiserror.workspace = true
async-trait.workspace = true
tokio.workspace = true

View File

@@ -21,6 +21,7 @@ pub use source::local_file::LocalFileSource;
pub use source::prompt::PromptSource;
pub use source::sqlite::SqliteSource;
pub use source::store::StoreSource;
pub use source::toml_file::TomlFileSource;
#[derive(Debug, Error)]
pub enum ConfigError {

View File

@@ -3,3 +3,4 @@ pub mod local_file;
pub mod prompt;
pub mod sqlite;
pub mod store;
pub mod toml_file;

View File

@@ -0,0 +1,62 @@
use async_trait::async_trait;
use std::path::PathBuf;
use tokio::fs;
use crate::{ConfigClass, ConfigError, ConfigSource};
/// Local TOML-backed config source (`<base_path>/<key>.toml`).
///
/// The human-friendly sibling of [`LocalFileSource`](crate::LocalFileSource):
/// TOML's triple-quoted literals hold multi-line values (kubeconfigs, PEM
/// keys) without escaping. Same caveat — cleartext on disk, ignores
/// `ConfigClass` — so it is for local, offline runs only, wired via an
/// explicit [`ConfigClient::new`](crate::ConfigClient::new), never the
/// default chain.
pub struct TomlFileSource {
base_path: PathBuf,
}
impl TomlFileSource {
pub fn new(base_path: PathBuf) -> Self {
Self { base_path }
}
fn file_path_for(&self, key: &str) -> PathBuf {
self.base_path.join(format!("{key}.toml"))
}
}
#[async_trait]
impl ConfigSource for TomlFileSource {
async fn get(
&self,
_class: ConfigClass,
key: &str,
) -> Result<Option<serde_json::Value>, ConfigError> {
let path = self.file_path_for(key);
match fs::read_to_string(&path).await {
Ok(contents) => toml::from_str(&contents)
.map(Some)
.map_err(|e| ConfigError::FileError(format!("Invalid TOML in {path:?}: {e}"))),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(e) => Err(ConfigError::FileError(format!(
"Failed to read config file {path:?}: {e}"
))),
}
}
async fn set(
&self,
_class: ConfigClass,
key: &str,
value: &serde_json::Value,
) -> Result<(), ConfigError> {
fs::create_dir_all(&self.base_path).await?;
let path = self.file_path_for(key);
let contents = toml::to_string_pretty(value).map_err(|e| {
ConfigError::FileError(format!("Failed to serialize {key} to TOML: {e}"))
})?;
fs::write(&path, contents).await?;
Ok(())
}
}

View File

@@ -1,3 +1,6 @@
# Standalone workspace — kept out of the public repo's workspace + Cargo.lock.
[workspace]
[package]
name = "example"
edition = "2024"