feat/declarative-app-dx #337
45
Cargo.lock
generated
45
Cargo.lock
generated
@@ -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]]
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
159
docs/adr/027-multi-tenant-identity.md
Normal file
159
docs/adr/027-multi-tenant-identity.md
Normal 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 #2–3 | 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.
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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`.
|
||||
|
||||
|
||||
94
docs/guides/application-capabilities.md
Normal file
94
docs/guides/application-capabilities.md
Normal 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
159
docs/why-harmony.draft.md
Normal 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.
|
||||
76
docs/why-harmony.manifesto.draft.md
Normal file
76
docs/why-harmony.manifesto.draft.md
Normal 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.
|
||||
6
examples/compose_java_react/.harmony/contexts.toml
Normal file
6
examples/compose_java_react/.harmony/contexts.toml
Normal 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"
|
||||
@@ -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 }
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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()))
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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()))
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
25
harmony_app/Cargo.toml
Normal 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
171
harmony_app/src/app.rs
Normal 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)
|
||||
}
|
||||
383
harmony_app/src/capabilities.rs
Normal file
383
harmony_app/src/capabilities.rs
Normal 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");
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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
194
harmony_app/src/context.rs
Normal 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
278
harmony_app/src/deploy.rs
Normal 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
37
harmony_app/src/lib.rs
Normal 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};
|
||||
13
harmony_app/src/profile.rs
Normal file
13
harmony_app/src/profile.rs
Normal 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
188
harmony_app/src/publish.rs
Normal 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()))
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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();
|
||||
@@ -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
99
harmony_cli/src/app.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -3,3 +3,4 @@ pub mod local_file;
|
||||
pub mod prompt;
|
||||
pub mod sqlite;
|
||||
pub mod store;
|
||||
pub mod toml_file;
|
||||
|
||||
62
harmony_config/src/source/toml_file.rs
Normal file
62
harmony_config/src/source/toml_file.rs
Normal 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(())
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,6 @@
|
||||
# Standalone workspace — kept out of the public repo's workspace + Cargo.lock.
|
||||
[workspace]
|
||||
|
||||
[package]
|
||||
name = "example"
|
||||
edition = "2024"
|
||||
|
||||
Reference in New Issue
Block a user