feat/declarative-app-dx #337
45
Cargo.lock
generated
45
Cargo.lock
generated
@@ -2785,10 +2785,6 @@ dependencies = [
|
|||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "example"
|
|
||||||
version = "0.0.0"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "example-application-monitoring-with-tenant"
|
name = "example-application-monitoring-with-tenant"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
@@ -2835,22 +2831,9 @@ name = "example-compose-java-react"
|
|||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"async-trait",
|
"harmony_app",
|
||||||
"clap",
|
|
||||||
"docker-compose-types",
|
|
||||||
"env_logger",
|
|
||||||
"fqdn",
|
|
||||||
"harmony",
|
|
||||||
"harmony_cli",
|
"harmony_cli",
|
||||||
"harmony_types",
|
|
||||||
"k8s-openapi",
|
|
||||||
"log",
|
|
||||||
"serde",
|
|
||||||
"serde_yaml",
|
|
||||||
"tempfile",
|
|
||||||
"thiserror 2.0.18",
|
|
||||||
"tokio",
|
"tokio",
|
||||||
"toml",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -4329,6 +4312,29 @@ dependencies = [
|
|||||||
"url",
|
"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]]
|
[[package]]
|
||||||
name = "harmony_assets"
|
name = "harmony_assets"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
@@ -4359,11 +4365,13 @@ dependencies = [
|
|||||||
name = "harmony_cli"
|
name = "harmony_cli"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"anyhow",
|
||||||
"assert_cmd",
|
"assert_cmd",
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"clap",
|
"clap",
|
||||||
"console",
|
"console",
|
||||||
"harmony",
|
"harmony",
|
||||||
|
"harmony_app",
|
||||||
"harmony_tui",
|
"harmony_tui",
|
||||||
"indicatif",
|
"indicatif",
|
||||||
"inquire 0.7.5",
|
"inquire 0.7.5",
|
||||||
@@ -4415,6 +4423,7 @@ dependencies = [
|
|||||||
"tempfile",
|
"tempfile",
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
"tokio",
|
"tokio",
|
||||||
|
"toml",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
resolver = "2"
|
resolver = "2"
|
||||||
members = [
|
members = [
|
||||||
"examples/*",
|
"examples/*",
|
||||||
"private_repos/*",
|
|
||||||
"harmony",
|
"harmony",
|
||||||
"harmony_zitadel_auth",
|
"harmony_zitadel_auth",
|
||||||
"harmony_zitadel_jwt",
|
"harmony_zitadel_jwt",
|
||||||
@@ -29,6 +28,7 @@ members = [
|
|||||||
"harmony_agent/deploy",
|
"harmony_agent/deploy",
|
||||||
"harmony_node_readiness",
|
"harmony_node_readiness",
|
||||||
"harmony-k8s",
|
"harmony-k8s",
|
||||||
|
"harmony_app",
|
||||||
"harmony_assets", "opnsense-codegen", "opnsense-api",
|
"harmony_assets", "opnsense-codegen", "opnsense-api",
|
||||||
"fleet/harmony-fleet-operator",
|
"fleet/harmony-fleet-operator",
|
||||||
"fleet/harmony-fleet-agent",
|
"fleet/harmony-fleet-agent",
|
||||||
|
|||||||
@@ -21,6 +21,7 @@
|
|||||||
|
|
||||||
- [Developer Guide](./guides/developer-guide.md)
|
- [Developer Guide](./guides/developer-guide.md)
|
||||||
- [Application CLI — Use Cases & Commands](./guides/application-cli.md)
|
- [Application CLI — Use Cases & Commands](./guides/application-cli.md)
|
||||||
|
- [Application Capabilities — .with(...)](./guides/application-capabilities.md)
|
||||||
- [Writing a Score](./guides/writing-a-score.md)
|
- [Writing a Score](./guides/writing-a-score.md)
|
||||||
- [Writing a Topology](./guides/writing-a-topology.md)
|
- [Writing a Topology](./guides/writing-a-topology.md)
|
||||||
- [Adding Capabilities](./guides/adding-capabilities.md)
|
- [Adding Capabilities](./guides/adding-capabilities.md)
|
||||||
|
|||||||
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 |
|
| [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](./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 |
|
| [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
|
## 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`.
|
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
|
version.workspace = true
|
||||||
readme.workspace = true
|
readme.workspace = true
|
||||||
license.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]]
|
[[bin]]
|
||||||
name = "compose-deploy"
|
name = "compose-deploy"
|
||||||
path = "src/main.rs"
|
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]
|
[dependencies]
|
||||||
harmony = { path = "../../harmony" }
|
harmony_app = { path = "../../harmony_app" }
|
||||||
harmony_cli = { path = "../../harmony_cli" }
|
harmony_cli = { path = "../../harmony_cli" }
|
||||||
harmony_types = { path = "../../harmony_types" }
|
|
||||||
|
|
||||||
docker-compose-types = "0.24"
|
|
||||||
anyhow = { workspace = true }
|
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"] }
|
tokio = { workspace = true, features = ["full"] }
|
||||||
toml = { workspace = true }
|
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
# Deploy a Java + React app from its `docker-compose.yml`
|
# Deploy a Java + React app from its `docker-compose.yml`
|
||||||
|
|
||||||
Demonstrates deploying an existing containerized app to Kubernetes with
|
Deploys an existing containerized app to Kubernetes with Harmony by
|
||||||
Harmony by **importing its `docker-compose.yml`** — no hand-written
|
**importing its `docker-compose.yml`** — no hand-written manifests, no Argo.
|
||||||
manifests, no Argo. The compose file stays the source of truth for the
|
The compose file stays the source of truth for the app's *base* shape;
|
||||||
app's *base* shape; deploy-only concerns live in typed Rust.
|
deploy-only concerns are typed Rust.
|
||||||
|
|
||||||
```
|
```
|
||||||
app/ the "customer" project (their existing repo)
|
app/ the "customer" project (their existing repo)
|
||||||
@@ -11,59 +11,70 @@ app/ the "customer" project (their existing repo)
|
|||||||
backend/ (Java + SQLite, Dockerfile)
|
backend/ (Java + SQLite, Dockerfile)
|
||||||
frontend/ (React + nginx, Dockerfile)
|
frontend/ (React + nginx, Dockerfile)
|
||||||
Harmony.toml identity only — the app name (ADR-026 §3)
|
Harmony.toml identity only — the app name (ADR-026 §3)
|
||||||
|
.harmony/contexts.toml deploy targets (local / prod), checked in
|
||||||
src/
|
src/
|
||||||
compose.rs import docker-compose → typed model (loud on the unsupported)
|
compose.rs import docker-compose → typed model (loud on the unsupported)
|
||||||
chart.rs model + deploy knobs → a hydrated helm chart (typed k8s)
|
chart.rs model + profile knobs → a hydrated helm chart (typed k8s)
|
||||||
publish.rs docker build/push each service + helm package/push the chart
|
publish.rs build images + (push to a registry | import to k3d)
|
||||||
score.rs ComposeAppScore: helm upgrade --install + Ingress
|
score.rs ComposeAppScore: helm upgrade --install + Ingress
|
||||||
main.rs `compose-deploy` bin
|
deploy.rs ComposeDeploy — the declarative builder (impls HarmonyApp)
|
||||||
bin/publish `compose-publish` bin
|
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
|
## Design
|
||||||
|
|
||||||
- **compose = base, Score = deploy knobs.** The importer reads only
|
- **compose = base, profile = deploy knobs.** The importer reads only images,
|
||||||
images, ports, env, and volumes. Replicas, RWX storage class, ingress
|
ports, env, volumes. Replicas, storage class, TLS, rolling strategy come
|
||||||
host, and rolling strategy are *not* in compose (or `Harmony.toml`) —
|
from the `Profile` the context carries (ADR-026 §6/§7), not from compose or
|
||||||
they are typed fields on the deploy `Score` (ADR-026 §6). A behavioral
|
`Harmony.toml`. A behavioral knob in either is a defect.
|
||||||
knob in compose or `Harmony.toml` is a defect.
|
- **Capabilities compose upward.** `.with(Postgres::managed())` /
|
||||||
- **Build / publish / deploy split — the fleet pattern.** `publish`
|
`.with(Monitoring::new()...)` / `.with(ZitadelAuth::oidc(...))` each deploy
|
||||||
builds + pushes the per-service images and a hydrated helm chart;
|
their own Scores and wire into the app **by reference** (a DB URL via
|
||||||
`deploy` is a `Score` that `helm upgrade --install`s it and layers the
|
`secretKeyRef`, a client_id via `configMapKeyRef`) — no `ApplicationScore`,
|
||||||
public Ingress. No `ApplicationScore`, no ArgoCD.
|
no ArgoCD.
|
||||||
- **Same Scores local → prod.** Only the topology changes
|
- **Same definition, local → prod.** Only the context changes; the *same*
|
||||||
(`K8sAnywhereTopology::from_env`): local k3d to test, a tenant in prod.
|
`ComposeAppScore` converges on local k3d or a tenant cluster.
|
||||||
- **Loud, never lossy.** Bind mounts and unparseable ports are hard
|
- **Loud, never lossy.** Bind mounts and unparseable ports are hard errors;
|
||||||
errors; `depends_on`/`command`/`restart` are warned, never silently
|
`depends_on`/`command`/`restart` are warned, never silently dropped.
|
||||||
dropped.
|
|
||||||
|
|
||||||
## Run it
|
## Run it (local k3d)
|
||||||
|
|
||||||
Local (build, don't push; render the chart from compose):
|
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
cargo run -p example-compose-java-react --bin compose-publish -- --no-push
|
k3d cluster create compose-local --servers 1 --wait
|
||||||
cargo run -p example-compose-java-react --bin compose-deploy -- \
|
kubectl --context k3d-compose-local create namespace timesheet
|
||||||
--host timesheet.local --storage-class <rwx-class> --yes
|
|
||||||
|
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
|
**Production** is the same verbs against a prod context (cluster + creds
|
||||||
compose-publish --version 0.1.0
|
brokered from OpenBao); see the `folk_ci_deploy` setup for the full tenant
|
||||||
compose-deploy --published --version 0.1.0 \
|
wiring.
|
||||||
--namespace <tenant>-timesheet --host timesheet.<tenant>.example \
|
|
||||||
--storage-class cephfs --cluster-issuer letsencrypt --yes
|
|
||||||
```
|
|
||||||
|
|
||||||
`compose-deploy --help` lists every deploy knob.
|
## Storage note (SQLite)
|
||||||
|
|
||||||
## ⚠️ SQLite + RollingUpdate + RWX
|
`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
|
||||||
This demo ships `--rolling` (RollingUpdate) with a `ReadWriteMany` PVC, as
|
(`Profile::Prod`) replicates with `RollingUpdate` on `ReadWriteMany`; for a
|
||||||
requested. During a rollout two pods briefly share the same SQLite file,
|
real multi-writer store use `.with(Postgres::managed())` (the demo already
|
||||||
which SQLite does not make safe for concurrent writers over a networked
|
provisions it) and point the app at `DATABASE_URL`.
|
||||||
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.
|
|
||||||
|
|||||||
@@ -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
|
//! `compose-deploy` — deploy this Java+React app to any context.
|
||||||
//! (`helm upgrade --install`). The same Scores run against local k3d and
|
//!
|
||||||
//! a remote tenant; only the topology (selected by env) changes.
|
//! 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 harmony_app::{ComposeDeploy, Postgres};
|
||||||
|
|
||||||
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,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
async fn main() -> Result<()> {
|
async fn main() -> anyhow::Result<()> {
|
||||||
harmony_cli::cli_logger::init();
|
let app = ComposeDeploy::from_dir("timesheet", concat!(env!("CARGO_MANIFEST_DIR"), "/app"))
|
||||||
let cli = Cli::parse();
|
.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)?;
|
harmony_cli::app::app_main(app).await
|
||||||
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}"))
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 k8s_openapi::api::core::v1::Pod;
|
||||||
use kube::{
|
use kube::{
|
||||||
Error,
|
Error,
|
||||||
api::{Api, AttachParams, ListParams},
|
api::{Api, AttachParams, ListParams, LogParams},
|
||||||
error::DiscoveryError,
|
error::DiscoveryError,
|
||||||
runtime::reflector::Lookup,
|
runtime::reflector::Lookup,
|
||||||
};
|
};
|
||||||
@@ -32,6 +32,24 @@ impl ExecOutput {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl K8sClient {
|
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> {
|
pub async fn get_pod(&self, name: &str, namespace: Option<&str>) -> Result<Option<Pod>, Error> {
|
||||||
let api: Api<Pod> = match namespace {
|
let api: Api<Pod> = match namespace {
|
||||||
Some(ns) => Api::namespaced(self.client.clone(), ns),
|
Some(ns) => Api::namespaced(self.client.clone(), ns),
|
||||||
|
|||||||
@@ -1139,6 +1139,22 @@ pub struct K8sAnywhereConfig {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl 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 :
|
/// Reads an environment variable `env_var` and parses its content :
|
||||||
/// Comma-separated `key=value` pairs, e.g.,
|
/// Comma-separated `key=value` pairs, e.g.,
|
||||||
/// `kubeconfig=/path/to/primary.kubeconfig,context=primary-ctx`
|
/// `kubeconfig=/path/to/primary.kubeconfig,context=primary-ctx`
|
||||||
|
|||||||
@@ -6,6 +6,12 @@ use serde::Serialize;
|
|||||||
|
|
||||||
use crate::{executors::ExecutorError, topology::Topology};
|
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,
|
/// An ApplicationFeature provided by harmony, such as Backups, Monitoring, MultisiteAvailability,
|
||||||
/// ContinuousIntegration, ContinuousDelivery
|
/// ContinuousIntegration, ContinuousDelivery
|
||||||
#[async_trait]
|
#[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::{
|
use crate::modules::application::{
|
||||||
Application, ApplicationFeature, InstallationError, InstallationOutcome,
|
Application, ApplicationFeature, InstallationError, InstallationOutcome,
|
||||||
};
|
};
|
||||||
@@ -32,6 +36,12 @@ use schemars::JsonSchema;
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
/// **Deprecated.** Use `harmony_app::capabilities::Monitoring` via
|
||||||
|
/// `.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)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct Monitoring {
|
pub struct Monitoring {
|
||||||
pub application: Arc<dyn Application>,
|
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 std::{io::Write, process::Command, sync::Arc};
|
||||||
|
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
@@ -46,6 +50,12 @@ use crate::{
|
|||||||
/// - Harbor as artifact registru
|
/// - Harbor as artifact registru
|
||||||
/// - ArgoCD to install/upgrade/rollback/inspect k8s resources
|
/// - ArgoCD to install/upgrade/rollback/inspect k8s resources
|
||||||
/// - Kubernetes for runtime orchestration
|
/// - Kubernetes for runtime orchestration
|
||||||
|
/// **Deprecated.** Use the `harmony_app` application layer — `ComposeDeploy`
|
||||||
|
/// 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)]
|
#[derive(Debug, Default, Clone)]
|
||||||
pub struct PackagingDeployment<A: OCICompliant + HelmPackage> {
|
pub struct PackagingDeployment<A: OCICompliant + HelmPackage> {
|
||||||
pub application: Arc<A>,
|
pub application: Arc<A>,
|
||||||
|
|||||||
@@ -20,6 +20,14 @@ use crate::{score::Score, topology::Topology};
|
|||||||
|
|
||||||
use super::{Application, ApplicationFeature, ApplicationInterpret, HelmPackage, OCICompliant};
|
use super::{Application, ApplicationFeature, ApplicationInterpret, HelmPackage, OCICompliant};
|
||||||
|
|
||||||
|
/// **Deprecated.** Use the `harmony_app` application layer —
|
||||||
|
/// `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)]
|
#[derive(Debug, Serialize, Clone)]
|
||||||
pub struct ApplicationScore<A: Application + Serialize, T: Topology + Clone + Serialize>
|
pub struct ApplicationScore<A: Application + Serialize, T: Topology + Clone + Serialize>
|
||||||
where
|
where
|
||||||
@@ -29,6 +37,7 @@ where
|
|||||||
pub application: Arc<A>,
|
pub application: Arc<A>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[allow(deprecated)] // the Score impl for the deprecated type stays until removal
|
||||||
impl<
|
impl<
|
||||||
A: Application + Serialize + Clone + 'static,
|
A: Application + Serialize + Clone + 'static,
|
||||||
T: Topology + std::fmt::Debug + Clone + Serialize + '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 device_groups::ZitadelDeviceGroups;
|
||||||
pub use setup::{
|
pub use setup::{
|
||||||
MachineKeyType, MintedDeviceCredentials, ZitadelApiApp, ZitadelAppType, ZitadelApplication,
|
MachineKeyType, MintedDeviceCredentials, ZitadelApiApp, ZitadelAppType, ZitadelApplication,
|
||||||
ZitadelClientConfig, ZitadelMachineUser, ZitadelRole, ZitadelScheme, ZitadelSetupScore,
|
ZitadelClientConfig, ZitadelClientIdExportScore, ZitadelMachineUser, ZitadelRole,
|
||||||
mint_device_credentials,
|
ZitadelScheme, ZitadelSetupScore, mint_device_credentials,
|
||||||
};
|
};
|
||||||
|
|
||||||
use harmony_k8s::KubernetesDistribution;
|
use harmony_k8s::KubernetesDistribution;
|
||||||
|
|||||||
@@ -79,6 +79,12 @@ pub enum ZitadelAppType {
|
|||||||
/// OAuth 2.0 Device Authorization Grant (RFC 8628).
|
/// OAuth 2.0 Device Authorization Grant (RFC 8628).
|
||||||
/// For CLI tools, SSH sessions, containers, and headless environments.
|
/// For CLI tools, SSH sessions, containers, and headless environments.
|
||||||
DeviceCode,
|
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.
|
/// 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,
|
&self,
|
||||||
client: &reqwest::Client,
|
client: &reqwest::Client,
|
||||||
pat: &str,
|
pat: &str,
|
||||||
project_id: &str,
|
project_id: &str,
|
||||||
app_name: &str,
|
body: serde_json::Value,
|
||||||
) -> Result<String, String> {
|
) -> Result<String, String> {
|
||||||
let mut body = self.device_code_oidc_config_body();
|
|
||||||
body["name"] = serde_json::json!(app_name);
|
|
||||||
|
|
||||||
let resp = self
|
let resp = self
|
||||||
.post(
|
.post(
|
||||||
client,
|
client,
|
||||||
@@ -965,6 +970,51 @@ impl ZitadelSetupInterpret {
|
|||||||
.ok_or_else(|| "No clientId in app response".to_string())
|
.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.
|
/// Reconciles an existing app's OIDC config to the desired shape.
|
||||||
/// `ZitadelSetupScore` owns the app definition declaratively, so any
|
/// `ZitadelSetupScore` owns the app definition declaratively, so any
|
||||||
/// drift (manual UI edits, schema changes between harmony versions)
|
/// 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
|
/// config byte-for-byte. We treat that as the success case — the
|
||||||
/// declared state already holds — instead of letting reconciliation
|
/// declared state already holds — instead of letting reconciliation
|
||||||
/// fail every re-run after the first.
|
/// 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,
|
&self,
|
||||||
client: &reqwest::Client,
|
client: &reqwest::Client,
|
||||||
pat: &str,
|
pat: &str,
|
||||||
project_id: &str,
|
project_id: &str,
|
||||||
app_id: &str,
|
app_id: &str,
|
||||||
|
body: serde_json::Value,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
let resp = self
|
let resp = self
|
||||||
.put(
|
.put(
|
||||||
@@ -990,7 +1042,7 @@ impl ZitadelSetupInterpret {
|
|||||||
&format!("/management/v1/projects/{project_id}/apps/{app_id}/oidc_config"),
|
&format!("/management/v1/projects/{project_id}/apps/{app_id}/oidc_config"),
|
||||||
)
|
)
|
||||||
.bearer_auth(pat)
|
.bearer_auth(pat)
|
||||||
.json(&self.device_code_oidc_config_body())
|
.json(&body)
|
||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
.map_err(|e| format!("Failed to update OIDC config: {e}"))?;
|
.map_err(|e| format!("Failed to update OIDC config: {e}"))?;
|
||||||
@@ -1007,6 +1059,23 @@ impl ZitadelSetupInterpret {
|
|||||||
Err(format!("Update OIDC config failed: {body}"))
|
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(
|
async fn ensure_app(
|
||||||
&self,
|
&self,
|
||||||
client: &reqwest::Client,
|
client: &reqwest::Client,
|
||||||
@@ -1036,6 +1105,19 @@ impl ZitadelSetupInterpret {
|
|||||||
.update_device_code_oidc_config(client, pat, &project_id, &found.id)
|
.update_device_code_oidc_config(client, pat, &project_id, &found.id)
|
||||||
.await
|
.await
|
||||||
.map_err(InterpretError::new)?,
|
.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
|
config
|
||||||
.apps
|
.apps
|
||||||
@@ -1048,6 +1130,20 @@ impl ZitadelSetupInterpret {
|
|||||||
.create_device_code_app(client, pat, &project_id, &app.app_name)
|
.create_device_code_app(client, pat, &project_id, &app.app_name)
|
||||||
.await
|
.await
|
||||||
.map_err(InterpretError::new)?,
|
.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!(
|
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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
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]
|
#[test]
|
||||||
fn user_grant_key_round_trips_uniquely() {
|
fn user_grant_key_round_trips_uniquely() {
|
||||||
let k1 = ZitadelClientConfig::user_grant_key("alice", "fleet");
|
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
|
//! Typed `k8s_openapi` resources serialized at build time (ADR-018, the
|
||||||
//! fleet operator chart pattern) — no `{{ .Values }}` templating. One
|
//! fleet operator chart pattern) — no `{{ .Values }}` templating. One
|
||||||
//! Deployment + Service per compose service, one RWX PVC per mounted
|
//! Deployment + Service per compose service, one PVC per mounted named
|
||||||
//! named volume. Ingress is layered at deploy time by the
|
//! 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
|
//! [`Score`](crate::score), not baked here, so the host stays a deploy
|
||||||
//! knob.
|
//! knob.
|
||||||
|
|
||||||
@@ -14,15 +15,17 @@ use k8s_openapi::api::apps::v1::{
|
|||||||
Deployment, DeploymentSpec, DeploymentStrategy, RollingUpdateDeployment,
|
Deployment, DeploymentSpec, DeploymentStrategy, RollingUpdateDeployment,
|
||||||
};
|
};
|
||||||
use k8s_openapi::api::core::v1::{
|
use k8s_openapi::api::core::v1::{
|
||||||
Container, ContainerPort, EnvVar, PersistentVolumeClaim, PersistentVolumeClaimSpec,
|
Container, ContainerPort, EnvFromSource, EnvVar, PersistentVolumeClaim,
|
||||||
PersistentVolumeClaimVolumeSource, PodSpec, PodTemplateSpec, Service, ServicePort, ServiceSpec,
|
PersistentVolumeClaimSpec, PersistentVolumeClaimVolumeSource, PodSpec, PodTemplateSpec,
|
||||||
Volume, VolumeMount, VolumeResourceRequirements,
|
SecretEnvSource, Service, ServicePort, ServiceSpec, Volume, VolumeMount,
|
||||||
|
VolumeResourceRequirements,
|
||||||
};
|
};
|
||||||
use k8s_openapi::apimachinery::pkg::api::resource::Quantity;
|
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::LabelSelector;
|
||||||
use k8s_openapi::apimachinery::pkg::apis::meta::v1::ObjectMeta;
|
use k8s_openapi::apimachinery::pkg::apis::meta::v1::ObjectMeta;
|
||||||
use k8s_openapi::apimachinery::pkg::util::intstr::IntOrString;
|
use k8s_openapi::apimachinery::pkg::util::intstr::IntOrString;
|
||||||
|
|
||||||
|
use crate::Profile;
|
||||||
use harmony::modules::application::helm::{HelmChart, HelmResourceKind};
|
use harmony::modules::application::helm::{HelmChart, HelmResourceKind};
|
||||||
|
|
||||||
use crate::compose::{ComposeApp, ComposeService};
|
use crate::compose::{ComposeApp, ComposeService};
|
||||||
@@ -45,9 +48,48 @@ pub struct DeployConfig {
|
|||||||
/// `true` → RollingUpdate (needs RWX so old+new pods share volumes);
|
/// `true` → RollingUpdate (needs RWX so old+new pods share volumes);
|
||||||
/// `false` → Recreate (safe for single-writer stores like sqlite).
|
/// `false` → Recreate (safe for single-writer stores like sqlite).
|
||||||
pub rolling: bool,
|
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_RWX: &str = "ReadWriteMany";
|
||||||
|
const ACCESS_MODE_RWO: &str = "ReadWriteOnce";
|
||||||
|
|
||||||
/// The published image ref a service's Deployment should pull: built
|
/// The published image ref a service's Deployment should pull: built
|
||||||
/// services get our registry coordinates, prebuilt `image:` services are
|
/// 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
|
/// Build + write the chart to `out_dir`; returns the chart directory
|
||||||
/// `helm install <path>` wants.
|
/// `helm install <path>` wants.
|
||||||
pub fn build_chart(
|
pub fn build_chart(
|
||||||
@@ -127,7 +177,7 @@ fn deployment(svc: &ComposeService, cfg: &DeployConfig) -> Deployment {
|
|||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
let env: Vec<EnvVar> = svc
|
let mut env: Vec<EnvVar> = svc
|
||||||
.env
|
.env
|
||||||
.iter()
|
.iter()
|
||||||
.map(|(k, v)| EnvVar {
|
.map(|(k, v)| EnvVar {
|
||||||
@@ -136,6 +186,8 @@ fn deployment(svc: &ComposeService, cfg: &DeployConfig) -> Deployment {
|
|||||||
..Default::default()
|
..Default::default()
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
// Capability-contributed env (e.g. a DB connection by `secretKeyRef`).
|
||||||
|
env.extend(cfg.extra_env.iter().cloned());
|
||||||
|
|
||||||
let volume_mounts: Vec<VolumeMount> = svc
|
let volume_mounts: Vec<VolumeMount> = svc
|
||||||
.mounts
|
.mounts
|
||||||
@@ -200,6 +252,16 @@ fn deployment(svc: &ComposeService, cfg: &DeployConfig) -> Deployment {
|
|||||||
image_pull_policy: Some("IfNotPresent".to_string()),
|
image_pull_policy: Some("IfNotPresent".to_string()),
|
||||||
ports: (!ports.is_empty()).then_some(ports),
|
ports: (!ports.is_empty()).then_some(ports),
|
||||||
env: (!env.is_empty()).then_some(env),
|
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),
|
volume_mounts: (!volume_mounts.is_empty()).then_some(volume_mounts),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
}],
|
}],
|
||||||
@@ -253,7 +315,18 @@ fn pvc(name: &str, cfg: &DeployConfig) -> PersistentVolumeClaim {
|
|||||||
..Default::default()
|
..Default::default()
|
||||||
},
|
},
|
||||||
spec: Some(PersistentVolumeClaimSpec {
|
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(),
|
storage_class_name: cfg.storage_class.clone(),
|
||||||
resources: Some(VolumeResourceRequirements {
|
resources: Some(VolumeResourceRequirements {
|
||||||
requests: Some(BTreeMap::from([(
|
requests: Some(BTreeMap::from([(
|
||||||
@@ -286,6 +359,7 @@ mod tests {
|
|||||||
volume_size: "2Gi".to_string(),
|
volume_size: "2Gi".to_string(),
|
||||||
replicas: 2,
|
replicas: 2,
|
||||||
rolling: true,
|
rolling: true,
|
||||||
|
extra_env: vec![],
|
||||||
};
|
};
|
||||||
let tmp = tempfile::tempdir().unwrap();
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
build_chart(&app, &cfg, tmp.path()).unwrap();
|
build_chart(&app, &cfg, tmp.path()).unwrap();
|
||||||
@@ -354,6 +428,17 @@ mod tests {
|
|||||||
assert!(pvc.contains("2Gi"), "{pvc}");
|
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]
|
#[test]
|
||||||
fn rolling_strategy_and_replicas_render() {
|
fn rolling_strategy_and_replicas_render() {
|
||||||
let (_, _, tmp) = fixture();
|
let (_, _, tmp) = fixture();
|
||||||
@@ -380,7 +380,7 @@ volumes:
|
|||||||
/// source of truth and a contract for the chart tests.
|
/// source of truth and a contract for the chart tests.
|
||||||
#[test]
|
#[test]
|
||||||
fn the_examples_compose_file_imports() {
|
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");
|
let app = ComposeApp::from_dir(&dir).expect("import example compose");
|
||||||
assert_eq!(app.services.len(), 2);
|
assert_eq!(app.services.len(), 2);
|
||||||
assert_eq!(app.mounted_volumes(), vec!["timesheet-data".to_string()]);
|
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
|
//! `ApplicationScore`, no ArgoCD — the same build/publish/deploy split the
|
||||||
//! fleet stack uses.
|
//! fleet stack uses.
|
||||||
|
|
||||||
|
use std::collections::BTreeMap;
|
||||||
use std::str::FromStr;
|
use std::str::FromStr;
|
||||||
|
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use harmony_types::id::Id;
|
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 log::info;
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
|
|
||||||
@@ -19,10 +22,11 @@ use harmony::interpret::{Interpret, InterpretError, InterpretName, InterpretStat
|
|||||||
use harmony::inventory::Inventory;
|
use harmony::inventory::Inventory;
|
||||||
use harmony::modules::helm::chart::{HelmChartScore, NonBlankString};
|
use harmony::modules::helm::chart::{HelmChartScore, NonBlankString};
|
||||||
use harmony::modules::k8s::ingress::K8sIngressScore;
|
use harmony::modules::k8s::ingress::K8sIngressScore;
|
||||||
|
use harmony::modules::k8s::resource::K8sResourceScore;
|
||||||
use harmony::score::Score;
|
use harmony::score::Score;
|
||||||
use harmony::topology::{HelmCommand, K8sclient, Topology};
|
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;
|
use crate::compose::ComposeApp;
|
||||||
|
|
||||||
/// The already-published OCI chart to install (the CD path). When set,
|
/// The already-published OCI chart to install (the CD path). When set,
|
||||||
@@ -47,6 +51,31 @@ pub struct PublicEndpoint {
|
|||||||
pub cluster_issuer: Option<String>,
|
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)]
|
#[derive(Debug, Clone, Serialize)]
|
||||||
pub struct ComposeAppScore {
|
pub struct ComposeAppScore {
|
||||||
pub namespace: String,
|
pub namespace: String,
|
||||||
@@ -57,6 +86,12 @@ pub struct ComposeAppScore {
|
|||||||
/// (dev/e2e); `Some` installs the published OCI chart (CD).
|
/// (dev/e2e); `Some` installs the published OCI chart (CD).
|
||||||
pub published_chart: Option<PublishedChart>,
|
pub published_chart: Option<PublishedChart>,
|
||||||
pub public_endpoint: Option<PublicEndpoint>,
|
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 {
|
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> {
|
) -> Result<Outcome, InterpretError> {
|
||||||
let s = &self.score;
|
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
|
// Install from the published OCI chart (CD) or a chart rendered
|
||||||
// from the imported compose (dev/e2e). Each branch keeps its
|
// from the imported compose (dev/e2e). Each branch keeps its
|
||||||
// tempdir alive across the install.
|
// tempdir alive across the install.
|
||||||
@@ -6,7 +6,7 @@
|
|||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
use std::process::Command;
|
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 {
|
fn helm_present() -> bool {
|
||||||
Command::new("helm")
|
Command::new("helm")
|
||||||
@@ -23,7 +23,7 @@ fn generated_chart_passes_helm_lint_and_template() {
|
|||||||
return;
|
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 app = ComposeApp::from_dir(&app_dir).expect("import compose");
|
||||||
let cfg = DeployConfig {
|
let cfg = DeployConfig {
|
||||||
app_name: "timesheet".to_string(),
|
app_name: "timesheet".to_string(),
|
||||||
@@ -35,6 +35,7 @@ fn generated_chart_passes_helm_lint_and_template() {
|
|||||||
volume_size: "1Gi".to_string(),
|
volume_size: "1Gi".to_string(),
|
||||||
replicas: 2,
|
replicas: 2,
|
||||||
rolling: true,
|
rolling: true,
|
||||||
|
extra_env: vec![],
|
||||||
};
|
};
|
||||||
|
|
||||||
let tmp = tempfile::tempdir().unwrap();
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
@@ -11,8 +11,10 @@ tui = ["dep:harmony_tui"]
|
|||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
assert_cmd = "2.0.17"
|
assert_cmd = "2.0.17"
|
||||||
|
anyhow = { workspace = true }
|
||||||
clap = { version = "4.5.35", features = ["derive"] }
|
clap = { version = "4.5.35", features = ["derive"] }
|
||||||
harmony = { path = "../harmony" }
|
harmony = { path = "../harmony" }
|
||||||
|
harmony_app = { path = "../harmony_app" }
|
||||||
harmony_tui = { path = "../harmony_tui", optional = true }
|
harmony_tui = { path = "../harmony_tui", optional = true }
|
||||||
inquire.workspace = true
|
inquire.workspace = true
|
||||||
tokio.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 inquire::Confirm;
|
||||||
use tracing::debug;
|
use tracing::debug;
|
||||||
|
|
||||||
|
pub mod app;
|
||||||
pub mod cli_logger; // FIXME: Don't make me pub
|
pub mod cli_logger; // FIXME: Don't make me pub
|
||||||
mod cli_reporter;
|
mod cli_reporter;
|
||||||
pub mod progress;
|
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" }
|
harmony_config_derive = { version = "0.1.0", path = "../harmony_config_derive" }
|
||||||
serde = { version = "1.0.209", features = ["derive", "rc"] }
|
serde = { version = "1.0.209", features = ["derive", "rc"] }
|
||||||
serde_json = "1.0.127"
|
serde_json = "1.0.127"
|
||||||
|
toml.workspace = true
|
||||||
thiserror.workspace = true
|
thiserror.workspace = true
|
||||||
async-trait.workspace = true
|
async-trait.workspace = true
|
||||||
tokio.workspace = true
|
tokio.workspace = true
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ pub use source::local_file::LocalFileSource;
|
|||||||
pub use source::prompt::PromptSource;
|
pub use source::prompt::PromptSource;
|
||||||
pub use source::sqlite::SqliteSource;
|
pub use source::sqlite::SqliteSource;
|
||||||
pub use source::store::StoreSource;
|
pub use source::store::StoreSource;
|
||||||
|
pub use source::toml_file::TomlFileSource;
|
||||||
|
|
||||||
#[derive(Debug, Error)]
|
#[derive(Debug, Error)]
|
||||||
pub enum ConfigError {
|
pub enum ConfigError {
|
||||||
|
|||||||
@@ -3,3 +3,4 @@ pub mod local_file;
|
|||||||
pub mod prompt;
|
pub mod prompt;
|
||||||
pub mod sqlite;
|
pub mod sqlite;
|
||||||
pub mod store;
|
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]
|
[package]
|
||||||
name = "example"
|
name = "example"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|||||||
Reference in New Issue
Block a user