From eea006535bd896fe9d871a553729cd22ef1b2cd2 Mon Sep 17 00:00:00 2001 From: Jean-Gabriel Gill-Couture Date: Tue, 8 Sep 2026 22:10:34 -0400 Subject: [PATCH 1/4] refactor: drop harmony_app landfill MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Scores own deploy. harmony_app is identity, images, context, and HarmonyApp→Scores. Authoring DX lives in dx. Compose/chart/Application model and the Helm render test are gone. --- Cargo.lock | 18 +- Cargo.toml | 7 + ROADMAP.md | 8 +- docs/ARCHITECTURE.md | 2 + docs/SUMMARY.md | 5 +- docs/adr/026-application-lifecycle-cli.md | 4 +- docs/adr/029-application-components.md | 114 ++ docs/adr/README.md | 1 + docs/catalogs/capabilities.md | 8 +- docs/guides/application-capabilities.md | 100 +- docs/guides/application-cli.md | 13 +- examples/README.md | 18 + examples/compose_java_react/Cargo.toml | 5 +- examples/compose_java_react/README.md | 83 +- .../compose_java_react/app/docker-compose.yml | 6 +- examples/compose_java_react/src/main.rs | 89 +- examples/dx_accepts_kind/Cargo.toml | 12 + examples/dx_accepts_kind/README.md | 9 + examples/dx_accepts_kind/src/backend.rs | 27 + examples/dx_accepts_kind/src/bucket.rs | 12 + examples/dx_accepts_kind/src/frontend.rs | 22 + examples/dx_accepts_kind/src/harmony.rs | 82 + examples/dx_accepts_kind/src/localdev.rs | 28 + examples/dx_accepts_kind/src/mailhog.rs | 10 + examples/dx_accepts_kind/src/main.rs | 15 + examples/dx_accepts_kind/src/postgres.rs | 12 + examples/dx_accepts_kind/src/production.rs | 29 + examples/dx_accepts_kind/src/ses.rs | 10 + examples/dx_accepts_kind/src/zitadel.rs | 19 + examples/dx_bind_origin/Cargo.toml | 12 + examples/dx_bind_origin/README.md | 10 + examples/dx_bind_origin/src/backend.rs | 43 + examples/dx_bind_origin/src/bucket.rs | 19 + examples/dx_bind_origin/src/frontend.rs | 31 + examples/dx_bind_origin/src/harmony.rs | 82 + examples/dx_bind_origin/src/localdev.rs | 16 + examples/dx_bind_origin/src/main.rs | 34 + examples/dx_bind_origin/src/postgres.rs | 15 + examples/dx_bind_origin/src/production.rs | 15 + examples/dx_bind_origin/src/zitadel.rs | 17 + examples/dx_guide_ship/Cargo.toml | 12 + examples/dx_guide_ship/README.md | 39 + examples/dx_guide_ship/src/backend.rs | 42 + examples/dx_guide_ship/src/bucket.rs | 24 + examples/dx_guide_ship/src/frontend.rs | 24 + examples/dx_guide_ship/src/harmony.rs | 153 ++ examples/dx_guide_ship/src/localdev.rs | 16 + examples/dx_guide_ship/src/main.rs | 31 + examples/dx_guide_ship/src/postgres.rs | 24 + examples/dx_guide_ship/src/production.rs | 15 + examples/dx_guide_ship/src/zitadel.rs | 24 + examples/dx_slot_secret/Cargo.toml | 12 + examples/dx_slot_secret/README.md | 9 + examples/dx_slot_secret/src/api.rs | 44 + examples/dx_slot_secret/src/harmony.rs | 180 ++ examples/dx_slot_secret/src/local.rs | 14 + examples/dx_slot_secret/src/main.rs | 14 + examples/dx_slot_secret/src/production.rs | 13 + examples/dx_slot_secret/src/topology.rs | 51 + examples/dx_slot_secret/src/web.rs | 31 + examples/notes/Cargo.toml | 31 + examples/notes/README.md | 17 + examples/notes/src/app.rs | 36 + examples/notes/src/backend.rs | 20 + examples/notes/src/bin/notes_api.rs | 21 + examples/notes/src/bin/notes_web.rs | 20 + examples/notes/src/cluster.rs | 36 + examples/notes/src/frontend.rs | 19 + examples/notes/src/localdev.rs | 39 + examples/notes/src/main.rs | 68 + examples/notes/src/postgres.rs | 21 + harmony/Cargo.toml | 1 + harmony/src/lib.rs | 6 + harmony/src/modules/application/feature.rs | 8 +- .../application/features/monitoring.rs | 8 +- .../features/packaging_deployment.rs | 8 +- harmony/src/modules/application/rust.rs | 10 +- harmony/src/modules/host_process/mod.rs | 360 ++++ harmony/src/modules/mod.rs | 1 + .../modules/postgresql/score_debug_route.rs | 10 + harmony/src/modules/registry_pull_secret.rs | 9 +- harmony_app/Cargo.toml | 6 +- harmony_app/README.md | 64 + harmony_app/src/app.rs | 15 + harmony_app/src/application/k8s_anywhere.rs | 1742 ----------------- harmony_app/src/application/mod.rs | 20 - harmony_app/src/application/model.rs | 562 ------ harmony_app/src/application/resources.rs | 266 --- harmony_app/src/application/validation.rs | 597 ------ harmony_app/src/capabilities.rs | 386 ---- harmony_app/src/chart.rs | 546 ------ harmony_app/src/compose.rs | 443 ----- harmony_app/src/context.rs | 34 +- harmony_app/src/deploy.rs | 303 --- harmony_app/src/dx.rs | 266 +++ harmony_app/src/error.rs | 4 - harmony_app/src/lib.rs | 49 +- harmony_app/src/score.rs | 284 --- harmony_app/tests/helm_render.rs | 73 - harmony_cli/Cargo.toml | 3 +- harmony_cli/README.md | 40 +- harmony_cli/src/app.rs | 21 +- harmony_cli/src/lib.rs | 8 + harmony_config/Cargo.toml | 1 + harmony_config/src/lib.rs | 8 + 105 files changed, 2754 insertions(+), 5570 deletions(-) create mode 100644 docs/adr/029-application-components.md create mode 100644 examples/dx_accepts_kind/Cargo.toml create mode 100644 examples/dx_accepts_kind/README.md create mode 100644 examples/dx_accepts_kind/src/backend.rs create mode 100644 examples/dx_accepts_kind/src/bucket.rs create mode 100644 examples/dx_accepts_kind/src/frontend.rs create mode 100644 examples/dx_accepts_kind/src/harmony.rs create mode 100644 examples/dx_accepts_kind/src/localdev.rs create mode 100644 examples/dx_accepts_kind/src/mailhog.rs create mode 100644 examples/dx_accepts_kind/src/main.rs create mode 100644 examples/dx_accepts_kind/src/postgres.rs create mode 100644 examples/dx_accepts_kind/src/production.rs create mode 100644 examples/dx_accepts_kind/src/ses.rs create mode 100644 examples/dx_accepts_kind/src/zitadel.rs create mode 100644 examples/dx_bind_origin/Cargo.toml create mode 100644 examples/dx_bind_origin/README.md create mode 100644 examples/dx_bind_origin/src/backend.rs create mode 100644 examples/dx_bind_origin/src/bucket.rs create mode 100644 examples/dx_bind_origin/src/frontend.rs create mode 100644 examples/dx_bind_origin/src/harmony.rs create mode 100644 examples/dx_bind_origin/src/localdev.rs create mode 100644 examples/dx_bind_origin/src/main.rs create mode 100644 examples/dx_bind_origin/src/postgres.rs create mode 100644 examples/dx_bind_origin/src/production.rs create mode 100644 examples/dx_bind_origin/src/zitadel.rs create mode 100644 examples/dx_guide_ship/Cargo.toml create mode 100644 examples/dx_guide_ship/README.md create mode 100644 examples/dx_guide_ship/src/backend.rs create mode 100644 examples/dx_guide_ship/src/bucket.rs create mode 100644 examples/dx_guide_ship/src/frontend.rs create mode 100644 examples/dx_guide_ship/src/harmony.rs create mode 100644 examples/dx_guide_ship/src/localdev.rs create mode 100644 examples/dx_guide_ship/src/main.rs create mode 100644 examples/dx_guide_ship/src/postgres.rs create mode 100644 examples/dx_guide_ship/src/production.rs create mode 100644 examples/dx_guide_ship/src/zitadel.rs create mode 100644 examples/dx_slot_secret/Cargo.toml create mode 100644 examples/dx_slot_secret/README.md create mode 100644 examples/dx_slot_secret/src/api.rs create mode 100644 examples/dx_slot_secret/src/harmony.rs create mode 100644 examples/dx_slot_secret/src/local.rs create mode 100644 examples/dx_slot_secret/src/main.rs create mode 100644 examples/dx_slot_secret/src/production.rs create mode 100644 examples/dx_slot_secret/src/topology.rs create mode 100644 examples/dx_slot_secret/src/web.rs create mode 100644 examples/notes/Cargo.toml create mode 100644 examples/notes/README.md create mode 100644 examples/notes/src/app.rs create mode 100644 examples/notes/src/backend.rs create mode 100644 examples/notes/src/bin/notes_api.rs create mode 100644 examples/notes/src/bin/notes_web.rs create mode 100644 examples/notes/src/cluster.rs create mode 100644 examples/notes/src/frontend.rs create mode 100644 examples/notes/src/localdev.rs create mode 100644 examples/notes/src/main.rs create mode 100644 examples/notes/src/postgres.rs create mode 100644 harmony/src/modules/host_process/mod.rs create mode 100644 harmony_app/README.md delete mode 100644 harmony_app/src/application/k8s_anywhere.rs delete mode 100644 harmony_app/src/application/mod.rs delete mode 100644 harmony_app/src/application/model.rs delete mode 100644 harmony_app/src/application/resources.rs delete mode 100644 harmony_app/src/application/validation.rs delete mode 100644 harmony_app/src/capabilities.rs delete mode 100644 harmony_app/src/chart.rs delete mode 100644 harmony_app/src/compose.rs delete mode 100644 harmony_app/src/deploy.rs create mode 100644 harmony_app/src/dx.rs delete mode 100644 harmony_app/src/score.rs delete mode 100644 harmony_app/tests/helm_render.rs diff --git a/Cargo.lock b/Cargo.lock index fb5288e6..6dc6c146 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2532,18 +2532,6 @@ dependencies = [ "syn 3.0.3", ] -[[package]] -name = "docker-compose-types" -version = "0.24.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bdfd601efc90644958510466e8904ca90da65a98fdfe4d21f940644a21c026b4" -dependencies = [ - "derive_builder 0.20.2", - "indexmap 2.14.0", - "serde", - "serde_yaml", -] - [[package]] name = "dockerfile_builder" version = "0.1.6" @@ -2838,10 +2826,13 @@ name = "example-compose-java-react" version = "0.1.0" dependencies = [ "anyhow", + "async-trait", + "harmony", "harmony_app", "harmony_cli", "harmony_macros", "harmony_types", + "serde_json", "tokio", ] @@ -4295,15 +4286,12 @@ version = "0.1.0" dependencies = [ "anyhow", "async-trait", - "docker-compose-types", - "fqdn", "harmony", "harmony-k8s", "harmony_config", "harmony_types", "k8s-openapi", "log", - "reqwest 0.12.28", "schemars 0.8.22", "serde", "serde_json", diff --git a/Cargo.toml b/Cargo.toml index bb16f0e8..2528522a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,12 @@ [workspace] resolver = "2" +exclude = [ + "examples/dx_accepts_kind", + "examples/dx_bind_origin", + "examples/dx_guide_ship", + "examples/dx_slot_secret", + "examples/notes", +] members = [ "examples/*", "harmony", diff --git a/ROADMAP.md b/ROADMAP.md index 62d4c89b..e00e0e1e 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -71,11 +71,9 @@ APIs are not accepted designs. Verified against current code: -- The short `ComposeDeploy` example does not yet cover the real application, - which still assembles lower-level Scores. -- `ComposeDeploy` supports one public endpoint. Capability environment wiring - applies to every Compose service, PostgreSQL exports one fixed variable, and - the current Zitadel capability covers only a simple PKCE application. +- Apps implement `HarmonyApp` and compose Scores. `ComposeDeploy` / + `Application` are gone. The Java+React example does not yet create a + Service or Ingress (`K8sDeploymentScore` is image+env only). - The real deploy still duplicates namespace-derived names and some provider conventions. Those are concrete DRY problems. - The application layer does not yet prove its full readiness claim; Helm diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 10c46769..17d33033 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -45,6 +45,8 @@ Key ADRs that lock the foundational decisions: - **ADR-023** — Deploy architecture: Scores everywhere (including tests), per-app `*-deploy` crates, deploy blocks on smoke-test, topologies are compile-time. +- **ADR-029** — One component, one file, one type. Runtimes are + capabilities; contexts bind, they do not fork types. The full ADR set lives under `docs/adr/`. diff --git a/docs/SUMMARY.md b/docs/SUMMARY.md index 8411c162..d69edf30 100644 --- a/docs/SUMMARY.md +++ b/docs/SUMMARY.md @@ -34,7 +34,7 @@ - [Developer Guide](./guides/developer-guide.md) - [Application CLI — Use Cases & Commands](./guides/application-cli.md) - [Harmony Auth CLI](./guides/harmony-auth-cli.md) -- [Application Capabilities — .with(...)](./guides/application-capabilities.md) +- [Application Scores](./guides/application-capabilities.md) - [Writing a Score](./guides/writing-a-score.md) - [Writing a Topology](./guides/writing-a-topology.md) - [Adding Capabilities](./guides/adding-capabilities.md) @@ -84,4 +84,5 @@ - [025-3 · Groups as the Security Boundary](./adr/025-device-secret-access/025-3-groups-as-security-boundary.md) - [026 · Application Lifecycle CLI](./adr/026-application-lifecycle-cli.md) - [027 · Multi-Tenant Cloud Identity](./adr/027-multi-tenant-identity.md) - - [028 · Typed Score References](./adr/028-typed-score-references.md) + - [028 · Typed Score References](./adr/028-typed-score-references.md) + - [029 · Application Components — One File, One Type](./adr/029-application-components.md) diff --git a/docs/adr/026-application-lifecycle-cli.md b/docs/adr/026-application-lifecycle-cli.md index 2cd8ee2e..f1bac04d 100644 --- a/docs/adr/026-application-lifecycle-cli.md +++ b/docs/adr/026-application-lifecycle-cli.md @@ -8,7 +8,9 @@ Last Updated Date: 2026-06-10 ## Status -Proposed (draft) +Proposed (draft). The `Application` / `ComposeDeploy` authoring layer is +withdrawn; apps implement `HarmonyApp` and return Scores (ADR-029). The +verb/context contract below still holds. Extends ADR-023 (deploy architecture) — whose CLI principle (8) only covers *how binaries are discovered* — with the CLI's **experience diff --git a/docs/adr/029-application-components.md b/docs/adr/029-application-components.md new file mode 100644 index 00000000..8dd032f6 --- /dev/null +++ b/docs/adr/029-application-components.md @@ -0,0 +1,114 @@ +# Architecture Decision Record: Application Components — One File, One Type + +Initial Author: Jean-Gabriel Gill-Couture + +Initial Date: 2026-09-03 + +Last Updated Date: 2026-09-03 + +## Status + +Proposed. + +Supersedes the app-facing use of a single `Application` graph +(`harmony_app::application`) as how an application is structured. +Does not replace ADR-023 (framework Scores) or ADR-028 (typed Refs). + +## Context + +An application is a map of components someone can point at. The crate +should *be* that map. Earlier shapes hid it: one god-object `Application`, +a type per runtime (`FrontendCommand` / `FrontendContainer`), both image +and command on one blob checked later, a typestate builder that encodes +bind order (a DAG — and real systems cycle, ADR-028). + +## Decision + +**One component, one file, one type.** Opening `frontend.rs` is opening +the frontend. + +Runtimes are capabilities on that type, not sibling types: + +- `Container` — image on a cluster +- `Command` — a process (the command is a string in the impl) +- `Remote` — this context does not run it; it only names where it lives + +A database that is always on the cluster implements `Container` only. A +frontend that is a container in one context and a process in another +implements both. The struct is still `Frontend`. + +A **context** binds each component to one runtime that component +implements. That bind is an ordinary struct the author fills in +(`frontend: app.frontend.as_command()`, …) — missing a field or calling +`as_command()` on something with no `Command` impl does not compile. Not +a macro: macros are cryptic if Rust is not your first language. Refs whose content depends on how it runs (public origin vs +`localhost`) live on that runtime impl. Contexts are user-declared. +Harmony may ship default bindings as examples, not as a closed enum. + +Components exchange typed Refs (ADR-028). A Ref is desired state, valid +before either side has run. Cycles are two Refs, not two builder stages. + +A new **framework** Score exists only when Harmony should orchestrate a +reusable capability. App glue is ordinary code in the component file. +Org-specific Scores live in that org's crate, not in `harmony`. + +Planned framework Score: OCI/image — Dockerfile in config, build/push +from the context, export `ContainerRef` for `K8sDeploymentScore`. Until +then, build/push may be procedural and still yield a `ContainerRef`. + +## Principles + +- **Feldman:** illegal states don't compile (`Command` on Postgres, + Mailhog in Production, `CalloutRef` where `IssuerRef` is required). + Runtime is not a boolean on a god object. +- **Crichton:** file = mental object; you can reason about `frontend.rs` + without the mesh. Notation matches: `impl Command for Frontend` *is* + "we run it with a command". +- **Parse, don't validate:** no `.image` + `.command` on one blob + checked later. Missing `impl Command` is the parse. +- **SOLID:** SRP = one file; ISP = Postgres doesn't see `Command`; + DIP = others depend on refs; OCP = a new context is new impls on + **context-dependent** components only; LSP = `Frontend` stays + `Frontend`, refs stay `UrlRef`. + +**Ugly we accept:** N named `impl Production` / `LocalDev` / `Staging` +on dual-runtime files. Better than a mesh-wide enum or a typestate DAG. + +**Ugly we refuse:** a second type per runtime; context as `if local` +inside scores; builder order as types. + +**Harmony's job:** `Container`, `Command`, `Remote`, `Unit`, scores, refs. + +**App's job:** one file per component, impl the runtimes it actually +has, impl the contexts that bind them. + +A process launcher (`devbox run`, `cargo watch`, …) is a string inside +`impl Command for Frontend`, never a Harmony type. + +## Consequences + +- The crate layout *is* the documentation — five components or tens. +- Always-cluster components keep a single `Container` impl. +- The `Application` builder was deleted. Apps implement `HarmonyApp` and + compose Scores. +- `--context` (ADR-026) selects bindings. It does not rename types. + +## Alternatives considered + +**Single `Application` declaration.** One lowering to Scores. The mental +model sits behind a god object. + +**Typestate builder.** Readable as a script; types encode order. Breaks +cycles; Harmony would have to know the app's slots. + +**Dual types per runtime.** The author no longer has one frontend. + +**Closed context enum in Harmony.** Defaults become the product. +Applications cannot declare their own tying-together. + +## Additional Notes + +Related: ADR-023, ADR-026, ADR-028. + +First implementation: `harmony_app::dx` (`Command` / `Container` / +`Remote`, `as_command` / `as_container`, `Slot` / `Ref`), `HostProcessScore`. diff --git a/docs/adr/README.md b/docs/adr/README.md index 1935b80b..761c4d5d 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -60,6 +60,7 @@ Every ADR follows this structure: | [026](./026-application-lifecycle-cli.md) | Application Lifecycle CLI | Accepted | | [027](./027-multi-tenant-identity.md) | Multi-Tenant Cloud Identity | Accepted | | [028](./028-typed-score-references.md) | Typed Score References | Proposed | +| [029](./029-application-components.md) | Application Components — One File, One Type | Proposed | ## Contributing diff --git a/docs/catalogs/capabilities.md b/docs/catalogs/capabilities.md index 721aebf9..bb8ab9b6 100644 --- a/docs/catalogs/capabilities.md +++ b/docs/catalogs/capabilities.md @@ -1,14 +1,12 @@ # 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). +> do, exposed as trait bounds a `Score` requires. Apps compose Scores; see +> [Application Scores](../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`. -This list is primarily for developers **writing new Topologies or Scores**. As a user, you just need to know that the `Topology` you pick (like `K8sAnywhereTopology`) provides the capabilities your `Scores` (like `ApplicationScore`) need. +This list is primarily for developers **writing new Topologies or Scores**. As a user, you just need to know that the `Topology` you pick (like `K8sAnywhereTopology`) provides the capabilities your `Scores` need. diff --git a/docs/guides/application-capabilities.md b/docs/guides/application-capabilities.md index d1dd2ad6..3ff6381f 100644 --- a/docs/guides/application-capabilities.md +++ b/docs/guides/application-capabilities.md @@ -1,94 +1,20 @@ -# Application Capabilities — `.with(...)` +# Application Scores -> **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). +Apps implement [`HarmonyApp`](../../harmony_app/src/app.rs) and return Scores. +There is no `.with(...)` capability menu and no `ComposeDeploy` / +`Application` god-object. -A **capability** is an add-on you attach to an app deployment — a database, -monitoring, auth — by declaring it: +Compose the Scores that already exist: `K8sPostgreSQLScore`, +`FleetDeploymentScore`, `HostProcessScore`, `K8sDeploymentScore`, … ```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 `-db` | `DATABASE_URL` ← `secretKeyRef(-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 `-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>> { vec![] } - /// Env injected into the app's containers, wired by reference. - fn env(&self, app: &AppRef) -> Vec { vec![] } +async fn scores(&self, ctx: &AppContext, images: &ImageRefs) -> Result>>, AppError> { + Ok(vec![ + Box::new(K8sPostgreSQLScore::new(ctx.namespace()).cluster_name("app-db")), + Box::new(K8sDeploymentScore { /* … */ }), + ]) } ``` -`AppRef { name, namespace, profile }` is how the capability learns who it's -augmenting (and so derives names like `-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 - `-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. +See [Application CLI](./application-cli.md) for `ship` / `deploy` / `status` / +`logs`. Authoring DX (one component, one type) is ADR-029. diff --git a/docs/guides/application-cli.md b/docs/guides/application-cli.md index 590a7123..55b2a7d1 100644 --- a/docs/guides/application-cli.md +++ b/docs/guides/application-cli.md @@ -1,13 +1,16 @@ # Harmony Application CLI — Use Cases & Commands -> **Status: partially implemented.** The app lifecycle provides build, -> publish, ship, deploy, status, and logs. Other verbs below remain the design -> target. The *decisions and rationale* -> live in [ADR-026](../adr/026-application-lifecycle-cli.md) — read that -> for the "why"; this doc is the "what" and "how". +> **Status: partially implemented.** A `HarmonyApp` composes Scores; the CLI +> parses argv, resolves `--context`, and calls `ship` / `deploy` / `status` / +> `logs`. Other verbs below remain the design target. See +> [ADR-026](../adr/026-application-lifecycle-cli.md). ## Mental model +**One component, one file, one type** +([ADR-029](../adr/029-application-components.md)). Opening `frontend.rs` +is opening the frontend. Contexts bind a runtime; they do not split the type. + Four ideas carry the whole CLI: - **`harmony `.** A small fixed set of scope nouns — diff --git a/examples/README.md b/examples/README.md index 0c514ee1..e2bdf377 100644 --- a/examples/README.md +++ b/examples/README.md @@ -7,6 +7,7 @@ This directory contains runnable examples demonstrating Harmony's capabilities. | Example | Description | Local K3D | Existing Cluster | Hardware Needed | |---------|-------------|:---------:|:----------------:|:---------------:| | `postgresql` | Deploy a PostgreSQL cluster | ✅ | ✅ | — | +| `notes` | ADR-029 frontend+backend+Postgres (`localdev` / `cluster`) | ✅ | — | — | | `ntfy` | Deploy ntfy notification server | ✅ | ✅ | — | | `tenant` | Create a multi-tenant namespace | ✅ | ✅ | — | | `cert_manager` | Provision TLS certificates | ✅ | ✅ | — | @@ -126,6 +127,23 @@ export HARMONY_AUTOINSTALL=false cargo run -p example-monitoring ``` +## App-structure DX experiments (ADR-029) + +Four compile-only crates. Same app (frontend, backend, postgres, identity, bucket); +different types. Not shippable deploys. + +| Crate | Package | Idea | +|-------|---------|------| +| `dx_bind_origin` | `example-dx-bind-origin` | `bind::()`, origin as a Ref | +| `dx_slot_secret` | `example-dx-slot-secret` | `Slot` / `Secret`, exhaustive `bind!` | +| `dx_accepts_kind` | `example-dx-accepts-kind` | `Accepts` matrix, `Inhabit` / `Live` | +| `dx_guide_ship` | `example-dx-guide-ship` | Tutorial inventory, `Runtime` enum | + +```bash +cargo check -p example-dx-bind-origin -p example-dx-slot-secret \ + -p example-dx-accepts-kind -p example-dx-guide-ship +``` + ## Notes on Private Infrastructure Some examples use NationTech-hosted infrastructure by default (DNS domains like `*.nationtech.io`, `*.harmony.mcd`). These are not suitable for public use without modification. See the [Getting Started Guide](../docs/guides/getting-started.md) for the recommended public examples. diff --git a/examples/compose_java_react/Cargo.toml b/examples/compose_java_react/Cargo.toml index b13989c0..dff75c63 100644 --- a/examples/compose_java_react/Cargo.toml +++ b/examples/compose_java_react/Cargo.toml @@ -4,16 +4,19 @@ edition = "2024" version.workspace = true readme.workspace = true license.workspace = true -description = "Deploy a Java+React app to Kubernetes by importing its docker-compose. The whole example is one main.rs; the machinery lives in harmony_app. See ADR-026." +description = "Thin HarmonyApp composing K8sPostgreSQLScore + K8sDeploymentScore for a Java+React app." [[bin]] name = "compose-deploy" path = "src/main.rs" [dependencies] +harmony = { path = "../../harmony" } harmony_app = { path = "../../harmony_app" } harmony_cli = { path = "../../harmony_cli" } harmony_macros = { path = "../../harmony_macros" } harmony_types = { path = "../../harmony_types" } anyhow = { workspace = true } +async-trait.workspace = true +serde_json.workspace = true tokio = { workspace = true, features = ["full"] } diff --git a/examples/compose_java_react/README.md b/examples/compose_java_react/README.md index df6e582f..5b284569 100644 --- a/examples/compose_java_react/README.md +++ b/examples/compose_java_react/README.md @@ -1,79 +1,14 @@ -# Deploy a Java + React app from its `docker-compose.yml` +# Java + React timesheet as a `HarmonyApp` -Deploys an existing containerized app to Kubernetes with Harmony by -**importing its `docker-compose.yml`** — no hand-written manifests, no Argo. -The compose file stays the source of truth for the app's *base* shape; -deploy-only concerns are typed Rust. - -``` -app/ the "customer" project (their existing repo) - docker-compose.yml ← source of truth: images, ports, env, volumes - backend/ (Java + SQLite, Dockerfile) - frontend/ (React + nginx, Dockerfile) -Harmony.toml identity only — the app name (ADR-026 §3) -src/ - compose.rs import docker-compose → typed model (loud on the unsupported) - chart.rs model + profile knobs → a hydrated helm chart (typed k8s) - publish.rs build images + (push to a registry | import to k3d) - score.rs ComposeAppScore: helm upgrade --install + Ingress - deploy.rs ComposeDeploy — the declarative builder (impls HarmonyApp) - main.rs the app and its compiled deploy contexts -``` - -## The app, declared (`main.rs`) - -```rust -let app = ComposeDeploy::from_dir("timesheet", "./app")? - .expose("frontend", "timesheet.example.harmony.mcd") - .with(Postgres::managed()); // a managed CNPG database, wired in -let contexts = ContextCatalog::new([Context { - name: context_name!("local"), - namespace: "timesheet".parse()?, - spec: ContextSpec::Local(LocalContext::ManagedK3d), -}])?; -harmony_cli::app::app_main(app, contexts).await -``` - -That declaration gets you `ship` / `deploy` / `status` / `logs` over any -`--context`, all from `harmony_app`. See -[Application CLI](../../docs/guides/application-cli.md) and -[Application Capabilities](../../docs/guides/application-capabilities.md). - -## Design - -- **compose = base, profile = deploy knobs.** The importer reads only images, - ports, env, volumes. Replicas, storage class, TLS, rolling strategy come - from the `Profile` the context carries (ADR-026 §6/§7), not from compose or - `Harmony.toml`. A behavioral knob in either is a defect. -- **Capabilities compose upward.** `.with(Postgres::managed())` / - `.with(Monitoring::new()...)` / `.with(ZitadelAuth::oidc(...))` each deploy - their own Scores and wire into the app **by reference** (a DB URL via - `secretKeyRef`, a client_id via `configMapKeyRef`) — no `ApplicationScore`, - no ArgoCD. -- **Same definition, local → prod.** Only the context changes; the *same* - `ComposeAppScore` converges on local k3d or a tenant cluster. -- **Loud, never lossy.** Bind mounts and unparseable ports are hard errors; - `depends_on`/`command`/`restart` are warned, never silently dropped. - -## Run it (local k3d) +A thin [`HarmonyApp`](../../harmony_app) that composes existing Scores: +`K8sPostgreSQLScore` and `K8sDeploymentScore` for backend and frontend. +Images come from `app/backend` and `app/frontend`. The `docker-compose.yml` +is the original local shape; Harmony does not import it. ```sh -cd examples/compose_java_react -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 +cargo run --bin compose-deploy -- ship --context local ``` -The binary accepts only contexts compiled into `main.rs`. A context is always -required, so omitting `--context` and `HARMONY_CONTEXT` is an error. - -**Production** uses the same verbs against a prod context, with cluster -credentials brokered from OpenBao. - -## Storage note (SQLite) - -`Profile::Local` deploys single-replica with `Recreate` on a `ReadWriteOnce` -volume — safe for the demo's single-writer SQLite (one pod at a time). Prod -(`Profile::Prod`) replicates with `RollingUpdate` on `ReadWriteMany`; for a -real multi-writer store use `.with(Postgres::managed())` (the demo already -provisions it) and point the app at `DATABASE_URL`. +`--context` is required. `K8sDeploymentScore` does not create a Service or +Ingress, so in-cluster DNS (`BACKEND_URL=http://backend:8080`) and public +expose are holes until those Scores exist. diff --git a/examples/compose_java_react/app/docker-compose.yml b/examples/compose_java_react/app/docker-compose.yml index e80949bd..2ee09163 100644 --- a/examples/compose_java_react/app/docker-compose.yml +++ b/examples/compose_java_react/app/docker-compose.yml @@ -1,8 +1,4 @@ -# The customer's existing docker-compose — the single source of truth for -# the app's *base* shape (images, ports, env, volumes). Harmony imports -# this and derives the k8s deployment; deploy-only knobs (ingress host, -# storage class, replicas, rolling strategy) live in the deploy crate's -# Score, never here. See ADR-026. +# Original local compose. HarmonyApp does not import this file. name: timesheet services: backend: diff --git a/examples/compose_java_react/src/main.rs b/examples/compose_java_react/src/main.rs index 2bfc95a7..f5561e30 100644 --- a/examples/compose_java_react/src/main.rs +++ b/examples/compose_java_react/src/main.rs @@ -1,26 +1,81 @@ -//! `compose-deploy` — deploy this Java+React app to any context. -//! -//! The whole app, declared: -//! -//! compose-deploy ship --context local # build + import to k3d + deploy -//! compose-deploy status --context local -//! compose-deploy logs --context local -//! -//! `ship`/`deploy`/`status`/`logs` and the context model all come from -//! `harmony_app` — this file only declares the app and its available target. +//! Timesheet: a thin [`HarmonyApp`] that composes existing Scores. -use harmony_app::{ComposeDeploy, Context, ContextCatalog, ContextSpec, LocalContext, Postgres}; +use async_trait::async_trait; +use harmony::modules::k8s::deployment::K8sDeploymentScore; +use harmony::modules::postgresql::K8sPostgreSQLScore; +use harmony::score::Score; +use harmony::topology::K8sAnywhereTopology; +use harmony_app::{ + AppContext, AppError, AppIdentity, Context, ContextCatalog, ContextSpec, HarmonyApp, ImageRefs, + ImageSpec, LocalContext, +}; use harmony_macros::context_name; +use serde_json::json; + +struct Timesheet; + +#[async_trait] +impl HarmonyApp for Timesheet { + fn identity(&self, ctx: &AppContext) -> AppIdentity { + AppIdentity { + name: "timesheet".into(), + namespace: ctx.namespace().into(), + } + } + + fn images(&self, ctx: &AppContext) -> Result, AppError> { + let root = format!("{}/app", env!("CARGO_MANIFEST_DIR")); + Ok(["backend", "frontend"] + .map(|name| ImageSpec { + name: name.into(), + image: ctx.image(name), + context: format!("{root}/{name}").into(), + dockerfile: format!("{root}/{name}/Dockerfile").into(), + platform: None, + build_args: Vec::new(), + }) + .into()) + } + + async fn scores( + &self, + ctx: &AppContext, + images: &ImageRefs, + ) -> Result>>, AppError> { + let namespace = ctx.namespace(); + let postgres = K8sPostgreSQLScore::new(namespace).cluster_name("timesheet-db"); + let backend = K8sDeploymentScore { + name: "backend".into(), + image: images.require("backend")?.into(), + namespace: Some(namespace.into()), + env_vars: json!([{ + "name": "DATABASE_URL", + "valueFrom": { + "secretKeyRef": { "name": "timesheet-db-app", "key": "uri" } + } + }]), + }; + let frontend = K8sDeploymentScore { + name: "frontend".into(), + image: images.require("frontend")?.into(), + namespace: Some(namespace.into()), + env_vars: json!([{ + "name": "BACKEND_URL", + "value": "http://backend:8080" + }]), + }; + Ok(vec![ + Box::new(postgres), + Box::new(backend), + Box::new(frontend), + ]) + } +} #[tokio::main] async fn main() -> anyhow::Result<()> { - let app = ComposeDeploy::from_dir("timesheet", concat!(env!("CARGO_MANIFEST_DIR"), "/app")) - .map_err(|e| anyhow::anyhow!(e))? - .expose("frontend", "timesheet.example.harmony.mcd") - .with(Postgres::managed()); // deploys a CNPG cluster + wires DATABASE_URL - harmony_cli::app::app_main( - app, + Timesheet, ContextCatalog::new([Context { name: context_name!("local"), namespace: "timesheet".parse()?, diff --git a/examples/dx_accepts_kind/Cargo.toml b/examples/dx_accepts_kind/Cargo.toml new file mode 100644 index 00000000..a7f1b60f --- /dev/null +++ b/examples/dx_accepts_kind/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "example-dx-accepts-kind" +edition = "2024" +version.workspace = true +readme.workspace = true +license.workspace = true +description = "DX experiment (ADR-029): Accepts matrix, kinded ports, Dev/Live atmosphere." +publish = false + +[[bin]] +name = "dx-accepts-kind" +path = "src/main.rs" diff --git a/examples/dx_accepts_kind/README.md b/examples/dx_accepts_kind/README.md new file mode 100644 index 00000000..d7a98089 --- /dev/null +++ b/examples/dx_accepts_kind/README.md @@ -0,0 +1,9 @@ +# DX: Accepts × atmosphere + +Type-theory experiment. Empty matrix cells do not compile. +`bind!(Postgres => Exec)` and `bind_in!(Live, Mailhog => Image)` are left +commented. Backend ports are `Ref`, not sibling names. + +```bash +cargo check -p example-dx-accepts-kind +``` diff --git a/examples/dx_accepts_kind/src/backend.rs b/examples/dx_accepts_kind/src/backend.rs new file mode 100644 index 00000000..930b243d --- /dev/null +++ b/examples/dx_accepts_kind/src/backend.rs @@ -0,0 +1,27 @@ +use crate::harmony::{ + Accepts, Bin, Component, Dev, Edge, Exec, Idp, Image, Inhabit, Live, Obj, Ref, Rel, Smtp, +}; + +pub struct Backend; + +impl Component for Backend { + type Kind = Bin; +} + +impl Accepts for Backend {} +impl Accepts for Backend {} +impl Inhabit for Backend {} +impl Inhabit for Backend {} + +impl Backend { + pub fn wire( + edge: Ref, + rel: Ref, + idp: Ref, + obj: Ref, + smtp: Ref, + ) -> Self { + let _ = (edge, rel, idp, obj, smtp); + Self + } +} diff --git a/examples/dx_accepts_kind/src/bucket.rs b/examples/dx_accepts_kind/src/bucket.rs new file mode 100644 index 00000000..bbe146b0 --- /dev/null +++ b/examples/dx_accepts_kind/src/bucket.rs @@ -0,0 +1,12 @@ +use crate::harmony::{Accepts, Component, Dev, Image, Inhabit, Live, Managed, Obj}; + +pub struct Bucket; + +impl Component for Bucket { + type Kind = Obj; +} + +impl Accepts for Bucket {} +impl Accepts for Bucket {} +impl Inhabit for Bucket {} +impl Inhabit for Bucket {} diff --git a/examples/dx_accepts_kind/src/frontend.rs b/examples/dx_accepts_kind/src/frontend.rs new file mode 100644 index 00000000..9ef22f9c --- /dev/null +++ b/examples/dx_accepts_kind/src/frontend.rs @@ -0,0 +1,22 @@ +use crate::harmony::{ + Accepts, Bin, Cdn, Component, Dev, Edge, Exec, Idp, Image, Inhabit, Live, Ref, +}; + +pub struct Frontend; + +impl Component for Frontend { + type Kind = Edge; +} + +impl Accepts for Frontend {} +impl Accepts for Frontend {} +impl Accepts for Frontend {} +impl Inhabit for Frontend {} +impl Inhabit for Frontend {} + +impl Frontend { + pub fn wire(api: Ref, idp: Ref) -> Self { + let _ = (api, idp); + Self + } +} diff --git a/examples/dx_accepts_kind/src/harmony.rs b/examples/dx_accepts_kind/src/harmony.rs new file mode 100644 index 00000000..cc3ba5d4 --- /dev/null +++ b/examples/dx_accepts_kind/src/harmony.rs @@ -0,0 +1,82 @@ +//! Framework stand-in. Not `harmony_app`. +//! +//! Capability: `Accepts`. +//! Atmosphere: `Inhabit` / `Inhabit` — no blanket. +//! Kind on `Component`; ports are `Ref`. + +#![allow(dead_code)] + +use std::marker::PhantomData; + +pub trait Kind {} + +pub struct Edge; +impl Kind for Edge {} +pub struct Bin; +impl Kind for Bin {} +pub struct Rel; +impl Kind for Rel {} +pub struct Idp; +impl Kind for Idp {} +pub struct Obj; +impl Kind for Obj {} +pub struct Smtp; +impl Kind for Smtp {} + +pub struct Ref(PhantomData); + +impl Copy for Ref {} +impl Clone for Ref { + fn clone(&self) -> Self { + *self + } +} +impl Ref { + pub const fn new() -> Self { + Self(PhantomData) + } +} + +pub trait Runtime {} +pub struct Exec; +impl Runtime for Exec {} +pub struct Image; +impl Runtime for Image {} +pub struct Managed; +impl Runtime for Managed {} +pub struct Cdn; +impl Runtime for Cdn {} + +pub trait Accepts {} + +pub trait Component { + type Kind: Kind; + + fn as_ref() -> Ref { + Ref::new() + } +} + +pub struct Dev; +pub struct Live; + +/// Who may inhabit this component. No blanket: Mailhog omits `Live`. +pub trait Inhabit {} + +#[macro_export] +macro_rules! bind { + ($comp:ty => $rt:ty) => {{ + fn _accepts, R: $crate::harmony::Runtime>() {} + _accepts::<$comp, $rt>(); + }}; +} + +#[macro_export] +macro_rules! bind_in { + ($atm:ty, $comp:ty => $rt:ty) => {{ + fn _accepts, R: $crate::harmony::Runtime>() {} + fn _inhabit, A>() {} + _accepts::<$comp, $rt>(); + _inhabit::<$comp, $atm>(); + }}; +} diff --git a/examples/dx_accepts_kind/src/localdev.rs b/examples/dx_accepts_kind/src/localdev.rs new file mode 100644 index 00000000..2bdfbd91 --- /dev/null +++ b/examples/dx_accepts_kind/src/localdev.rs @@ -0,0 +1,28 @@ +use crate::backend::Backend; +use crate::bucket::Bucket; +use crate::frontend::Frontend; +use crate::harmony::{Component, Dev, Exec, Image}; +use crate::mailhog::Mailhog; +use crate::postgres::Postgres; +use crate::zitadel::Zitadel; +use crate::bind_in; + +pub fn bind() { + bind_in!(Dev, Frontend => Exec); + bind_in!(Dev, Backend => Exec); + bind_in!(Dev, Postgres => Image); + bind_in!(Dev, Zitadel => Image); + bind_in!(Dev, Bucket => Image); + bind_in!(Dev, Mailhog => Image); + // crate::bind!(Postgres => Exec); // E0277: Postgres: !Accepts + + let _ = Frontend::wire(Backend::as_ref(), Zitadel::as_ref()); + let _ = Backend::wire( + Frontend::as_ref(), + Postgres::as_ref(), + Zitadel::as_ref(), + Bucket::as_ref(), + Mailhog::as_ref(), + ); + let _ = Zitadel::wire(Frontend::as_ref()); +} diff --git a/examples/dx_accepts_kind/src/mailhog.rs b/examples/dx_accepts_kind/src/mailhog.rs new file mode 100644 index 00000000..a8149dc1 --- /dev/null +++ b/examples/dx_accepts_kind/src/mailhog.rs @@ -0,0 +1,10 @@ +use crate::harmony::{Accepts, Component, Dev, Image, Inhabit, Smtp}; + +pub struct Mailhog; + +impl Component for Mailhog { + type Kind = Smtp; +} + +impl Accepts for Mailhog {} +impl Inhabit for Mailhog {} diff --git a/examples/dx_accepts_kind/src/main.rs b/examples/dx_accepts_kind/src/main.rs new file mode 100644 index 00000000..7cf2eec6 --- /dev/null +++ b/examples/dx_accepts_kind/src/main.rs @@ -0,0 +1,15 @@ +mod backend; +mod bucket; +mod frontend; +mod harmony; +mod localdev; +mod mailhog; +mod postgres; +mod production; +mod ses; +mod zitadel; + +fn main() { + localdev::bind(); + production::bind(); +} diff --git a/examples/dx_accepts_kind/src/postgres.rs b/examples/dx_accepts_kind/src/postgres.rs new file mode 100644 index 00000000..6a23788a --- /dev/null +++ b/examples/dx_accepts_kind/src/postgres.rs @@ -0,0 +1,12 @@ +use crate::harmony::{Accepts, Component, Dev, Image, Inhabit, Live, Managed, Rel}; + +pub struct Postgres; + +impl Component for Postgres { + type Kind = Rel; +} + +impl Accepts for Postgres {} +impl Accepts for Postgres {} +impl Inhabit for Postgres {} +impl Inhabit for Postgres {} diff --git a/examples/dx_accepts_kind/src/production.rs b/examples/dx_accepts_kind/src/production.rs new file mode 100644 index 00000000..6de0c350 --- /dev/null +++ b/examples/dx_accepts_kind/src/production.rs @@ -0,0 +1,29 @@ +use crate::backend::Backend; +use crate::bucket::Bucket; +use crate::frontend::Frontend; +use crate::harmony::{Cdn, Component, Image, Live, Managed}; +use crate::postgres::Postgres; +use crate::ses::Ses; +use crate::zitadel::Zitadel; +use crate::bind_in; + +pub fn bind() { + bind_in!(Live, Frontend => Cdn); + bind_in!(Live, Backend => Image); + bind_in!(Live, Postgres => Managed); + bind_in!(Live, Zitadel => Managed); + bind_in!(Live, Bucket => Managed); + bind_in!(Live, Ses => Managed); + // bind_in!(Live, crate::mailhog::Mailhog => Image); // !Inhabit + // crate::bind!(Postgres => crate::harmony::Exec); // !Accepts + + let _ = Frontend::wire(Backend::as_ref(), Zitadel::as_ref()); + let _ = Backend::wire( + Frontend::as_ref(), + Postgres::as_ref(), + Zitadel::as_ref(), + Bucket::as_ref(), + Ses::as_ref(), + ); + let _ = Zitadel::wire(Frontend::as_ref()); +} diff --git a/examples/dx_accepts_kind/src/ses.rs b/examples/dx_accepts_kind/src/ses.rs new file mode 100644 index 00000000..d1b6f417 --- /dev/null +++ b/examples/dx_accepts_kind/src/ses.rs @@ -0,0 +1,10 @@ +use crate::harmony::{Accepts, Component, Inhabit, Live, Managed, Smtp}; + +pub struct Ses; + +impl Component for Ses { + type Kind = Smtp; +} + +impl Accepts for Ses {} +impl Inhabit for Ses {} diff --git a/examples/dx_accepts_kind/src/zitadel.rs b/examples/dx_accepts_kind/src/zitadel.rs new file mode 100644 index 00000000..c2684285 --- /dev/null +++ b/examples/dx_accepts_kind/src/zitadel.rs @@ -0,0 +1,19 @@ +use crate::harmony::{Accepts, Component, Dev, Edge, Idp, Image, Inhabit, Live, Managed, Ref}; + +pub struct Zitadel; + +impl Component for Zitadel { + type Kind = Idp; +} + +impl Accepts for Zitadel {} +impl Accepts for Zitadel {} +impl Inhabit for Zitadel {} +impl Inhabit for Zitadel {} + +impl Zitadel { + pub fn wire(edge: Ref) -> Self { + let _ = edge; + Self + } +} diff --git a/examples/dx_bind_origin/Cargo.toml b/examples/dx_bind_origin/Cargo.toml new file mode 100644 index 00000000..ee7357bb --- /dev/null +++ b/examples/dx_bind_origin/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "example-dx-bind-origin" +edition = "2024" +version.workspace = true +readme.workspace = true +license.workspace = true +description = "DX experiment (ADR-029): bind::(), origin as a Ref. Not a shippable deploy." +publish = false + +[[bin]] +name = "dx-bind-origin" +path = "src/main.rs" diff --git a/examples/dx_bind_origin/README.md b/examples/dx_bind_origin/README.md new file mode 100644 index 00000000..59c345bf --- /dev/null +++ b/examples/dx_bind_origin/README.md @@ -0,0 +1,10 @@ +# DX: bind + origin Ref + +Frontend-shaped. Opening `frontend.rs` is the frontend. Contexts bind +command vs container; origin is a Ref. Vite proxy is Command-only. + +```bash +cargo check -p example-dx-bind-origin +``` + +`.bind::()` does not compile (uncomment in `localdev.rs`). diff --git a/examples/dx_bind_origin/src/backend.rs b/examples/dx_bind_origin/src/backend.rs new file mode 100644 index 00000000..71431f54 --- /dev/null +++ b/examples/dx_bind_origin/src/backend.rs @@ -0,0 +1,43 @@ +use crate::bucket::Bucket; +use crate::harmony::{Command, Container, Image, Launch, Origin, Ref}; +use crate::postgres::Postgres; + +pub struct Backend { + origin: Ref, + issuer: Ref, + db: Ref, + files: Ref, +} + +impl Backend { + pub fn new( + origin: Ref, + issuer: Ref, + db: Ref, + files: Ref, + ) -> Self { + Self { + origin, + issuer, + db, + files, + } + } +} + +impl Command for Backend { + fn launch(&self) -> Launch { + Launch::sh("npm run start") + .cwd("api") + .env("FRONTEND_ORIGIN", self.origin) + .env("OIDC_ISSUER", self.issuer) + .env("DATABASE_URL", self.db) + .env("S3_ENDPOINT", self.files) + } +} + +impl Container for Backend { + fn image(&self) -> Image { + Image::build("api") + } +} diff --git a/examples/dx_bind_origin/src/bucket.rs b/examples/dx_bind_origin/src/bucket.rs new file mode 100644 index 00000000..071ac6c4 --- /dev/null +++ b/examples/dx_bind_origin/src/bucket.rs @@ -0,0 +1,19 @@ +use crate::harmony::{Container, Image, Origin, Ref, Remote}; + +pub struct Bucket; + +impl Bucket { + pub fn endpoint(&self) -> Ref { + Ref::new() + } + + pub fn cors(&self, _: Ref) {} +} + +impl Container for Bucket { + fn image(&self) -> Image { + Image::build("s3") + } +} + +impl Remote for Bucket {} diff --git a/examples/dx_bind_origin/src/frontend.rs b/examples/dx_bind_origin/src/frontend.rs new file mode 100644 index 00000000..d28af2d5 --- /dev/null +++ b/examples/dx_bind_origin/src/frontend.rs @@ -0,0 +1,31 @@ +use crate::harmony::{Command, Container, Image, Launch, Origin, Ref}; + +pub struct Frontend { + issuer: Ref, +} + +impl Frontend { + pub fn new(issuer: Ref) -> Self { + Self { issuer } + } + + pub fn origin(&self) -> Ref { + Ref::new() + } +} + +impl Command for Frontend { + fn launch(&self) -> Launch { + Launch::sh("npm run dev") + .cwd("web") + .env("VITE_OIDC_ISSUER", self.issuer) + .env("VITE_PROXY", "/v1") + .publish(self.origin(), 5173) + } +} + +impl Container for Frontend { + fn image(&self) -> Image { + Image::build("web") + } +} diff --git a/examples/dx_bind_origin/src/harmony.rs b/examples/dx_bind_origin/src/harmony.rs new file mode 100644 index 00000000..80768ac9 --- /dev/null +++ b/examples/dx_bind_origin/src/harmony.rs @@ -0,0 +1,82 @@ +//! Stand-in for framework types this experiment needs. Not `harmony_app`. + +use std::marker::PhantomData; + +pub struct Origin; + +pub struct Ref(PhantomData); + +impl Copy for Ref {} +impl Clone for Ref { + fn clone(&self) -> Self { + *self + } +} + +impl Ref { + pub const fn new() -> Self { + Self(PhantomData) + } +} + +pub trait EnvVal {} +impl EnvVal for Ref {} +impl EnvVal for &'static str {} + +pub struct Launch; + +impl Launch { + pub fn sh(_: &'static str) -> Self { + Self + } + pub fn cwd(self, _: &'static str) -> Self { + self + } + pub fn env(self, _: &'static str, _: impl EnvVal) -> Self { + self + } + pub fn publish(self, _: Ref, _: u16) -> Self { + self + } +} + +pub struct Image; + +impl Image { + pub fn build(_: &'static str) -> Self { + Self + } +} + +pub trait Command { + fn launch(&self) -> Launch; +} + +pub trait Container { + fn image(&self) -> Image; +} + +pub trait Remote {} + +#[allow(non_camel_case_types)] +pub struct command; +#[allow(non_camel_case_types)] +pub struct container; +#[allow(non_camel_case_types)] +pub struct remote; + +pub trait Accepts {} +impl Accepts for T {} +impl Accepts for T {} +impl Accepts for T {} + +pub struct Context; + +impl Context { + pub fn named(_: &'static str) -> Self { + Self + } + pub fn bind, R>(self) -> Self { + self + } +} diff --git a/examples/dx_bind_origin/src/localdev.rs b/examples/dx_bind_origin/src/localdev.rs new file mode 100644 index 00000000..98651b76 --- /dev/null +++ b/examples/dx_bind_origin/src/localdev.rs @@ -0,0 +1,16 @@ +use crate::backend::Backend; +use crate::bucket::Bucket; +use crate::frontend::Frontend; +use crate::harmony::{command, container, Context}; +use crate::postgres::Postgres; +use crate::zitadel::Zitadel; + +pub fn context() -> Context { + Context::named("localdev") + .bind::() + .bind::() + .bind::() + .bind::() + .bind::() + // .bind::() +} diff --git a/examples/dx_bind_origin/src/main.rs b/examples/dx_bind_origin/src/main.rs new file mode 100644 index 00000000..12d4e103 --- /dev/null +++ b/examples/dx_bind_origin/src/main.rs @@ -0,0 +1,34 @@ +#![allow(dead_code)] + +mod backend; +mod bucket; +mod frontend; +mod harmony; +mod localdev; +mod postgres; +mod production; +mod zitadel; + +use crate::harmony::{Command, Container}; + +fn main() { + let db = postgres::Postgres; + let files = bucket::Bucket; + let id = zitadel::Zitadel; + let web = frontend::Frontend::new(id.issuer()); + let api = backend::Backend::new(web.origin(), id.issuer(), db.url(), files.endpoint()); + + id.redirect(web.origin(), "/auth/callback"); + files.cors(web.origin()); + + let _ = web.launch(); + let _ = web.image(); + let _ = api.launch(); + let _ = api.image(); + let _ = db.image(); + let _ = id.image(); + let _ = files.image(); + + let _ = localdev::context(); + let _ = production::context(); +} diff --git a/examples/dx_bind_origin/src/postgres.rs b/examples/dx_bind_origin/src/postgres.rs new file mode 100644 index 00000000..c51e1f28 --- /dev/null +++ b/examples/dx_bind_origin/src/postgres.rs @@ -0,0 +1,15 @@ +use crate::harmony::{Container, Image, Ref}; + +pub struct Postgres; + +impl Postgres { + pub fn url(&self) -> Ref { + Ref::new() + } +} + +impl Container for Postgres { + fn image(&self) -> Image { + Image::build("postgres:16") + } +} diff --git a/examples/dx_bind_origin/src/production.rs b/examples/dx_bind_origin/src/production.rs new file mode 100644 index 00000000..327d3f05 --- /dev/null +++ b/examples/dx_bind_origin/src/production.rs @@ -0,0 +1,15 @@ +use crate::backend::Backend; +use crate::bucket::Bucket; +use crate::frontend::Frontend; +use crate::harmony::{container, remote, Context}; +use crate::postgres::Postgres; +use crate::zitadel::Zitadel; + +pub fn context() -> Context { + Context::named("production") + .bind::() + .bind::() + .bind::() + .bind::() + .bind::() +} diff --git a/examples/dx_bind_origin/src/zitadel.rs b/examples/dx_bind_origin/src/zitadel.rs new file mode 100644 index 00000000..c9b89192 --- /dev/null +++ b/examples/dx_bind_origin/src/zitadel.rs @@ -0,0 +1,17 @@ +use crate::harmony::{Container, Image, Origin, Ref}; + +pub struct Zitadel; + +impl Zitadel { + pub fn issuer(&self) -> Ref { + Ref::new() + } + + pub fn redirect(&self, _: Ref, _: &'static str) {} +} + +impl Container for Zitadel { + fn image(&self) -> Image { + Image::build("zitadel") + } +} diff --git a/examples/dx_guide_ship/Cargo.toml b/examples/dx_guide_ship/Cargo.toml new file mode 100644 index 00000000..7c7f9353 --- /dev/null +++ b/examples/dx_guide_ship/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "example-dx-guide-ship" +edition = "2024" +version.workspace = true +readme.workspace = true +license.workspace = true +description = "DX experiment (ADR-029): tutorial-shaped ship.rs inventory, Runtime enum binds." +publish = false + +[[bin]] +name = "dx-guide-ship" +path = "src/main.rs" diff --git a/examples/dx_guide_ship/README.md b/examples/dx_guide_ship/README.md new file mode 100644 index 00000000..6c03b26b --- /dev/null +++ b/examples/dx_guide_ship/README.md @@ -0,0 +1,39 @@ +# Getting started: one file per component + +Junior-shaped ship inventory (ADR-029). Opening `frontend.rs` *is* the +frontend. There is no `FrontendCommand` type. + +```bash +cargo check -p example-dx-guide-ship +``` + +## Copy this layout + +| File | What it is | +|------|------------| +| `src/main.rs` | Inventory: `.component::()` + contexts | +| `src/frontend.rs` | `impl Command` (`npm run dev`) and `impl Container` | +| `src/backend.rs` | Same, plus `env()` wiring via `Ref` etc. | +| `src/postgres.rs` | Container (or Remote in production). `PostgresScore` | +| `src/zitadel.rs` | `ZitadelScore` — do not invent `AppScore` | +| `src/bucket.rs` | `BucketScore` | +| `src/localdev.rs` | `Context::new("localdev").bind::(Runtime::Command)` | +| `src/production.rs` | Frontend/Backend as Container; managed bits as Remote | +| `src/harmony.rs` | Stub framework types. Not `harmony_app`. | + +## Runtime is an enum + +```rust +Context::new("localdev").bind::(Runtime::Command) +``` + +`Runtime` is `Command | Container | Remote`. Binding `Runtime::Command` on +Postgres **compiles**. The type system does not know Postgres has no +`impl Command`. That is the tradeoff: obvious API, few traits, illegal +binds are not a type error. (The other DX crates make that a type error.) + +## Cycles + +`Frontend::env` takes `Ref` and `Backend::env` takes +`Ref`. Those URLs are allocated before either side starts. +A Ref is desired state, not a live connection. diff --git a/examples/dx_guide_ship/src/backend.rs b/examples/dx_guide_ship/src/backend.rs new file mode 100644 index 00000000..a801ec4e --- /dev/null +++ b/examples/dx_guide_ship/src/backend.rs @@ -0,0 +1,42 @@ +use crate::bucket::Bucket; +use crate::frontend::Frontend; +use crate::harmony::{Command, CommandSpec, Container, Env, Image, Ref}; +use crate::postgres::Postgres; +use crate::zitadel::Zitadel; + +/// THIS FILE IS THE BACKEND. +pub struct Backend; + +impl Command for Backend { + fn command(&self) -> CommandSpec { + CommandSpec::new("npm").args(&["run", "start:dev"]).port(8080) + } +} + +impl Container for Backend { + fn image(&self) -> Image { + Image::from_dockerfile("../backend/Dockerfile").port(8080) + } +} + +impl Backend { + /// Circular with Frontend::env: URLs are allocated before either side starts. + /// A Ref is desired state, not a live connection. + pub fn env( + &self, + web: Ref, + db: Ref, + idp: Ref, + files: Ref, + ) -> Env { + Env::new() + .set("CORS_ORIGIN", web.public_url()) + .set("DATABASE_URL", db.url()) + .set("OIDC_ISSUER", idp.issuer()) + .set("OIDC_CLIENT_ID", idp.client_id()) + .set("S3_ENDPOINT", files.endpoint()) + .set("S3_BUCKET", files.name()) + .set("S3_ACCESS_KEY", files.access_key()) + .set("S3_SECRET_KEY", files.secret_key()) + } +} diff --git a/examples/dx_guide_ship/src/bucket.rs b/examples/dx_guide_ship/src/bucket.rs new file mode 100644 index 00000000..e15215f6 --- /dev/null +++ b/examples/dx_guide_ship/src/bucket.rs @@ -0,0 +1,24 @@ +use crate::harmony::scores::BucketScore; +use crate::harmony::{Container, Image, Remote, Url}; + +/// THIS FILE IS THE FILE BUCKET. +pub struct Bucket; + +// Harmony already has BucketScore. Do not invent AppScore for glue. +impl BucketScore for Bucket { + fn name(&self) -> &str { + "app-files" + } +} + +impl Container for Bucket { + fn image(&self) -> Image { + Image::from_dockerfile("s3") + } +} + +impl Remote for Bucket { + fn url(&self) -> Url { + Url::from_score(self) + } +} diff --git a/examples/dx_guide_ship/src/frontend.rs b/examples/dx_guide_ship/src/frontend.rs new file mode 100644 index 00000000..905c40f3 --- /dev/null +++ b/examples/dx_guide_ship/src/frontend.rs @@ -0,0 +1,24 @@ +use crate::backend::Backend; +use crate::harmony::{Command, CommandSpec, Container, Env, Image, Ref}; + +/// THIS FILE IS THE FRONTEND. Not FrontendCommand / FrontendContainer. +pub struct Frontend; + +impl Command for Frontend { + fn command(&self) -> CommandSpec { + CommandSpec::new("npm").args(&["run", "dev"]).port(5173) + } +} + +impl Container for Frontend { + fn image(&self) -> Image { + Image::from_dockerfile("../frontend/Dockerfile").port(80) + } +} + +impl Frontend { + /// Circular with Backend::env: URLs are allocated before either side starts. + pub fn env(&self, api: Ref) -> Env { + Env::new().set("VITE_API_URL", api.public_url()) + } +} diff --git a/examples/dx_guide_ship/src/harmony.rs b/examples/dx_guide_ship/src/harmony.rs new file mode 100644 index 00000000..c10d46e7 --- /dev/null +++ b/examples/dx_guide_ship/src/harmony.rs @@ -0,0 +1,153 @@ +//! Stand-in for framework types. Not `harmony_app`. + +use std::marker::PhantomData; + +/// How a context runs a component. An enum, not a type parameter — +/// `bind::(Runtime::Command)` is *not* a type error. See README. +pub enum Runtime { + Command, + Container, + Remote, +} + +pub struct CommandSpec { + pub bin: &'static str, +} + +impl CommandSpec { + pub fn new(bin: &'static str) -> Self { + Self { bin } + } + pub fn args(self, _: &[&str]) -> Self { + self + } + pub fn port(self, _: u16) -> Self { + self + } +} + +pub struct Image; +impl Image { + pub fn from_dockerfile(_: &'static str) -> Self { + Self + } + pub fn port(self, _: u16) -> Self { + self + } +} + +pub struct Env; +impl Env { + pub fn new() -> Self { + Self + } + pub fn set(self, _: &'static str, _: impl ToString) -> Self { + self + } +} + +pub struct Url; +impl Url { + pub fn from_score(_: &T) -> Self { + Self + } +} +impl std::fmt::Display for Url { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "url") + } +} + +pub struct Ref(PhantomData); +impl Copy for Ref {} +impl Clone for Ref { + fn clone(&self) -> Self { + *self + } +} +impl Ref { + pub fn new() -> Self { + Self(PhantomData) + } + pub fn public_url(self) -> Url { + Url + } + pub fn url(self) -> Url { + Url + } + pub fn issuer(self) -> Url { + Url + } + pub fn client_id(self) -> &'static str { + "id" + } + pub fn endpoint(self) -> Url { + Url + } + pub fn name(self) -> &'static str { + "files" + } + pub fn access_key(self) -> &'static str { + "key" + } + pub fn secret_key(self) -> &'static str { + "secret" + } +} + +pub trait Command { + fn command(&self) -> CommandSpec; +} +pub trait Container { + fn image(&self) -> Image; +} +pub trait Remote { + fn url(&self) -> Url { + Url + } +} + +pub struct Context; + +impl Context { + pub fn new(_: &'static str) -> Self { + Self + } + /// Not type-checked against Command/Container impls — see README. + pub fn bind(self, _: Runtime) -> Self { + let _ = PhantomData::; + self + } +} + +pub struct Ship; + +impl Ship { + pub fn component(self) -> Self { + self + } + pub fn context(self, _: fn() -> Context) -> Self { + self + } + pub fn run(self) {} +} + +pub fn ship() -> Ship { + Ship +} + +pub mod scores { + /// Framework scores. App glue is `env()` on the component — do not invent `AppScore`. + pub trait PostgresScore { + fn name(&self) -> &str; + fn version(&self) -> &str { + "16" + } + } + pub trait ZitadelScore { + fn name(&self) -> &str; + } + pub trait BucketScore { + fn name(&self) -> &str; + } +} diff --git a/examples/dx_guide_ship/src/localdev.rs b/examples/dx_guide_ship/src/localdev.rs new file mode 100644 index 00000000..27690666 --- /dev/null +++ b/examples/dx_guide_ship/src/localdev.rs @@ -0,0 +1,16 @@ +use crate::backend::Backend; +use crate::bucket::Bucket; +use crate::frontend::Frontend; +use crate::harmony::{Context, Runtime}; +use crate::postgres::Postgres; +use crate::zitadel::Zitadel; + +pub fn context() -> Context { + Context::new("localdev") + .bind::(Runtime::Command) + .bind::(Runtime::Command) + .bind::(Runtime::Container) + .bind::(Runtime::Container) + .bind::(Runtime::Container) + // .bind::(Runtime::Command) // compiles! Runtime is an enum. See README. +} diff --git a/examples/dx_guide_ship/src/main.rs b/examples/dx_guide_ship/src/main.rs new file mode 100644 index 00000000..a74c596d --- /dev/null +++ b/examples/dx_guide_ship/src/main.rs @@ -0,0 +1,31 @@ +//! Copy-paste inventory. One line per component, one line per context. + +#![allow(dead_code)] + +mod backend; +mod bucket; +mod frontend; +mod harmony; +mod localdev; +mod postgres; +mod production; +mod zitadel; + +use backend::Backend; +use bucket::Bucket; +use frontend::Frontend; +use harmony::ship; +use postgres::Postgres; +use zitadel::Zitadel; + +fn main() { + ship() + .component::() + .component::() + .component::() + .component::() + .component::() + .context(localdev::context) + .context(production::context) + .run(); +} diff --git a/examples/dx_guide_ship/src/postgres.rs b/examples/dx_guide_ship/src/postgres.rs new file mode 100644 index 00000000..f31d4ca3 --- /dev/null +++ b/examples/dx_guide_ship/src/postgres.rs @@ -0,0 +1,24 @@ +use crate::harmony::scores::PostgresScore; +use crate::harmony::{Container, Image, Remote, Url}; + +/// THIS FILE IS POSTGRES. No `impl Command` — but `Runtime::Command` still binds (see README). +pub struct Postgres; + +// Harmony already has PostgresScore. Do not invent AppScore for glue. +impl PostgresScore for Postgres { + fn name(&self) -> &str { + "app-db" + } +} + +impl Container for Postgres { + fn image(&self) -> Image { + Image::from_dockerfile("postgres:16") + } +} + +impl Remote for Postgres { + fn url(&self) -> Url { + Url::from_score(self) + } +} diff --git a/examples/dx_guide_ship/src/production.rs b/examples/dx_guide_ship/src/production.rs new file mode 100644 index 00000000..c31611c8 --- /dev/null +++ b/examples/dx_guide_ship/src/production.rs @@ -0,0 +1,15 @@ +use crate::backend::Backend; +use crate::bucket::Bucket; +use crate::frontend::Frontend; +use crate::harmony::{Context, Runtime}; +use crate::postgres::Postgres; +use crate::zitadel::Zitadel; + +pub fn context() -> Context { + Context::new("production") + .bind::(Runtime::Container) + .bind::(Runtime::Container) + .bind::(Runtime::Remote) + .bind::(Runtime::Remote) + .bind::(Runtime::Remote) +} diff --git a/examples/dx_guide_ship/src/zitadel.rs b/examples/dx_guide_ship/src/zitadel.rs new file mode 100644 index 00000000..c80297e1 --- /dev/null +++ b/examples/dx_guide_ship/src/zitadel.rs @@ -0,0 +1,24 @@ +use crate::harmony::scores::ZitadelScore; +use crate::harmony::{Container, Image, Remote, Url}; + +/// THIS FILE IS ZITADEL. +pub struct Zitadel; + +// Harmony already has ZitadelScore. Do not invent AppScore for glue. +impl ZitadelScore for Zitadel { + fn name(&self) -> &str { + "idp" + } +} + +impl Container for Zitadel { + fn image(&self) -> Image { + Image::from_dockerfile("zitadel") + } +} + +impl Remote for Zitadel { + fn url(&self) -> Url { + Url::from_score(self) + } +} diff --git a/examples/dx_slot_secret/Cargo.toml b/examples/dx_slot_secret/Cargo.toml new file mode 100644 index 00000000..1bf15cee --- /dev/null +++ b/examples/dx_slot_secret/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "example-dx-slot-secret" +edition = "2024" +version.workspace = true +readme.workspace = true +license.workspace = true +description = "DX experiment (ADR-029): Slot/Ref, Secret, dialect types, exhaustive bind." +publish = false + +[[bin]] +name = "dx-slot-secret" +path = "src/main.rs" diff --git a/examples/dx_slot_secret/README.md b/examples/dx_slot_secret/README.md new file mode 100644 index 00000000..902daca2 --- /dev/null +++ b/examples/dx_slot_secret/README.md @@ -0,0 +1,9 @@ +# DX: Slot / Secret / dialect + +Backend-shaped. `Slot` is advertised; `Ref` is held. +`Secret` cannot sit on `WebEnv`. `db.jdbc()` is Java; `db.url()` is Node. +`bind! { t.db => Command }` does not compile. + +```bash +cargo check -p example-dx-slot-secret +``` diff --git a/examples/dx_slot_secret/src/api.rs b/examples/dx_slot_secret/src/api.rs new file mode 100644 index 00000000..c1daaa7c --- /dev/null +++ b/examples/dx_slot_secret/src/api.rs @@ -0,0 +1,44 @@ +use crate::harmony::{ + CommandRuntime, ContainerRuntime, HttpUrl, Issuer, Jdbc, MachineKey, OAuthClient, Ref, S3, + Secret, Slot, +}; + +pub struct Api { + pub url: Slot, + pub client: Slot, + pub db: Ref>, + pub issuer: Ref, + pub sa: Ref>, + pub bucket: Ref, +} + +pub struct ApiEnv { + pub db: Secret, + pub issuer: Issuer, + pub sa: Secret, + pub s3: S3, +} + +impl Api { + pub fn env(&self) -> ApiEnv { + ApiEnv { + db: self.db.get(), + issuer: self.issuer.get(), + sa: self.sa.get(), + s3: self.bucket.get(), + } + } +} + +impl CommandRuntime for Api { + fn listen(&self) -> u16 { + let _ = self.env(); + 8080 + } +} + +impl ContainerRuntime for Api { + fn image(&self) -> &'static str { + "api" + } +} diff --git a/examples/dx_slot_secret/src/harmony.rs b/examples/dx_slot_secret/src/harmony.rs new file mode 100644 index 00000000..485507cb --- /dev/null +++ b/examples/dx_slot_secret/src/harmony.rs @@ -0,0 +1,180 @@ +use std::marker::PhantomData; + +#[derive(Clone, Copy)] +pub struct Slot(PhantomData); + +impl Slot { + pub const fn new() -> Self { + Self(PhantomData) + } + + pub fn as_ref(self) -> Ref { + Ref(PhantomData) + } + + pub fn public(self) -> Ref { + Ref(PhantomData) + } + + pub fn join(self, _: &'static str) -> Ref { + Ref(PhantomData) + } +} + +impl From> for Ref { + fn from(slot: Slot) -> Self { + slot.as_ref() + } +} + +#[derive(Clone, Copy)] +pub struct Ref(PhantomData); + +impl Ref { + pub fn get(self) -> T + where + T: Default, + { + T::default() + } +} + +#[derive(Clone, Copy, Default)] +pub struct Secret(PhantomData); + +#[derive(Clone, Copy, Default)] +pub struct Jdbc; + +#[derive(Clone, Copy, Default)] +pub struct PgUrl; + +#[derive(Clone, Copy, Default)] +pub struct HttpUrl; + +#[derive(Clone, Copy, Default)] +pub struct Issuer; + +#[derive(Clone, Copy, Default)] +pub struct MachineKey; + +#[derive(Clone, Copy, Default)] +pub struct S3; + +#[derive(Clone, Copy)] +pub struct OAuthClient; + +pub struct Command; +pub struct Container; +pub struct Remote; + +pub trait RunsAs {} + +pub trait CommandRuntime { + fn listen(&self) -> u16; +} + +pub trait ContainerRuntime { + fn image(&self) -> &'static str; +} + +pub trait RemoteRuntime {} + +impl RunsAs for T {} +impl RunsAs for T {} +impl RunsAs for T {} + +#[derive(Clone, Copy)] +pub struct Postgres; + +impl Postgres { + pub fn named(_: &'static str) -> Self { + Self + } + + pub fn jdbc(&self) -> Ref> { + Ref(PhantomData) + } + + pub fn url(&self) -> Ref> { + Ref(PhantomData) + } +} + +impl ContainerRuntime for Postgres { + fn image(&self) -> &'static str { + "postgres:16" + } +} + +impl RemoteRuntime for Postgres {} + +#[derive(Clone, Copy)] +pub struct Identity; + +impl Identity { + pub fn named(_: &'static str) -> Self { + Self + } + + pub fn issuer(&self) -> Ref { + Ref(PhantomData) + } + + pub fn machine_key(&self, _: Ref) -> Ref> { + Ref(PhantomData) + } + + pub fn redirect(&self, _: impl Into>) {} + + pub fn invite_base(&self, _: Slot) {} + + pub fn client(&self, _: Slot, _: ClientKind) {} +} + +impl ContainerRuntime for Identity { + fn image(&self) -> &'static str { + "identity" + } +} + +impl RemoteRuntime for Identity {} + +pub enum ClientKind { + Machine, +} + +#[derive(Clone, Copy)] +pub struct Bucket; + +impl Bucket { + pub fn named(_: &'static str) -> Self { + Self + } + + pub fn s3(&self) -> Ref { + Ref(PhantomData) + } +} + +impl ContainerRuntime for Bucket { + fn image(&self) -> &'static str { + "bucket" + } +} + +impl RemoteRuntime for Bucket {} + +#[macro_export] +macro_rules! bind { + ($($t:ident . $comp:ident => $rt:ty),+ $(,)?) => {{ + $( + { + fn __runs_as>(_: &T) {} + __runs_as(&$t.$comp); + } + )+ + $crate::topology::Bound { + $($comp: &$t.$comp,)+ + } + }}; +} diff --git a/examples/dx_slot_secret/src/local.rs b/examples/dx_slot_secret/src/local.rs new file mode 100644 index 00000000..f569016d --- /dev/null +++ b/examples/dx_slot_secret/src/local.rs @@ -0,0 +1,14 @@ +use crate::bind; +use crate::harmony::{Command, Container}; +use crate::topology::Topology; + +pub fn bind(t: &Topology) { + bind! { + t.api => Command, + t.web => Command, + t.db => Container, + t.idp => Container, + t.bucket => Container, + }; + // bind! { t.db => Command } // does not compile +} diff --git a/examples/dx_slot_secret/src/main.rs b/examples/dx_slot_secret/src/main.rs new file mode 100644 index 00000000..ac909146 --- /dev/null +++ b/examples/dx_slot_secret/src/main.rs @@ -0,0 +1,14 @@ +#![allow(dead_code)] + +mod api; +mod harmony; +mod local; +mod production; +mod topology; +mod web; + +fn main() { + let t = topology::topology(); + local::bind(&t); + production::bind(&t); +} diff --git a/examples/dx_slot_secret/src/production.rs b/examples/dx_slot_secret/src/production.rs new file mode 100644 index 00000000..be01d4f9 --- /dev/null +++ b/examples/dx_slot_secret/src/production.rs @@ -0,0 +1,13 @@ +use crate::bind; +use crate::harmony::{Container, Remote}; +use crate::topology::Topology; + +pub fn bind(t: &Topology) { + bind! { + t.api => Container, + t.web => Container, + t.db => Remote, + t.idp => Container, + t.bucket => Remote, + }; +} diff --git a/examples/dx_slot_secret/src/topology.rs b/examples/dx_slot_secret/src/topology.rs new file mode 100644 index 00000000..c9c6aecb --- /dev/null +++ b/examples/dx_slot_secret/src/topology.rs @@ -0,0 +1,51 @@ +use crate::api::Api; +use crate::harmony::{Bucket, ClientKind, Identity, Postgres, Slot}; +use crate::web::Web; + +pub struct Topology { + pub api: Api, + pub web: Web, + pub db: Postgres, + pub idp: Identity, + pub bucket: Bucket, +} + +pub struct Bound<'a> { + pub api: &'a Api, + pub web: &'a Web, + pub db: &'a Postgres, + pub idp: &'a Identity, + pub bucket: &'a Bucket, +} + +pub fn topology() -> Topology { + let db = Postgres::named("app"); + let bucket = Bucket::named("files"); + let idp = Identity::named("auth"); + let client = Slot::new(); + let url = Slot::new(); + idp.client(client, ClientKind::Machine); + let api = Api { + url, + client, + db: db.jdbc(), + // db: db.url(), // Api is Java-shaped; url() is Node + issuer: idp.issuer(), + sa: idp.machine_key(client.as_ref()), + bucket: bucket.s3(), + }; + let web = Web { + url: Slot::new(), + api: api.url.public(), + }; + idp.redirect(api.url); + idp.redirect(web.url.join("/silent-renew")); + idp.invite_base(web.url); + Topology { + api, + web, + db, + idp, + bucket, + } +} diff --git a/examples/dx_slot_secret/src/web.rs b/examples/dx_slot_secret/src/web.rs new file mode 100644 index 00000000..862ed1f4 --- /dev/null +++ b/examples/dx_slot_secret/src/web.rs @@ -0,0 +1,31 @@ +use crate::harmony::{CommandRuntime, ContainerRuntime, HttpUrl, Ref, Slot}; + +pub struct Web { + pub url: Slot, + pub api: Ref, +} + +pub struct WebEnv { + pub api: HttpUrl, +} + +impl Web { + pub fn env(&self) -> WebEnv { + WebEnv { + api: self.api.get(), + } + } +} + +impl CommandRuntime for Web { + fn listen(&self) -> u16 { + let _ = self.env(); + 5173 + } +} + +impl ContainerRuntime for Web { + fn image(&self) -> &'static str { + "web" + } +} diff --git a/examples/notes/Cargo.toml b/examples/notes/Cargo.toml new file mode 100644 index 00000000..55a8a85e --- /dev/null +++ b/examples/notes/Cargo.toml @@ -0,0 +1,31 @@ +[package] +name = "example-notes" +edition = "2024" +version.workspace = true +readme.workspace = true +license.workspace = true +description = "ADR-029 notes app: frontend + backend + postgres, localdev vs cluster." +publish = false + +[[bin]] +name = "notes" +path = "src/main.rs" + +[[bin]] +name = "notes-api" +path = "src/bin/notes_api.rs" + +[[bin]] +name = "notes-web" +path = "src/bin/notes_web.rs" + +[dependencies] +anyhow.workspace = true +async-trait.workspace = true +harmony = { path = "../../harmony" } +harmony_app = { path = "../../harmony_app" } +harmony_cli = { path = "../../harmony_cli" } +harmony_macros = { path = "../../harmony_macros" } +harmony_types = { path = "../../harmony_types" } +serde_json.workspace = true +tokio = { workspace = true, features = ["full"] } diff --git a/examples/notes/README.md b/examples/notes/README.md new file mode 100644 index 00000000..c1cb7c89 --- /dev/null +++ b/examples/notes/README.md @@ -0,0 +1,17 @@ +# notes — ADR-029 example + +Frontend, backend, postgres. One type per file. Contexts bind runtimes. + +```bash +cargo build -p example-notes --bins +cargo run -p example-notes --bin notes -- ship --context localdev +# frontend http://127.0.0.1:15173 backend http://127.0.0.1:18080/ (200 + "ok" when DATABASE_URL is set) + +cargo run -p example-notes --bin notes -- ship --context cluster +``` + +`localdev`: host processes + CNPG on k3d (NodePort). +`cluster`: all containers on the same k3d (nginx + http-echo + CNPG). + +Context bind is a struct you fill in (`as_command` / `as_container`). Forget a +field or `postgres.as_command()` — compile error, no macro. diff --git a/examples/notes/src/app.rs b/examples/notes/src/app.rs new file mode 100644 index 00000000..735b2f1d --- /dev/null +++ b/examples/notes/src/app.rs @@ -0,0 +1,36 @@ +use crate::backend::Backend; +use crate::frontend::Frontend; +use crate::postgres::Postgres; +use harmony_app::dx::{BoundComp, HttpUrl, Slot}; + +pub struct Notes { + pub frontend: Frontend, + pub backend: Backend, + pub postgres: Postgres, +} + +pub struct Bound<'a, F, B, P> { + pub frontend: BoundComp<'a, Frontend, F>, + pub backend: BoundComp<'a, Backend, B>, + pub postgres: BoundComp<'a, Postgres, P>, +} + +pub fn notes() -> Notes { + let web_url = Slot::::new(); + let api_url = Slot::::new(); + let postgres = Postgres::named("notes-db"); + let backend = Backend { + url: api_url, + origin: web_url.public(), + db: postgres.url(), + }; + let frontend = Frontend { + url: web_url, + api: api_url.public(), + }; + Notes { + frontend, + backend, + postgres, + } +} diff --git a/examples/notes/src/backend.rs b/examples/notes/src/backend.rs new file mode 100644 index 00000000..91c0b56a --- /dev/null +++ b/examples/notes/src/backend.rs @@ -0,0 +1,20 @@ +use harmony_app::dx::{Command, Container, HttpUrl, Image, Launch, PgUrl, Ref, Secret, Slot}; + +#[allow(dead_code)] +pub struct Backend { + pub url: Slot, + pub origin: Ref, + pub db: Ref>, +} + +impl Command for Backend { + fn launch(&self) -> Launch { + Launch::bin("notes-api").listen(18080) + } +} + +impl Container for Backend { + fn image(&self) -> Image { + Image::from_registry("hashicorp/http-echo:1.0.0").port(5678) + } +} diff --git a/examples/notes/src/bin/notes_api.rs b/examples/notes/src/bin/notes_api.rs new file mode 100644 index 00000000..52697285 --- /dev/null +++ b/examples/notes/src/bin/notes_api.rs @@ -0,0 +1,21 @@ +use std::io::Write; +use std::net::TcpListener; + +fn main() { + let db = std::env::var("DATABASE_URL").unwrap_or_default(); + let listener = TcpListener::bind("127.0.0.1:18080").expect("bind 18080"); + eprintln!("notes-api listening 18080 DATABASE_URL_set={}", !db.is_empty()); + for mut stream in listener.incoming().flatten() { + let body = if db.is_empty() { + "DATABASE_URL missing" + } else { + "ok" + }; + let status = if db.is_empty() { "503" } else { "200" }; + let resp = format!( + "HTTP/1.1 {status} OK\r\nContent-Type: text/plain\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len() + ); + let _ = stream.write_all(resp.as_bytes()); + } +} diff --git a/examples/notes/src/bin/notes_web.rs b/examples/notes/src/bin/notes_web.rs new file mode 100644 index 00000000..bae61bcc --- /dev/null +++ b/examples/notes/src/bin/notes_web.rs @@ -0,0 +1,20 @@ +use std::io::Write; +use std::net::TcpListener; + +const PAGE: &str = r#" +notes +

notes frontend

+

backend

+"#; + +fn main() { + let listener = TcpListener::bind("127.0.0.1:15173").expect("bind 15173"); + eprintln!("notes-web listening 15173"); + for mut stream in listener.incoming().flatten() { + let resp = format!( + "HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{PAGE}", + PAGE.len() + ); + let _ = stream.write_all(resp.as_bytes()); + } +} diff --git a/examples/notes/src/cluster.rs b/examples/notes/src/cluster.rs new file mode 100644 index 00000000..cc6c382c --- /dev/null +++ b/examples/notes/src/cluster.rs @@ -0,0 +1,36 @@ +use crate::app::{Bound, Notes}; +use harmony::modules::k8s::deployment::K8sDeploymentScore; +use harmony::modules::postgresql::K8sPostgreSQLScore; +use harmony::score::Score; +use harmony::topology::K8sAnywhereTopology; +use harmony_app::dx::{container, AsContainer, Container}; +use serde_json::json; + +pub fn bind(app: &Notes) -> Bound<'_, container, container, container> { + Bound { + frontend: app.frontend.as_container(), + backend: app.backend.as_container(), + postgres: app.postgres.as_container(), + } +} + +pub fn scores(app: &Notes, namespace: &str) -> Vec>> { + let bound = bind(app); + let cluster = bound.postgres.inner.name; + let pg = K8sPostgreSQLScore::new(namespace).cluster_name(cluster); + let frontend = K8sDeploymentScore { + name: "frontend".into(), + image: bound.frontend.inner.image().name, + namespace: Some(namespace.into()), + env_vars: json!([]), + }; + let backend = K8sDeploymentScore { + name: "backend".into(), + image: bound.backend.inner.image().name, + namespace: Some(namespace.into()), + env_vars: json!([ + { "name": "CORS_ORIGIN", "value": "http://frontend" } + ]), + }; + vec![Box::new(pg), Box::new(frontend), Box::new(backend)] +} diff --git a/examples/notes/src/frontend.rs b/examples/notes/src/frontend.rs new file mode 100644 index 00000000..1144bc3a --- /dev/null +++ b/examples/notes/src/frontend.rs @@ -0,0 +1,19 @@ +use harmony_app::dx::{Command, Container, HttpUrl, Image, Launch, Ref, Slot}; + +#[allow(dead_code)] +pub struct Frontend { + pub url: Slot, + pub api: Ref, +} + +impl Command for Frontend { + fn launch(&self) -> Launch { + Launch::bin("notes-web").listen(15173) + } +} + +impl Container for Frontend { + fn image(&self) -> Image { + Image::from_registry("nginx:alpine").port(80) + } +} diff --git a/examples/notes/src/localdev.rs b/examples/notes/src/localdev.rs new file mode 100644 index 00000000..18936a6d --- /dev/null +++ b/examples/notes/src/localdev.rs @@ -0,0 +1,39 @@ +use crate::app::{Bound, Notes}; +use harmony::modules::host_process::HostProcessScore; +use harmony::modules::postgresql::{K8sPostgreSQLScore, PostgresDebugRouteScore}; +use harmony::score::Score; +use harmony::topology::K8sAnywhereTopology; +use harmony_app::dx::{command, container, AsCommand, AsContainer, Command, Launch}; + +pub fn bind(app: &Notes) -> Bound<'_, command, command, container> { + Bound { + frontend: app.frontend.as_command(), + backend: app.backend.as_command(), + postgres: app.postgres.as_container(), + // postgres: app.postgres.as_command(), // does not compile: no Command + } +} + +pub fn scores(app: &Notes, namespace: &str) -> Vec>> { + let bound = bind(app); + let cluster = bound.postgres.inner.name; + let pg = K8sPostgreSQLScore::new(namespace).cluster_name(cluster); + let debug = PostgresDebugRouteScore::new(namespace, cluster).node_port(); + let mut api = from_launch("backend", bound.backend.inner.launch()); + api = api.env_postgres_uri("DATABASE_URL", namespace, cluster); + let web = from_launch("frontend", bound.frontend.inner.launch()); + vec![Box::new(pg), Box::new(debug), Box::new(api), Box::new(web)] +} + +fn from_launch(name: &str, launch: Launch) -> HostProcessScore { + let mut score = HostProcessScore::new(name, launch.program); + score.args = launch.args; + score.cwd = launch.cwd; + score.port = launch.port; + score.env = launch + .env + .into_iter() + .map(|(k, v)| (k, harmony::modules::host_process::ProcessEnv::Literal(v))) + .collect(); + score +} diff --git a/examples/notes/src/main.rs b/examples/notes/src/main.rs new file mode 100644 index 00000000..86e45440 --- /dev/null +++ b/examples/notes/src/main.rs @@ -0,0 +1,68 @@ +mod app; +mod backend; +mod cluster; +mod frontend; +mod localdev; +mod postgres; + +use async_trait::async_trait; +use harmony::topology::K8sAnywhereTopology; +use harmony_app::dx::container; +use harmony_app::{ + AppContext, AppError, AppIdentity, Context, ContextCatalog, ContextSpec, HarmonyApp, + ImageRefs, LocalContext, +}; +use harmony_macros::context_name; + +struct NotesApp; + +#[async_trait] +impl HarmonyApp for NotesApp { + fn identity(&self, ctx: &AppContext) -> AppIdentity { + AppIdentity { + name: "notes".into(), + namespace: ctx.namespace().to_string(), + } + } + + async fn scores( + &self, + ctx: &AppContext, + _images: &ImageRefs, + ) -> Result>>, AppError> { + let notes = app::notes(); + match ctx.name() { + "localdev" => { + let _ = localdev::bind(¬es); + Ok(localdev::scores(¬es, ctx.namespace())) + } + "cluster" => { + let _: app::Bound = cluster::bind(¬es); + Ok(cluster::scores(¬es, ctx.namespace())) + } + other => Err(AppError::InvalidComposition(format!( + "unknown context '{other}'" + ))), + } + } +} + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + harmony_cli::app::app_main( + NotesApp, + ContextCatalog::new([ + Context { + name: context_name!("localdev"), + namespace: "notes".parse()?, + spec: ContextSpec::Local(LocalContext::ManagedK3d), + }, + Context { + name: context_name!("cluster"), + namespace: "notes".parse()?, + spec: ContextSpec::Local(LocalContext::ManagedK3d), + }, + ])?, + ) + .await +} diff --git a/examples/notes/src/postgres.rs b/examples/notes/src/postgres.rs new file mode 100644 index 00000000..9f4b9955 --- /dev/null +++ b/examples/notes/src/postgres.rs @@ -0,0 +1,21 @@ +use harmony_app::dx::{Container, Image, PgUrl, Ref, Secret, Slot}; + +pub struct Postgres { + pub name: &'static str, +} + +impl Postgres { + pub fn named(name: &'static str) -> Self { + Self { name } + } + + pub fn url(&self) -> Ref> { + Slot::new().as_ref() + } +} + +impl Container for Postgres { + fn image(&self) -> Image { + Image::from_registry("ghcr.io/cloudnative-pg/postgresql:16") + } +} diff --git a/harmony/Cargo.toml b/harmony/Cargo.toml index 6993ca97..69ac1fb4 100644 --- a/harmony/Cargo.toml +++ b/harmony/Cargo.toml @@ -4,6 +4,7 @@ edition = "2024" version.workspace = true readme.workspace = true license.workspace = true +description = "Infrastructure orchestration: Score, Topology, Interpret. Source of truth for applying desired state." [features] default = ["podman"] diff --git a/harmony/src/lib.rs b/harmony/src/lib.rs index e700b622..386cbd08 100644 --- a/harmony/src/lib.rs +++ b/harmony/src/lib.rs @@ -1,3 +1,9 @@ +//! Infrastructure orchestration: Scores (desired state), Topologies (where), +//! Interpret (how). Source of truth for applying that model on a topology +//! (Kubernetes, host process, Fleet device, …). +//! +//! Not application delivery (`harmony_app`) and not a UI (`harmony_cli`). + mod domain; pub use domain::*; pub mod infra; diff --git a/harmony/src/modules/application/feature.rs b/harmony/src/modules/application/feature.rs index 9c56b410..e981663e 100644 --- a/harmony/src/modules/application/feature.rs +++ b/harmony/src/modules/application/feature.rs @@ -6,11 +6,9 @@ use serde::Serialize; use crate::{executors::ExecutorError, topology::Topology}; -/// **Deprecated** (see [`ApplicationScore`](super::ApplicationScore)). Superseded -/// by the `harmony_app` application layer + `.with(...)` capabilities (ADR-026); -/// see `docs/guides/application-capabilities.md`. The trait itself is not yet -/// `#[deprecated]` to avoid warning every internal impl, but it should not be -/// used in new code. +/// **Deprecated** (see [`ApplicationScore`](super::ApplicationScore)). +/// Superseded by `HarmonyApp` composing Scores (ADR-029). The trait itself +/// is not yet `#[deprecated]` to avoid warning every internal impl. /// /// An ApplicationFeature provided by harmony, such as Backups, Monitoring, MultisiteAvailability, /// ContinuousIntegration, ContinuousDelivery diff --git a/harmony/src/modules/application/features/monitoring.rs b/harmony/src/modules/application/features/monitoring.rs index 75c6ae5c..f04fe06e 100644 --- a/harmony/src/modules/application/features/monitoring.rs +++ b/harmony/src/modules/application/features/monitoring.rs @@ -36,12 +36,8 @@ use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use std::sync::Arc; -/// **Deprecated.** Use `harmony_app::capabilities::Monitoring` via -/// `.with(Monitoring::new().alert(...))` (ADR-026). See -/// `docs/guides/application-capabilities.md`. -#[deprecated( - note = "Use harmony_app::capabilities::Monitoring (.with(...)). See docs/guides/application-capabilities.md" -)] +/// **Deprecated.** Compose monitoring Scores from a `HarmonyApp` (ADR-029). +#[deprecated(note = "Use harmony_app::HarmonyApp and compose Scores")] #[derive(Debug, Clone)] pub struct Monitoring { pub application: Arc, diff --git a/harmony/src/modules/application/features/packaging_deployment.rs b/harmony/src/modules/application/features/packaging_deployment.rs index 43ff67ad..f6b9fc89 100644 --- a/harmony/src/modules/application/features/packaging_deployment.rs +++ b/harmony/src/modules/application/features/packaging_deployment.rs @@ -50,12 +50,8 @@ use crate::{ /// - Harbor as artifact registru /// - ArgoCD to install/upgrade/rollback/inspect k8s resources /// - Kubernetes for runtime orchestration -/// **Deprecated.** Use the `harmony_app` application layer — `ComposeDeploy` -/// publishes + deploys, capabilities attach add-ons (ADR-026). See -/// `docs/guides/application-capabilities.md`. -#[deprecated( - note = "Use harmony_app: ComposeDeploy + .with(...) capabilities. See docs/guides/application-capabilities.md" -)] +/// **Deprecated.** Use `HarmonyApp` and compose Scores (ADR-029). +#[deprecated(note = "Use harmony_app::HarmonyApp and compose Scores")] #[derive(Debug, Default, Clone)] pub struct PackagingDeployment { pub application: Arc, diff --git a/harmony/src/modules/application/rust.rs b/harmony/src/modules/application/rust.rs index 9515fc85..3e4c8b3a 100644 --- a/harmony/src/modules/application/rust.rs +++ b/harmony/src/modules/application/rust.rs @@ -20,14 +20,8 @@ use crate::{score::Score, topology::Topology}; use super::{Application, ApplicationFeature, ApplicationInterpret, HelmPackage, OCICompliant}; -/// **Deprecated.** Use the `harmony_app` application layer — -/// `ComposeDeploy`/`HarmonyApp` + `.with(...)` capabilities (ADR-026). See -/// `docs/guides/application-capabilities.md`. The feature-menu model couples -/// delivery to a fixed feature set and the cancelled ArgoCD path and does not -/// compose with plain Scores. -#[deprecated( - note = "Use harmony_app: ComposeDeploy/HarmonyApp + .with(...) capabilities (ADR-026). See docs/guides/application-capabilities.md" -)] +/// **Deprecated.** Use `HarmonyApp` and compose Scores (ADR-029). +#[deprecated(note = "Use harmony_app::HarmonyApp and compose Scores")] #[derive(Debug, Serialize, Clone)] pub struct ApplicationScore where diff --git a/harmony/src/modules/host_process/mod.rs b/harmony/src/modules/host_process/mod.rs new file mode 100644 index 00000000..df7923c7 --- /dev/null +++ b/harmony/src/modules/host_process/mod.rs @@ -0,0 +1,360 @@ +//! Run a process on the machine that interprets the Score. +//! +//! Idempotent: same name + spec while the pid is alive → [`Outcome::noop`]. + +use std::fs; +use std::net::TcpStream; +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; +use std::time::{Duration, Instant}; + +use async_trait::async_trait; +use harmony_types::id::Id; +use serde::{Deserialize, Serialize}; + +use crate::data::Version; +use crate::domain::config::HARMONY_DATA_DIR; +use crate::interpret::{Interpret, InterpretError, InterpretName, InterpretStatus, Outcome}; +use crate::inventory::Inventory; +use crate::score::Score; +use crate::topology::{K8sclient, Topology}; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub enum ProcessEnv { + Literal(String), + /// Read `{secret}` `{key}` after other Scores have created it. + /// If `node_port_service` is set, rewrite the URI host to 127.0.0.1 and + /// the port to that Service's nodePort (host processes talking to CNPG). + SecretUri { + namespace: String, + secret: String, + key: String, + node_port_service: Option<(String, String)>, + }, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct HostProcessScore { + pub name: String, + pub program: String, + pub args: Vec, + pub cwd: Option, + pub env: Vec<(String, ProcessEnv)>, + pub port: Option, +} + +impl HostProcessScore { + pub fn new(name: impl Into, program: impl Into) -> Self { + Self { + name: name.into(), + program: program.into(), + args: Vec::new(), + cwd: None, + env: Vec::new(), + port: None, + } + } + + pub fn arg(mut self, arg: impl Into) -> Self { + self.args.push(arg.into()); + self + } + + pub fn cwd(mut self, cwd: impl Into) -> Self { + self.cwd = Some(cwd.into()); + self + } + + pub fn env_lit(mut self, key: impl Into, value: impl Into) -> Self { + self.env + .push((key.into(), ProcessEnv::Literal(value.into()))); + self + } + + pub fn env_postgres_uri( + mut self, + key: impl Into, + namespace: impl Into, + cluster: impl Into, + ) -> Self { + let namespace = namespace.into(); + let cluster = cluster.into(); + self.env.push(( + key.into(), + ProcessEnv::SecretUri { + node_port_service: Some((namespace.clone(), format!("{cluster}-rw-debug"))), + secret: format!("{cluster}-app"), + key: "uri".into(), + namespace, + }, + )); + self + } + + pub fn port(mut self, port: u16) -> Self { + self.port = Some(port); + self + } + + fn state_dir(&self) -> PathBuf { + HARMONY_DATA_DIR.join("host-process").join(&self.name) + } +} + +impl Score for HostProcessScore { + fn create_interpret(&self) -> Box> { + Box::new(HostProcessInterpret { + score: self.clone(), + }) + } + + fn name(&self) -> String { + format!("HostProcessScore({})", self.name) + } +} + +#[derive(Debug)] +struct HostProcessInterpret { + score: HostProcessScore, +} + +#[derive(Serialize, Deserialize)] +struct StateFile { + pid: u32, + spec: String, +} + +#[async_trait] +impl Interpret for HostProcessInterpret { + async fn execute( + &self, + _inventory: &Inventory, + topology: &T, + ) -> Result { + let dir = self.score.state_dir(); + fs::create_dir_all(&dir).map_err(|e| InterpretError::new(e.to_string()))?; + let spec = + serde_json::to_string(&self.score).map_err(|e| InterpretError::new(e.to_string()))?; + let state_path = dir.join("state.json"); + + if let Some(state) = read_state(&state_path) { + if state.spec == spec && pid_alive(state.pid) && port_ready(self.score.port) { + return Ok(Outcome::noop(format!( + "host process '{}' already running (pid {})", + self.score.name, state.pid + ))); + } + if pid_alive(state.pid) { + let _ = Command::new("kill").arg(state.pid.to_string()).status(); + std::thread::sleep(Duration::from_millis(100)); + } + } + + if let Some(port) = self.score.port + && port_ready(Some(port)) + { + return Err(InterpretError::new(format!( + "127.0.0.1:{port} is already in use" + ))); + } + + let env = resolve_env(&self.score.env, topology).await?; + let program = resolve_program(&self.score.program)?; + let mut cmd = Command::new(&program); + cmd.args(&self.score.args) + .stdin(Stdio::null()) + .stdout(fs::File::create(dir.join("stdout.log")).map_err(io)?) + .stderr(fs::File::create(dir.join("stderr.log")).map_err(io)?); + if let Some(cwd) = &self.score.cwd { + cmd.current_dir(cwd); + } + for (k, v) in &env { + cmd.env(k, v); + } + let child = cmd + .spawn() + .map_err(|e| InterpretError::new(format!("spawn {}: {e}", program.display())))?; + let pid = child.id(); + fs::write( + &state_path, + serde_json::to_vec(&StateFile { + pid, + spec: spec.clone(), + }) + .map_err(|e| InterpretError::new(e.to_string()))?, + ) + .map_err(io)?; + + if let Some(port) = self.score.port { + if let Err(e) = wait_port(port, Duration::from_secs(15)) { + let stderr = fs::read_to_string(dir.join("stderr.log")).unwrap_or_default(); + return Err(InterpretError::new(format!( + "{e}; process alive={} stderr={stderr}", + pid_alive(pid) + ))); + } + } else if !pid_alive(pid) { + let stderr = fs::read_to_string(dir.join("stderr.log")).unwrap_or_default(); + return Err(InterpretError::new(format!( + "host process '{}' exited immediately: {stderr}", + self.score.name + ))); + } + + Ok(Outcome::success(format!( + "host process '{}' started (pid {pid})", + self.score.name + ))) + } + + fn get_name(&self) -> InterpretName { + InterpretName::Custom("HostProcess") + } + fn get_version(&self) -> Version { + Version::from("0.1.0").unwrap() + } + fn get_status(&self) -> InterpretStatus { + InterpretStatus::QUEUED + } + fn get_children(&self) -> Vec { + vec![] + } +} + +fn io(e: std::io::Error) -> InterpretError { + InterpretError::new(e.to_string()) +} + +fn resolve_program(program: &str) -> Result { + let p = PathBuf::from(program); + if p.exists() { + return Ok(p); + } + if let Ok(exe) = std::env::current_exe() + && let Some(dir) = exe.parent() + { + let sibling = dir.join(program); + if sibling.exists() { + return Ok(sibling); + } + } + Ok(p) +} + +fn read_state(path: &Path) -> Option { + serde_json::from_slice(&fs::read(path).ok()?).ok() +} + +fn pid_alive(pid: u32) -> bool { + Command::new("kill") + .args(["-0", &pid.to_string()]) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .map(|s| s.success()) + .unwrap_or(false) +} + +fn port_ready(port: Option) -> bool { + match port { + None => true, + Some(p) => TcpStream::connect(("127.0.0.1", p)).is_ok(), + } +} + +fn wait_port(port: u16, timeout: Duration) -> Result<(), InterpretError> { + let start = Instant::now(); + while start.elapsed() < timeout { + if TcpStream::connect(("127.0.0.1", port)).is_ok() { + return Ok(()); + } + std::thread::sleep(Duration::from_millis(50)); + } + Err(InterpretError::new(format!( + "timed out waiting for 127.0.0.1:{port}" + ))) +} + +async fn resolve_env( + env: &[(String, ProcessEnv)], + topology: &T, +) -> Result, InterpretError> { + let mut out = Vec::new(); + for (k, v) in env { + match v { + ProcessEnv::Literal(s) => out.push((k.clone(), s.clone())), + ProcessEnv::SecretUri { + namespace, + secret, + key, + node_port_service, + } => { + let client = topology + .k8s_client() + .await + .map_err(|e| InterpretError::new(format!("k8s client: {e}")))?; + let sec = client + .get_resource::(secret, Some(namespace)) + .await + .map_err(|e| InterpretError::new(format!("get secret {secret}: {e}")))? + .ok_or_else(|| InterpretError::new(format!("secret {secret} missing")))?; + let bytes = + sec.data.as_ref().and_then(|d| d.get(key)).ok_or_else(|| { + InterpretError::new(format!("secret {secret} missing {key}")) + })?; + let mut uri = String::from_utf8(bytes.0.clone()) + .map_err(|e| InterpretError::new(e.to_string()))?; + if let Some((svc_ns, svc_name)) = node_port_service { + let svc = client + .get_resource::(svc_name, Some(svc_ns)) + .await + .map_err(|e| InterpretError::new(format!("get service {svc_name}: {e}")))? + .ok_or_else(|| { + InterpretError::new(format!("service {svc_name} missing")) + })?; + let node_port = svc + .spec + .as_ref() + .and_then(|s| s.ports.as_ref()) + .and_then(|p| p.first()) + .and_then(|p| p.node_port) + .ok_or_else(|| { + InterpretError::new(format!("{svc_name} has no nodePort")) + })?; + uri = rewrite_uri_host_port(&uri, "127.0.0.1", node_port); + } + out.push((k.clone(), uri)); + } + } + } + Ok(out) +} + +fn rewrite_uri_host_port(uri: &str, host: &str, port: i32) -> String { + // postgres://user:pass@oldhost:5432/db → postgres://user:pass@host:port/db + if let Some(at) = uri.rfind('@') { + let (head, rest) = uri.split_at(at + 1); + if let Some(slash) = rest.find('/') { + format!("{head}{host}:{port}{}", &rest[slash..]) + } else { + format!("{head}{host}:{port}") + } + } else { + uri.to_string() + } +} + +#[cfg(test)] +mod tests { + use super::rewrite_uri_host_port; + + #[test] + fn rewrites_cnpg_uri() { + let out = rewrite_uri_host_port( + "postgresql://app:secret@notes-db-rw.notes.svc:5432/app", + "127.0.0.1", + 32001, + ); + assert_eq!(out, "postgresql://app:secret@127.0.0.1:32001/app"); + } +} diff --git a/harmony/src/modules/mod.rs b/harmony/src/modules/mod.rs index 493f9997..32927aac 100644 --- a/harmony/src/modules/mod.rs +++ b/harmony/src/modules/mod.rs @@ -8,6 +8,7 @@ pub mod dummy; pub mod fleet; pub mod github_runner; pub mod helm; +pub mod host_process; pub mod http; pub mod inventory; pub mod k3d; diff --git a/harmony/src/modules/postgresql/score_debug_route.rs b/harmony/src/modules/postgresql/score_debug_route.rs index 4c5218aa..83968404 100644 --- a/harmony/src/modules/postgresql/score_debug_route.rs +++ b/harmony/src/modules/postgresql/score_debug_route.rs @@ -33,6 +33,9 @@ use crate::topology::{K8sclient, Topology}; pub struct PostgresDebugRouteScore { pub namespace: String, pub cluster_name: String, + /// Create as NodePort when the Service does not exist yet (host processes). + #[serde(default)] + pub create_as_node_port: bool, } impl PostgresDebugRouteScore { @@ -40,9 +43,15 @@ impl PostgresDebugRouteScore { Self { namespace: namespace.into(), cluster_name: cluster_name.into(), + create_as_node_port: false, } } + pub fn node_port(mut self) -> Self { + self.create_as_node_port = true; + self + } + fn service_name(&self) -> String { format!("{}-rw-debug", self.cluster_name) } @@ -111,6 +120,7 @@ impl Interpret for PostgresDebugRouteInterpret { .and_then(|p| p.node_port); (t, np) } + Ok(None) if self.score.create_as_node_port => ("NodePort".into(), None), Ok(None) => ("ClusterIP".into(), None), Err(e) => { return Err(InterpretError::new(format!( diff --git a/harmony/src/modules/registry_pull_secret.rs b/harmony/src/modules/registry_pull_secret.rs index 5864cad4..e7b08f94 100644 --- a/harmony/src/modules/registry_pull_secret.rs +++ b/harmony/src/modules/registry_pull_secret.rs @@ -1,10 +1,9 @@ //! A reusable Score that materializes a `kubernetes.io/dockerconfigjson` Secret //! so the kubelet can pull an app's images from a private registry. //! -//! The Secret is referenced by name from each pod's `imagePullSecrets` (see -//! `harmony_app`'s `DeployConfig::image_pull_secrets`). Keep the credentials -//! **pull-only** and load them from a vault (e.g. OpenBao `DeploySecrets`) — they -//! never belong in code or chart values. The Secret is namespaced, so a +//! The Secret is referenced by name from each pod's `imagePullSecrets`. Keep +//! the credentials **pull-only** and load them from a vault (e.g. OpenBao +//! `DeploySecrets`) — they never belong in code. The Secret is namespaced, so a //! namespace-scoped deployer can apply it without any cluster RBAC. //! //! ```ignore @@ -15,7 +14,7 @@ //! username: pull_user, //! token: pull_token, //! }; -//! // add `pull` to scores(), and set deploy.image_pull_secrets = vec!["registry-pull"] +//! // add `pull` to scores(); set imagePullSecrets on the workload Score //! ``` use std::collections::BTreeMap; diff --git a/harmony_app/Cargo.toml b/harmony_app/Cargo.toml index ab7ec984..91462908 100644 --- a/harmony_app/Cargo.toml +++ b/harmony_app/Cargo.toml @@ -2,8 +2,9 @@ name = "harmony_app" edition = "2024" version.workspace = true -readme.workspace = true +readme = "README.md" license.workspace = true +description = "Application delivery: HarmonyApp composes Scores; ship/deploy interpret them. Not infrastructure orchestration." [dependencies] anyhow.workspace = true @@ -19,8 +20,5 @@ serde_yaml = { workspace = true } schemars = "0.8" tempfile.workspace = true log.workspace = true -reqwest.workspace = true k8s-openapi.workspace = true thiserror.workspace = true -docker-compose-types = "0.24" -fqdn = "0.5.2" diff --git a/harmony_app/README.md b/harmony_app/README.md new file mode 100644 index 00000000..c752816b --- /dev/null +++ b/harmony_app/README.md @@ -0,0 +1,64 @@ +# harmony_app + +Application delivery. A [`HarmonyApp`](src/app.rs) composes **Scores**; `ship` / +`deploy` / `status` / `logs` are glue that interpret them. This crate does **not** +orchestrate infrastructure — that is [`harmony`](../harmony). + +## Architecture + +| Crate | Job | +|---|---| +| **`harmony_cli`** | UI only. Parse argv, require `--context`, call verbs, render. Safe types and DX (digest-shaped flags, `--json`). No Scores, no image policy, no cluster mutation. | +| **`harmony_app`** | Application delivery: identity, image build/publish, context catalog, `HarmonyApp` → Scores, ship/deploy as interpret glue. Authoring DX in [`dx`](src/dx.rs) (one component, one file, one type). | +| **`harmony`** | Source of truth for infrastructure orchestration. Scores (desired state), Topologies (where), Interpret (how). Deploying Kubernetes resources, running containers, building the deployment model and applying it on a topology (k8s or otherwise). | +| **`harmony_config`** | Load, save, discover settings and secrets. Schema is Rust; state is a store. Default store is OpenBao (CNCF). | + +Identity is one SSO (`https://sso.nationtech.io`). That identity is what may +read the tenant's config and environment in OpenBao, the OKD cluster, Harbor, +and later more. + +**SSO is still WIP for Harbor and OKD.** Access secrets for those live in +OpenBao, which *is* SSO. Current flow: + +1. Load one secret: `HARMONY_ZITADEL_KEY_JSON` (or `HARMONY_ZITADEL_KEY_PATH`). +2. Authenticate to OpenBao (JWT-bearer against Zitadel). +3. Load Harbor and kubeconfig from OpenBao. +4. Authenticate to Harbor and Kubernetes with those secrets. + +Interactive humans use device-code OIDC to the same OpenBao instead of a +machine key. Same store, different first hop. + +`--context` is required (or `HARMONY_CONTEXT`). There is no default. + +`K8sAnywhereTopology` on `app_main` / `AppContext::topology()` is the +**control plane** (kube apply — including Fleet CRs). It is not a claim that +the workload runs on Kubernetes. + +## Crate map + +**UI:** `harmony_cli`, `harmony_tui`, `harmony_auth_cli`, `harmony_auth_ui` + +**Application delivery:** `harmony_app` + +**Orchestration:** `harmony`, `harmony-k8s`, `harmony-reconciler-contracts`, +`harmony_execution`, `k3d`, `fleet/*` + +**Config & secrets:** `harmony_config`, `harmony_config_derive`, `harmony_secret`, +`harmony_secret_derive` + +**Identity:** `harmony_zitadel_auth`, `harmony_zitadel_jwt`, `harmony_auth` + +**Shared types:** `harmony_types`, `harmony_macros` + +Infra-specific crates (OPNsense, NATS, agents, Brocade, …) live under `harmony` +modules or their own crates; they are Scores/topologies, not app delivery. + +## Remaining leaks (honest) + +- `status` / `logs` list Kubernetes Deployments/Pods via the control-plane + client. Operational verbs are allowed to read the cluster (ADR-026); they are + not yet Score-status. +- `harmony_cli::run` is a second frontend: a generic Score runner over Maestro. + Still UI. Not application delivery. +- `dx::Secret` is a stub. Secrets today are OpenBao-backed config structs, not + a `Secret` field type. diff --git a/harmony_app/src/app.rs b/harmony_app/src/app.rs index b751e5a3..0c047a92 100644 --- a/harmony_app/src/app.rs +++ b/harmony_app/src/app.rs @@ -74,6 +74,21 @@ pub trait HarmonyApp: Send + Sync { } } +pub fn build( + app: &dyn HarmonyApp, + ctx: &AppContext, +) -> Result { + app.build(ctx) +} + +pub async fn publish( + app: &dyn HarmonyApp, + ctx: &AppContext, + images: &ImageRefs, +) -> Result { + app.publish(ctx, images).await +} + // ---- structured results (rendered by the front-end, never printed here) ---- #[derive(Debug, Clone, serde::Serialize)] diff --git a/harmony_app/src/application/k8s_anywhere.rs b/harmony_app/src/application/k8s_anywhere.rs deleted file mode 100644 index 7d028059..00000000 --- a/harmony_app/src/application/k8s_anywhere.rs +++ /dev/null @@ -1,1742 +0,0 @@ -use std::collections::{BTreeMap, BTreeSet}; -use std::time::Duration; - -use async_trait::async_trait; -use harmony::data::Version; -use harmony::interpret::{Interpret, InterpretError, InterpretName, InterpretStatus, Outcome}; -use harmony::inventory::Inventory; -use harmony::modules::k8s::resource::K8sResourceScore; -use harmony::modules::postgresql::{K8sPostgreSQLScore, PostgresDebugRouteScore}; -use harmony::modules::registry_pull_secret::RegistryPullSecretScore; -use harmony::modules::storage::ObjectBucketScore; -use harmony::modules::zitadel::{ZitadelContract, ZitadelScore, ZitadelSetupScore}; -use harmony::score::Score; -use harmony::topology::{K8sAnywhereTopology, K8sclient}; -use harmony_config::ConfigError; -use harmony_types::id::Id; -use k8s_openapi::api::apps::v1::{ - Deployment, DeploymentSpec, DeploymentStrategy, RollingUpdateDeployment, -}; -use k8s_openapi::api::core::v1::{ - Capabilities, ConfigMapKeySelector, Container, ContainerPort, EnvVar, EnvVarSource, - HTTPGetAction, LocalObjectReference, PodSecurityContext, PodSpec, PodTemplateSpec, Probe, - ResourceRequirements, SeccompProfile, SecretKeySelector, SecretVolumeSource, SecurityContext, - Service as K8sService, ServicePort, ServiceSpec, TCPSocketAction, Volume, VolumeMount, -}; -use k8s_openapi::api::networking::v1::{ - HTTPIngressPath, HTTPIngressRuleValue, Ingress, IngressBackend, IngressRule, - IngressServiceBackend, IngressSpec, IngressTLS, ServiceBackendPort, -}; -use k8s_openapi::apimachinery::pkg::api::resource::Quantity; -use k8s_openapi::apimachinery::pkg::apis::meta::v1::{LabelSelector, ObjectMeta}; -use k8s_openapi::apimachinery::pkg::util::intstr::IntOrString; -use serde::Serialize; - -use crate::{ - AppContext, AppError, AppIdentity, HarmonyApp, ImageRefs, ImageSpec, RegistryPullCredentials, - application::{ - Application, Cpu, FileRef, HealthCheck, ImageSource, ManagedResource, ManagedTls, Memory, - Protocol, PublicEndpointRef, RolloutStrategy, Route, Service, ValueRef, - }, -}; - -// K8sAnywhere owns these choices. They deliberately do not appear in the declaration model. -const MANAGED_TLS_ISSUER: &str = "letsencrypt-prod"; - -/// Internal-provider Score for a portable [`Application`] declaration. -#[derive(Debug, Clone, Serialize)] -pub(crate) struct K8sAnywhereApplicationScore { - application: Application, - namespace: String, - image_overrides: BTreeMap, - #[serde(skip)] - bindings: ProviderBindings, - #[serde(skip)] - endpoints: BTreeMap, - image_pull_secret: Option, -} - -impl K8sAnywhereApplicationScore { - fn new( - application: Application, - namespace: impl Into, - bindings: ProviderBindings, - endpoints: BTreeMap, - image_pull_secret: Option, - ) -> Result { - application - .validate() - .map_err(|error| AppError::InvalidComposition(error.to_string()))?; - Ok(Self { - application, - namespace: namespace.into(), - image_overrides: BTreeMap::new(), - bindings, - endpoints, - image_pull_secret, - }) - } - - pub(crate) fn with_image_overrides(mut self, images: &ImageRefs) -> Self { - self.image_overrides = images - .iter() - .map(|(name, image)| (name.to_string(), image.to_string())) - .collect(); - self - } - - fn lower(&self) -> Result { - self.application - .validate() - .map_err(|error| AppError::InvalidComposition(error.to_string()))?; - lower( - &self.application, - &self.image_overrides, - &self.bindings, - &self.endpoints, - self.image_pull_secret.as_deref(), - ) - } -} - -impl Score for K8sAnywhereApplicationScore { - fn create_interpret(&self) -> Box> { - Box::new(K8sAnywhereApplicationInterpret { - score: self.clone(), - }) - } - - fn name(&self) -> String { - format!("K8sAnywhereApplicationScore({})", self.application.name) - } -} - -#[async_trait] -impl HarmonyApp for Application { - fn identity(&self, ctx: &AppContext) -> AppIdentity { - AppIdentity { - name: self.name.clone(), - namespace: ctx.namespace().to_string(), - } - } - - async fn scores( - &self, - ctx: &AppContext, - images: &ImageRefs, - ) -> Result>>, AppError> { - self.validate() - .map_err(|error| AppError::InvalidComposition(error.to_string()))?; - if ctx.profile() == crate::Profile::Local && !self.endpoints.is_empty() { - return Err(AppError::InvalidComposition( - "K8sAnywhere local public endpoints are not supported yet".to_string(), - )); - } - for image in &self.images { - if matches!(&image.source, ImageSource::Build(_)) { - images.require(&image.name)?; - } - } - let mut scores: Vec>> = Vec::new(); - let mut bindings = ProviderBindings::default(); - let endpoints = resolve_endpoints(self, ctx); - let migrate_zitadel_legacy_state = self - .resources - .iter() - .filter(|resource| matches!(resource, ManagedResource::Zitadel(_))) - .count() - == 1; - - if let Some(name) = ctx.image_pull_secret() { - match ctx.config_client().get::().await { - Ok(credentials) => scores.push(Box::new(RegistryPullSecretScore { - namespace: ctx.namespace().to_string(), - name: name.to_string(), - registry: ctx - .registry() - .expect("only remote contexts have image pull secrets") - .to_string(), - username: credentials.username, - token: credentials.token, - })), - Err(ConfigError::NotFound { .. }) => {} - Err(error) => { - return Err(AppError::Deploy(format!( - "loading RegistryPullCredentials: {error}" - ))); - } - } - } - - for resource in &self.resources { - match resource { - ManagedResource::Postgres(database) => { - let mut score = - K8sPostgreSQLScore::new(ctx.namespace()).cluster_name(&database.name); - score.config.instances = database.instances; - score.config.version = database.version.clone(); - bindings.databases.insert( - database.name.clone(), - application_database_binding(&database.name), - ); - scores.push(Box::new(score)); - if database.debug_route { - scores.push(Box::new(PostgresDebugRouteScore::new( - ctx.namespace(), - &database.name, - ))); - } - } - ManagedResource::Bucket(bucket) => { - let endpoint = bucket - .endpoint - .clone() - .or_else(|| ctx.object_storage_endpoint().map(str::to_string)); - let cors_origins = bucket - .cors - .iter() - .map(|endpoint| { - let resolved = endpoints.get(endpoint.name()).ok_or_else(|| { - AppError::InvalidComposition(format!( - "unknown public endpoint '{}' for bucket CORS", - endpoint.name() - )) - })?; - let scheme = match resolved.tls { - ManagedTls::Managed => "https", - ManagedTls::Disabled => "http", - }; - Ok(format!("{scheme}://{}", resolved.host)) - }) - .collect::, AppError>>()?; - let mut score = ObjectBucketScore::new(ctx.namespace(), &bucket.name) - .storage_class(&bucket.storage_class) - .max_size(&bucket.max_size) - .cors_origins(cors_origins); - if let Some(endpoint) = endpoint { - score = score.endpoint_override(endpoint); - } - bindings.buckets.insert( - bucket.name.clone(), - BucketBinding { - secret: score.app_secret_name(), - }, - ); - scores.push(Box::new(score)); - } - ManagedResource::Zitadel(zitadel) => { - let database = K8sPostgreSQLScore::new(ctx.namespace()) - .cluster_name(format!("{}-db", zitadel.name)); - let root = database.root_account_ref(); - scores.push(Box::new(database)); - - let endpoint = endpoints.get(zitadel.endpoint.name()).ok_or_else(|| { - AppError::InvalidComposition(format!( - "unknown public endpoint '{}'", - zitadel.endpoint.name() - )) - })?; - let mut deployment = - ZitadelScore::new(&endpoint.host, ctx.namespace()).database(root); - deployment.zitadel_version = zitadel.version.clone(); - if endpoint.tls == ManagedTls::Disabled { - deployment = deployment.http(None); - } - let provider = deployment.provider_ref(); - bindings - .zitadels - .insert(zitadel.name.clone(), provider.issuer()); - let state = ctx.state_client(&zitadel.name, migrate_zitadel_legacy_state); - scores.push(Box::new(deployment.with_state_client(state.clone()))); - - let contract = lower_contract(zitadel, &endpoints)?; - let mut setup = ZitadelSetupScore::for_provider( - &provider, - ctx.namespace(), - ctx.namespace(), - ); - if ctx.profile() == crate::Profile::Local { - setup = setup.port_forward("zitadel"); - } - let setup = setup.contract(contract); - bind_contract_outputs(&mut bindings, &zitadel.name, &setup); - scores.push(Box::new( - setup.with_config_clients(ctx.config_client_arc(), state), - )); - } - } - } - scores.push(Box::new( - K8sAnywhereApplicationScore::new( - self.clone(), - ctx.namespace(), - bindings, - endpoints, - ctx.image_pull_secret().map(|secret| secret.to_string()), - )? - .with_image_overrides(images), - )); - Ok(scores) - } - - fn images(&self, ctx: &AppContext) -> Result, AppError> { - Ok(self - .images - .iter() - .filter_map(|image| match &image.source { - ImageSource::Reference(_) => None, - ImageSource::Build(build) => Some(ImageSpec { - name: image.name.clone(), - image: ctx.image(&image.name), - context: build.context.clone(), - dockerfile: build.context.join(&build.dockerfile), - platform: build.platform.clone(), - build_args: build.build_args.clone(), - }), - }) - .collect()) - } -} - -#[derive(Debug)] -struct K8sAnywhereApplicationInterpret { - score: K8sAnywhereApplicationScore, -} - -#[async_trait] -impl Interpret for K8sAnywhereApplicationInterpret { - async fn execute( - &self, - inventory: &Inventory, - topology: &K8sAnywhereTopology, - ) -> Result { - let lowered = self - .score - .lower() - .map_err(|error| InterpretError::new(error.to_string()))?; - let namespace = self.score.namespace.clone(); - let client = topology - .k8s_client() - .await - .map_err(|error| InterpretError::new(format!("get Kubernetes client: {error}")))?; - client.ensure_namespace(&namespace).await.map_err(|error| { - InterpretError::new(format!( - "ensure application namespace '{namespace}': {error}" - )) - })?; - - if !lowered.services.is_empty() { - K8sResourceScore { - resource: lowered.services, - namespace: Some(namespace.clone()), - } - .interpret(inventory, topology) - .await?; - } - K8sResourceScore { - resource: lowered.deployments, - namespace: Some(namespace.clone()), - } - .interpret(inventory, topology) - .await?; - if let Some(ingress) = lowered.ingress { - K8sResourceScore::single(ingress, Some(namespace.clone())) - .interpret(inventory, topology) - .await?; - } - - if self.score.application.rollout.readiness.wait { - for service in &self.score.application.services { - client - .wait_until_deployment_ready( - &service.name, - Some(&namespace), - Some(self.score.application.rollout.readiness.timeout), - ) - .await - .map_err(|error| { - InterpretError::new(format!( - "application deployment {namespace}/{} not ready: {error}", - service.name - )) - })?; - } - } - - smoke_check_routes(&self.score.application.routes, &self.score.endpoints).await?; - Ok(Outcome::success_with_details( - format!("deployed application '{}'", self.score.application.name), - vec![format!( - "services: {}", - self.score.application.services.len() - )], - )) - } - - fn get_name(&self) -> InterpretName { - InterpretName::Custom("K8sAnywhereApplicationInterpret") - } - - 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 { - Vec::new() - } -} - -struct LoweredApplication { - deployments: Vec, - services: Vec, - ingress: Option, -} - -#[derive(Debug, Clone)] -struct ResolvedEndpoint { - host: String, - tls: ManagedTls, -} - -#[derive(Debug, Clone)] -struct DatabaseBinding { - secret: String, -} - -fn application_database_binding(cluster: &str) -> DatabaseBinding { - DatabaseBinding { - // CNPG's application-owner Secret exports username, password, URI, - // and JDBC URI without granting the workload superuser access. - secret: format!("{cluster}-app"), - } -} - -#[derive(Debug, Clone)] -struct KeyBinding { - source: String, - key: String, -} - -#[derive(Debug, Clone)] -struct BucketBinding { - secret: String, -} - -#[derive(Debug, Clone, Default)] -struct ProviderBindings { - databases: BTreeMap, - buckets: BTreeMap, - zitadels: BTreeMap, - projects: BTreeMap<(String, String), KeyBinding>, - applications: BTreeMap<(String, String, String), KeyBinding>, - machine_client_ids: BTreeMap<(String, String), KeyBinding>, - machine_client_secrets: BTreeMap<(String, String), KeyBinding>, - machine_json_keys: BTreeMap<(String, String), KeyBinding>, -} - -fn lower( - application: &Application, - image_overrides: &BTreeMap, - bindings: &ProviderBindings, - endpoints: &BTreeMap, - image_pull_secret: Option<&str>, -) -> Result { - let images: BTreeMap<_, _> = application - .images - .iter() - .filter_map(|image| match &image.source { - ImageSource::Reference(reference) => Some((image.name.as_str(), reference.as_str())), - ImageSource::Build(_) => None, - }) - .collect(); - let ports: BTreeMap<_, _> = application - .services - .iter() - .flat_map(|service| { - service - .ports - .iter() - .map(move |port| ((service.name.as_str(), port.name.as_str()), port.number)) - }) - .collect(); - - let deployments = application - .services - .iter() - .map(|service| { - let image = image_overrides - .get(service.image.name()) - .map(String::as_str) - .or_else(|| images.get(service.image.name()).copied()) - .ok_or_else(|| { - AppError::InvalidComposition(format!( - "unknown image '{}'", - service.image.name() - )) - })?; - deployment( - application, - service, - image, - &ports, - bindings, - endpoints, - image_pull_secret, - ) - }) - .collect::, AppError>>()?; - let services = application - .services - .iter() - .filter(|service| !service.ports.is_empty()) - .map(k8s_service) - .collect(); - - Ok(LoweredApplication { - deployments, - services, - ingress: ingress(application, endpoints), - }) -} - -fn deployment( - application: &Application, - service: &Service, - image: &str, - ports: &BTreeMap<(&str, &str), u16>, - bindings: &ProviderBindings, - endpoints: &BTreeMap, - image_pull_secret: Option<&str>, -) -> Result { - let labels = labels(&application.name, &service.name); - let mut env = Vec::new(); - let mut volumes = Vec::new(); - let mut volume_mounts = Vec::new(); - for (index, (name, value)) in service.values.iter().enumerate() { - let mut variable = EnvVar { - name: name.clone(), - ..Default::default() - }; - match value { - ValueRef::Literal(value) => variable.value = Some(value.clone()), - ValueRef::ServiceHost(reference) => variable.value = Some(reference.name().to_string()), - ValueRef::ServicePort(reference) => { - variable.value = Some(port_number(ports, reference)?.to_string()) - } - ValueRef::ServiceUrl { scheme, port } => { - variable.value = Some(format!( - "{scheme}://{}:{}", - port.service().name(), - port_number(ports, port)? - )); - } - ValueRef::PublicEndpointOrigin(endpoint) => { - variable.value = Some(endpoint_url(endpoint, "", endpoints)?); - } - ValueRef::PublicEndpointUrl { endpoint, path } => { - variable.value = Some(endpoint_url(endpoint, path, endpoints)?); - } - ValueRef::DatabaseJdbcUrl(reference) - | ValueRef::DatabaseUsername(reference) - | ValueRef::DatabasePassword(reference) => { - let binding = bindings.databases.get(reference.name()).ok_or_else(|| { - AppError::InvalidComposition(format!("unknown database '{}'", reference.name())) - })?; - let key = match value { - ValueRef::DatabaseJdbcUrl(_) => "jdbc-uri", - ValueRef::DatabaseUsername(_) => "username", - ValueRef::DatabasePassword(_) => "password", - _ => unreachable!(), - }; - variable.value_from = Some(secret_value(&binding.secret, key)); - } - ValueRef::BucketEndpoint(reference) - | ValueRef::BucketName(reference) - | ValueRef::BucketAccessKey(reference) - | ValueRef::BucketSecretKey(reference) - | ValueRef::BucketRegion(reference) - | ValueRef::BucketPathStyle(reference) => { - let binding = bindings.buckets.get(reference.name()).ok_or_else(|| { - AppError::InvalidComposition(format!("unknown bucket '{}'", reference.name())) - })?; - let key = match value { - ValueRef::BucketEndpoint(_) => "endpoint", - ValueRef::BucketName(_) => "bucket", - ValueRef::BucketAccessKey(_) => "access-key", - ValueRef::BucketSecretKey(_) => "secret-key", - ValueRef::BucketRegion(_) => "region", - ValueRef::BucketPathStyle(_) => "path-style", - _ => unreachable!(), - }; - variable.value_from = Some(secret_value(&binding.secret, key)); - } - ValueRef::ZitadelIssuer(reference) | ValueRef::ZitadelManagementUrl(reference) => { - variable.value = Some( - bindings - .zitadels - .get(reference.name()) - .cloned() - .ok_or_else(|| { - AppError::InvalidComposition(format!( - "unknown Zitadel '{}'", - reference.name() - )) - })?, - ); - } - ValueRef::OidcProjectId { zitadel, project } => { - variable.value_from = Some(config_value(binding( - &bindings.projects, - (&zitadel.0, project.name()), - "OIDC project", - )?)); - } - ValueRef::OidcClientId { - zitadel, - application, - } => { - let binding = bindings - .applications - .get(&( - zitadel.0.clone(), - application.project().name().to_string(), - application.name().to_string(), - )) - .ok_or_else(|| { - AppError::InvalidComposition(format!( - "unknown OIDC application '{}/{}/{}'", - zitadel.name(), - application.project().name(), - application.name() - )) - })?; - variable.value_from = Some(config_value(binding)); - } - ValueRef::MachineClientId { zitadel, machine } - | ValueRef::MachineClientSecret { zitadel, machine } => { - let source = if matches!(value, ValueRef::MachineClientId { .. }) { - &bindings.machine_client_ids - } else { - &bindings.machine_client_secrets - }; - let binding = binding(source, (&zitadel.0, machine.name()), "machine identity")?; - variable.value_from = Some(secret_value(&binding.source, &binding.key)); - } - ValueRef::File(reference) => { - variable.value = Some(reference.path().to_string()); - let volume_name = format!("value-file-{index}"); - let FileRef::MachineJsonKey { - zitadel, machine, .. - } = reference; - let binding = binding( - &bindings.machine_json_keys, - (&zitadel.0, machine.name()), - "machine identity", - )?; - let (secret, key) = (&binding.source, &binding.key); - volumes.push(Volume { - name: volume_name.clone(), - secret: Some(SecretVolumeSource { - secret_name: Some(secret.clone()), - optional: Some(false), - ..Default::default() - }), - ..Default::default() - }); - volume_mounts.push(VolumeMount { - name: volume_name, - mount_path: reference.path().to_string(), - sub_path: Some(key.clone()), - read_only: Some(true), - ..Default::default() - }); - } - } - env.push(variable); - } - - let (command, args) = service - .command - .as_ref() - .map(|command| { - ( - Some(vec![command.program.clone()]), - Some(command.args.clone()), - ) - }) - .unwrap_or_default(); - let probes = service - .health - .as_ref() - .map(|health| health_probes(health, ports)) - .transpose()? - .unwrap_or_default(); - let strategy = match application.rollout.strategy { - RolloutStrategy::Rolling => DeploymentStrategy { - type_: Some("RollingUpdate".to_string()), - rolling_update: Some(RollingUpdateDeployment { - max_surge: Some(IntOrString::Int(1)), - max_unavailable: Some(IntOrString::Int(0)), - }), - }, - RolloutStrategy::Replace => DeploymentStrategy { - type_: Some("Recreate".to_string()), - ..Default::default() - }, - }; - - Ok(Deployment { - metadata: ObjectMeta { - name: Some(service.name.clone()), - labels: Some(labels.clone()), - ..Default::default() - }, - spec: Some(DeploymentSpec { - replicas: Some(application.rollout.replicas.try_into().map_err(|_| { - AppError::InvalidComposition("rollout replicas exceed provider limit".to_string()) - })?), - strategy: Some(strategy), - selector: LabelSelector { - match_labels: Some(labels.clone()), - ..Default::default() - }, - template: PodTemplateSpec { - metadata: Some(ObjectMeta { - labels: Some(labels), - ..Default::default() - }), - spec: Some(PodSpec { - automount_service_account_token: Some(false), - image_pull_secrets: image_pull_secret.map(|name| { - vec![LocalObjectReference { - name: name.to_string(), - }] - }), - security_context: Some(PodSecurityContext { - run_as_non_root: Some(true), - seccomp_profile: Some(SeccompProfile { - type_: "RuntimeDefault".to_string(), - ..Default::default() - }), - ..Default::default() - }), - containers: vec![Container { - name: service.name.clone(), - image: Some(image.to_string()), - image_pull_policy: Some("IfNotPresent".to_string()), - command, - args, - ports: (!service.ports.is_empty()).then(|| { - service - .ports - .iter() - .map(|port| ContainerPort { - name: Some(port.name.clone()), - container_port: i32::from(port.number), - protocol: Some(protocol(port.protocol).to_string()), - ..Default::default() - }) - .collect() - }), - env: (!env.is_empty()).then_some(env), - volume_mounts: (!volume_mounts.is_empty()).then_some(volume_mounts), - startup_probe: probes.startup, - readiness_probe: probes.readiness, - liveness_probe: probes.liveness, - resources: resources(service), - security_context: Some(SecurityContext { - allow_privilege_escalation: Some(false), - capabilities: Some(Capabilities { - drop: Some(vec!["ALL".to_string()]), - ..Default::default() - }), - run_as_non_root: Some(true), - ..Default::default() - }), - ..Default::default() - }], - volumes: (!volumes.is_empty()).then_some(volumes), - ..Default::default() - }), - }, - ..Default::default() - }), - ..Default::default() - }) -} - -fn k8s_service(service: &Service) -> K8sService { - K8sService { - metadata: ObjectMeta { - name: Some(service.name.clone()), - ..Default::default() - }, - spec: Some(ServiceSpec { - type_: Some("ClusterIP".to_string()), - selector: Some(BTreeMap::from([( - "app.kubernetes.io/component".to_string(), - service.name.clone(), - )])), - ports: Some( - service - .ports - .iter() - .map(|port| ServicePort { - name: Some(port.name.clone()), - port: i32::from(port.number), - target_port: Some(IntOrString::String(port.name.clone())), - protocol: Some(protocol(port.protocol).to_string()), - ..Default::default() - }) - .collect(), - ), - ..Default::default() - }), - ..Default::default() - } -} - -fn ingress( - application: &Application, - endpoints: &BTreeMap, -) -> Option { - if application.routes.is_empty() { - return None; - } - let mut hosts = Vec::::new(); - let mut paths = BTreeMap::>::new(); - for route in &application.routes { - let host = endpoints.get(route.endpoint.name())?.host.clone(); - if !paths.contains_key(&host) { - hosts.push(host.clone()); - } - paths.entry(host).or_default().push(HTTPIngressPath { - path: Some(route.path.clone()), - path_type: "Prefix".to_string(), - backend: IngressBackend { - service: Some(IngressServiceBackend { - name: route.target.service().name().to_string(), - port: Some(ServiceBackendPort { - name: Some(route.target.name().to_string()), - number: None, - }), - }), - ..Default::default() - }, - }); - } - let tls_hosts: Vec<_> = application - .routes - .iter() - .filter_map(|route| { - endpoints - .get(route.endpoint.name()) - .filter(|endpoint| endpoint.tls == ManagedTls::Managed) - .map(|endpoint| endpoint.host.clone()) - }) - .collect::>() - .into_iter() - .collect(); - let managed_tls = !tls_hosts.is_empty(); - Some(Ingress { - metadata: ObjectMeta { - name: Some(application.name.clone()), - annotations: managed_tls.then(|| { - BTreeMap::from([( - "cert-manager.io/cluster-issuer".to_string(), - MANAGED_TLS_ISSUER.to_string(), - )]) - }), - ..Default::default() - }, - spec: Some(IngressSpec { - rules: Some( - hosts - .into_iter() - .map(|host| IngressRule { - host: (!host.is_empty()).then_some(host.clone()), - http: Some(HTTPIngressRuleValue { - paths: paths.remove(&host).unwrap_or_default(), - }), - }) - .collect(), - ), - tls: managed_tls.then(|| { - vec![IngressTLS { - hosts: Some(tls_hosts), - secret_name: Some(format!("{}-tls", application.name)), - }] - }), - ..Default::default() - }), - ..Default::default() - }) -} - -/// Default window where startup probe failures do not restart the container. -/// Success still marks the pod started as soon as the first probe passes. -const STARTUP_GRACE: Duration = Duration::from_secs(120); - -#[derive(Default)] -struct ContainerProbes { - startup: Option, - readiness: Option, - liveness: Option, -} - -fn health_probes( - health: &HealthCheck, - ports: &BTreeMap<(&str, &str), u16>, -) -> Result { - let (reference, interval, timeout, initial_delay) = match health { - HealthCheck::Http { - port, - interval, - timeout, - initial_delay, - .. - } - | HealthCheck::Tcp { - port, - interval, - timeout, - initial_delay, - } => (port, interval, timeout, initial_delay), - }; - let port = IntOrString::Int(i32::from(port_number(ports, reference)?)); - let period = seconds(*interval).max(1); - // Ceiling of grace/period so slow boots get a full STARTUP_GRACE of failures. - let startup_failures = - ((STARTUP_GRACE.as_secs() as i32 + period - 1) / period).clamp(1, i32::MAX); - - let mut base = Probe { - initial_delay_seconds: Some(seconds(*initial_delay)), - period_seconds: Some(period), - timeout_seconds: Some(seconds(*timeout)), - ..Default::default() - }; - match health { - HealthCheck::Http { path, .. } => { - base.http_get = Some(HTTPGetAction { - path: Some(path.clone()), - port, - scheme: Some("HTTP".to_string()), - ..Default::default() - }); - } - HealthCheck::Tcp { .. } => { - base.tcp_socket = Some(TCPSocketAction { - port, - ..Default::default() - }); - } - } - - // Startup absorbs boot failures; readiness/liveness only run after startup succeeds. - let mut startup = base.clone(); - startup.failure_threshold = Some(startup_failures); - - let mut runtime = base; - runtime.initial_delay_seconds = Some(0); - runtime.failure_threshold = Some(3); - - Ok(ContainerProbes { - startup: Some(startup), - readiness: Some(runtime.clone()), - liveness: Some(runtime), - }) -} - -fn resources(service: &Service) -> Option { - let requests = [ - service - .resources - .cpu_request - .map(|value| ("cpu", cpu(value))), - service - .resources - .memory_request - .map(|value| ("memory", memory(value))), - ] - .into_iter() - .flatten() - .map(|(name, value)| (name.to_string(), Quantity(value))) - .collect::>(); - let limits = [ - service.resources.cpu_limit.map(|value| ("cpu", cpu(value))), - service - .resources - .memory_limit - .map(|value| ("memory", memory(value))), - ] - .into_iter() - .flatten() - .map(|(name, value)| (name.to_string(), Quantity(value))) - .collect::>(); - (!requests.is_empty() || !limits.is_empty()).then_some(ResourceRequirements { - requests: (!requests.is_empty()).then_some(requests), - limits: (!limits.is_empty()).then_some(limits), - ..Default::default() - }) -} - -fn cpu(value: Cpu) -> String { - match value { - Cpu::Millicores(value) => format!("{value}m"), - Cpu::Cores(value) => value.to_string(), - } -} - -fn memory(value: Memory) -> String { - match value { - Memory::Mebibytes(value) => format!("{value}Mi"), - Memory::Gibibytes(value) => format!("{value}Gi"), - } -} - -fn labels(application: &str, service: &str) -> BTreeMap { - let mut labels = BTreeMap::from([ - ( - "app.kubernetes.io/component".to_string(), - service.to_string(), - ), - ( - "app.kubernetes.io/managed-by".to_string(), - "harmony".to_string(), - ), - ]); - if !application.is_empty() { - labels.insert( - "app.kubernetes.io/part-of".to_string(), - application.to_string(), - ); - } - labels -} - -fn port_number( - ports: &BTreeMap<(&str, &str), u16>, - reference: &crate::application::PortRef, -) -> Result { - ports - .get(&(reference.service().name(), reference.name())) - .copied() - .ok_or_else(|| { - AppError::InvalidComposition(format!( - "unknown port '{}.{}'", - reference.service().name(), - reference.name() - )) - }) -} - -fn protocol(protocol: Protocol) -> &'static str { - match protocol { - Protocol::Tcp => "TCP", - Protocol::Udp => "UDP", - } -} - -fn secret_value(secret: &str, key: &str) -> EnvVarSource { - EnvVarSource { - secret_key_ref: Some(SecretKeySelector { - name: secret.to_string(), - key: key.to_string(), - optional: Some(false), - }), - ..Default::default() - } -} - -fn config_value(binding: &KeyBinding) -> EnvVarSource { - EnvVarSource { - config_map_key_ref: Some(ConfigMapKeySelector { - name: binding.source.clone(), - key: binding.key.clone(), - optional: Some(false), - }), - ..Default::default() - } -} - -fn binding<'a>( - bindings: &'a BTreeMap<(String, String), KeyBinding>, - key: (&str, &str), - kind: &str, -) -> Result<&'a KeyBinding, AppError> { - bindings - .get(&(key.0.to_string(), key.1.to_string())) - .ok_or_else(|| { - AppError::InvalidComposition(format!("unknown {kind} '{}.{}'", key.0, key.1)) - }) -} - -fn resolve_endpoints( - application: &Application, - ctx: &AppContext, -) -> BTreeMap { - application - .endpoints - .iter() - .map(|endpoint| { - ( - endpoint.name.clone(), - ResolvedEndpoint { - host: ctx.service_host(&endpoint.name), - tls: endpoint.tls, - }, - ) - }) - .collect() -} - -fn endpoint_url( - endpoint: &PublicEndpointRef, - path: &str, - endpoints: &BTreeMap, -) -> Result { - let endpoint = endpoints.get(endpoint.name()).ok_or_else(|| { - AppError::InvalidComposition(format!("unknown public endpoint '{}'", endpoint.name())) - })?; - let scheme = if endpoint.tls == ManagedTls::Managed { - "https" - } else { - "http" - }; - Ok(format!("{scheme}://{}{}", endpoint.host, path)) -} - -fn lower_contract( - managed: &crate::application::ManagedZitadel, - endpoints: &BTreeMap, -) -> Result { - let mut lowered = managed.contract.clone(); - for redirect in &managed.redirects { - let application = lowered - .applications - .iter_mut() - .find(|application| application.application == redirect.application) - .ok_or_else(|| { - AppError::InvalidComposition(format!( - "OIDC application '{}' is not declared", - redirect.application.name() - )) - })?; - let url = endpoint_url(&redirect.endpoint, &redirect.path, endpoints)?; - if redirect.post_logout { - application.post_logout_redirect_uris.push(url); - } else { - application.redirect_uris.push(url); - } - } - Ok(lowered) -} - -fn bind_contract_outputs( - bindings: &mut ProviderBindings, - zitadel: &str, - setup: &harmony::modules::zitadel::ZitadelContractSetupScore, -) { - for project in &setup.contract.projects { - let output = setup.project_output(&project.project); - bindings.projects.insert( - (zitadel.to_string(), project.project.name().to_string()), - KeyBinding { - source: output.config_map_name().to_string(), - key: output.project_id_key().to_string(), - }, - ); - } - for application in &setup.contract.applications { - let output = setup.application_output(&application.application); - bindings.applications.insert( - ( - zitadel.to_string(), - application.application.project().name().to_string(), - application.application.name().to_string(), - ), - KeyBinding { - source: output.config_map_name().to_string(), - key: output.client_id_key().to_string(), - }, - ); - } - for machine in &setup.contract.machines { - let output = setup.machine_output(&machine.machine); - let coordinate = (zitadel.to_string(), machine.machine.name().to_string()); - bindings.machine_client_ids.insert( - coordinate.clone(), - KeyBinding { - source: output.secret_name().to_string(), - key: output.client_id_key().to_string(), - }, - ); - bindings.machine_client_secrets.insert( - coordinate.clone(), - KeyBinding { - source: output.secret_name().to_string(), - key: output.client_secret_key().to_string(), - }, - ); - bindings.machine_json_keys.insert( - coordinate, - KeyBinding { - source: output.secret_name().to_string(), - key: output.key_json_key().to_string(), - }, - ); - } -} - -fn seconds(duration: Duration) -> i32 { - duration.as_secs().clamp(1, i32::MAX as u64) as i32 -} - -async fn smoke_check_routes( - routes: &[Route], - endpoints: &BTreeMap, -) -> Result<(), InterpretError> { - let routes: Vec<_> = routes.iter().filter(|route| route.smoke_check).collect(); - if routes.is_empty() { - return Ok(()); - } - let client = reqwest::Client::builder() - .timeout(Duration::from_secs(10)) - .build() - .map_err(|error| InterpretError::new(format!("build route smoke-check client: {error}")))?; - for route in routes { - let endpoint = endpoints.get(route.endpoint.name()).ok_or_else(|| { - InterpretError::new(format!( - "unknown public endpoint '{}'", - route.endpoint.name() - )) - })?; - let scheme = if endpoint.tls == ManagedTls::Managed { - "https" - } else { - "http" - }; - let url = format!("{scheme}://{}{}", endpoint.host, route.path); - let mut last_error = "route did not respond".to_string(); - tokio::time::timeout(Duration::from_secs(180), async { - loop { - match client.get(&url).send().await { - Ok(response) if response.status().is_success() => return, - Ok(response) => last_error = format!("HTTP {}", response.status()), - Err(error) => last_error = error.to_string(), - } - tokio::time::sleep(Duration::from_secs(2)).await; - } - }) - .await - .map_err(|_| { - InterpretError::new(format!( - "application route {url} failed its smoke check after 180s: {last_error}" - )) - })?; - } - Ok(()) -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::application::{ - Image, LogicalEndpoint, ManagedZitadel, Port, ResourceIntent, ServiceRef, - }; - use crate::{Context, ContextSpec, LocalContext, OpenBaoClusterAccess, RemoteContext}; - use harmony::modules::zitadel::{ - ZitadelApplicationRef, ZitadelContract, ZitadelOidcApplicationDeclaration, - ZitadelProjectDeclaration, ZitadelProjectRef, - }; - use harmony_config::{ConfigClass, ConfigSource}; - use std::sync::Arc; - - struct PullCredentialSource; - - #[async_trait] - impl ConfigSource for PullCredentialSource { - async fn get( - &self, - class: ConfigClass, - key: &str, - ) -> Result, ConfigError> { - assert_eq!(class, ConfigClass::Secret); - assert_eq!(key, "RegistryPullCredentials"); - Ok(Some(serde_json::json!({ - "username": "robot$pull", - "token": "secret" - }))) - } - - async fn set( - &self, - _class: ConfigClass, - _key: &str, - _value: &serde_json::Value, - ) -> Result<(), ConfigError> { - unreachable!() - } - } - - fn fixture() -> Application { - let image = Image::new("api-image", "example/api:1"); - let api = Service::new("api", image.reference()) - .port(Port::tcp("http", 8080)) - .value( - "SELF", - ValueRef::service_url("http", ServiceRef::new("api").port("http")), - ) - .resources(ResourceIntent { - cpu_request: Some(Cpu::Millicores(100)), - memory_limit: Some(Memory::Mebibytes(128)), - ..Default::default() - }); - let api_ref = api.reference(); - let endpoint = LogicalEndpoint::new("sample").managed_tls(); - let endpoint_ref = endpoint.reference(); - Application::new("sample") - .image(image) - .endpoint(endpoint) - .service(api) - .route(Route::new(endpoint_ref, "/api", api_ref.port("http"))) - } - - fn endpoints() -> BTreeMap { - BTreeMap::from([( - "sample".to_string(), - ResolvedEndpoint { - host: "sample.test".to_string(), - tls: ManagedTls::Managed, - }, - )]) - } - - fn remote_context(image_pull_secret: Option<&str>) -> Context { - Context { - name: "prod".parse().unwrap(), - namespace: "sample".parse().unwrap(), - spec: ContextSpec::Remote(RemoteContext { - registry: "registry.example.com".parse().unwrap(), - repository: "apps".parse().unwrap(), - domain: "example.com".parse().unwrap(), - image_pull_secret: image_pull_secret.map(|name| name.parse().unwrap()), - object_storage_endpoint: None, - access: OpenBaoClusterAccess { - namespace: "sample".parse().unwrap(), - url: "https://bao.example.com".parse().unwrap(), - role: "deployer".parse().unwrap(), - zitadel_url: "https://auth.example.com".parse().unwrap(), - zitadel_audience: "harmony".parse().unwrap(), - }, - }), - } - } - - fn lower_fixture( - app: &Application, - images: &BTreeMap, - ) -> Result { - lower( - app, - images, - &ProviderBindings::default(), - &endpoints(), - None, - ) - } - - #[test] - fn lowers_values_security_resources_and_files() { - let lowered = lower_fixture(&fixture(), &BTreeMap::new()).unwrap(); - let pod = lowered.deployments[0] - .spec - .as_ref() - .unwrap() - .template - .spec - .as_ref() - .unwrap(); - assert_eq!(pod.automount_service_account_token, Some(false)); - let container = &pod.containers[0]; - assert_eq!( - container - .env - .as_ref() - .unwrap() - .iter() - .find(|value| value.name == "SELF") - .unwrap() - .value - .as_deref(), - Some("http://api:8080") - ); - assert_eq!( - container - .resources - .as_ref() - .unwrap() - .limits - .as_ref() - .unwrap()["memory"] - .0, - "128Mi" - ); - assert_eq!( - container - .security_context - .as_ref() - .unwrap() - .allow_privilege_escalation, - Some(false) - ); - } - - #[test] - fn preserves_route_order_and_managed_tls_policy() { - let mut app = fixture(); - let target = app.services[0].reference().port("http"); - app.routes - .push(Route::new(PublicEndpointRef::new("sample"), "/", target)); - let ingress = lower_fixture(&app, &BTreeMap::new()) - .unwrap() - .ingress - .unwrap(); - let paths = &ingress.spec.as_ref().unwrap().rules.as_ref().unwrap()[0] - .http - .as_ref() - .unwrap() - .paths; - assert_eq!(paths[0].path.as_deref(), Some("/api")); - assert_eq!(paths[1].path.as_deref(), Some("/")); - assert_eq!( - ingress.metadata.annotations.as_ref().unwrap()["cert-manager.io/cluster-issuer"], - MANAGED_TLS_ISSUER - ); - assert_eq!( - ingress.spec.unwrap().tls.unwrap()[0] - .hosts - .as_ref() - .unwrap(), - &["sample.test"] - ); - } - - #[test] - fn digest_override_changes_deployment_pod_template_image() { - let overrides = BTreeMap::from([( - "api-image".to_string(), - "registry.example/api@sha256:abc".to_string(), - )]); - let lowered = lower_fixture(&fixture(), &overrides).unwrap(); - let image = lowered.deployments[0] - .spec - .as_ref() - .unwrap() - .template - .spec - .as_ref() - .unwrap() - .containers[0] - .image - .as_deref(); - assert_eq!(image, Some("registry.example/api@sha256:abc")); - } - - #[test] - fn context_pull_secret_is_lowered_only_to_the_pod() { - let lowered = lower( - &fixture(), - &BTreeMap::new(), - &ProviderBindings::default(), - &endpoints(), - Some("registry-credentials"), - ) - .unwrap(); - let pod = lowered.deployments[0] - .spec - .as_ref() - .unwrap() - .template - .spec - .as_ref() - .unwrap(); - assert_eq!( - pod.image_pull_secrets.as_ref().unwrap()[0].name, - "registry-credentials" - ); - } - - #[tokio::test] - async fn pull_credentials_add_secret_score_before_the_application() { - let context = remote_context(Some("registry-auth")); - let ctx = AppContext::new( - &context, - "1.0.0".into(), - None, - Arc::new(harmony_config::ConfigClient::new(vec![Arc::new( - PullCredentialSource, - )])), - None, - None, - ); - - let scores = fixture().scores(&ctx, &ImageRefs::default()).await.unwrap(); - let names: Vec<_> = scores.iter().map(|score| score.name()).collect(); - - assert_eq!(names[0], "RegistryPullSecretScore(sample/registry-auth)"); - assert_eq!(names.last().unwrap(), "K8sAnywhereApplicationScore(sample)"); - } - - #[tokio::test] - async fn missing_pull_credentials_preserve_a_manually_managed_secret() { - let context = remote_context(Some("registry-auth")); - let ctx = AppContext::load_metadata(&context, "1.0.0", None); - - let scores = fixture().scores(&ctx, &ImageRefs::default()).await.unwrap(); - let names: Vec<_> = scores.iter().map(|score| score.name()).collect(); - - assert_eq!(names, ["K8sAnywhereApplicationScore(sample)"]); - } - - #[test] - fn semantic_values_lower_to_typed_provider_references() { - let mut app = fixture(); - let database = crate::application::DatabaseRef::new("app-db"); - let zitadel = crate::application::ZitadelRef::new("identity"); - let project = ZitadelProjectRef::new("recipe"); - let application = ZitadelApplicationRef::new(project.clone(), "web"); - let machine = harmony::modules::zitadel::ZitadelMachineRef::new("backend"); - app.services[0].values.extend([ - ("JDBC_URL".into(), ValueRef::DatabaseJdbcUrl(database)), - ( - "PUBLIC_ORIGIN".into(), - PublicEndpointRef::new("sample").origin(), - ), - ( - "CALLBACK_URL".into(), - PublicEndpointRef::new("sample").url("/callback"), - ), - ( - "DB_USERNAME".into(), - ValueRef::DatabaseUsername(crate::application::DatabaseRef::new("app-db")), - ), - ( - "DB_PASSWORD".into(), - ValueRef::DatabasePassword(crate::application::DatabaseRef::new("app-db")), - ), - ("OIDC_CLIENT_ID".into(), zitadel.oidc_client_id(application)), - ( - "MACHINE_SECRET".into(), - zitadel.machine_client_secret(machine.clone()), - ), - ( - "MACHINE_KEY".into(), - ValueRef::File(zitadel.machine_json_key(machine, "/run/identity/key.json")), - ), - ]); - let key = KeyBinding { - source: "generated-output".into(), - key: "value".into(), - }; - let bindings = ProviderBindings { - databases: BTreeMap::from([("app-db".into(), application_database_binding("app-db"))]), - applications: BTreeMap::from([( - ("identity".into(), "recipe".into(), "web".into()), - key.clone(), - )]), - machine_client_secrets: BTreeMap::from([( - ("identity".into(), "backend".into()), - key.clone(), - )]), - machine_json_keys: BTreeMap::from([(("identity".into(), "backend".into()), key)]), - ..Default::default() - }; - let lowered = lower(&app, &BTreeMap::new(), &bindings, &endpoints(), None).unwrap(); - let container = &lowered.deployments[0] - .spec - .as_ref() - .unwrap() - .template - .spec - .as_ref() - .unwrap() - .containers[0]; - let env = container.env.as_ref().unwrap(); - let jdbc = env.iter().find(|value| value.name == "JDBC_URL").unwrap(); - assert_eq!( - jdbc.value_from - .as_ref() - .unwrap() - .secret_key_ref - .as_ref() - .unwrap() - .name, - "app-db-app" - ); - assert_eq!( - jdbc.value_from - .as_ref() - .unwrap() - .secret_key_ref - .as_ref() - .unwrap() - .key, - "jdbc-uri" - ); - for name in ["DB_USERNAME", "DB_PASSWORD"] { - let selector = env - .iter() - .find(|value| value.name == name) - .unwrap() - .value_from - .as_ref() - .unwrap() - .secret_key_ref - .as_ref() - .unwrap(); - assert_eq!(selector.name, "app-db-app"); - } - assert_eq!( - env.iter() - .find(|value| value.name == "PUBLIC_ORIGIN") - .unwrap() - .value - .as_deref(), - Some("https://sample.test") - ); - assert_eq!( - env.iter() - .find(|value| value.name == "CALLBACK_URL") - .unwrap() - .value - .as_deref(), - Some("https://sample.test/callback") - ); - let oidc = env - .iter() - .find(|value| value.name == "OIDC_CLIENT_ID") - .unwrap(); - assert_eq!( - oidc.value_from - .as_ref() - .unwrap() - .config_map_key_ref - .as_ref() - .unwrap() - .name, - "generated-output" - ); - assert_eq!( - container.volume_mounts.as_ref().unwrap()[0].mount_path, - "/run/identity/key.json" - ); - } - - #[test] - fn contract_urls_resolve_from_logical_endpoints() { - let project = ZitadelProjectRef::new("recipe"); - let application = ZitadelApplicationRef::new(project.clone(), "web"); - let contract = ZitadelContract::default() - .project(ZitadelProjectDeclaration::new(project)) - .application(ZitadelOidcApplicationDeclaration::web_pkce( - application.clone(), - Vec::new(), - )); - let managed = ManagedZitadel::new("identity", PublicEndpointRef::new("sample")) - .contract(contract) - .redirect(application, PublicEndpointRef::new("sample"), "/callback"); - let lowered = lower_contract(&managed, &endpoints()).unwrap(); - assert_eq!( - lowered.applications[0].redirect_uris, - ["https://sample.test/callback"] - ); - } - - #[test] - fn image_build_declaration_becomes_image_spec() { - let app = Application::new("sample") - .image( - Image::build("api", "services/api") - .dockerfile("Containerfile") - .platform("linux/amd64") - .build_arg("PROFILE", Some("prod")), - ) - .service(Service::new( - "api", - crate::application::ImageRef::new("api"), - )); - let context = Context { - name: "dev".parse().unwrap(), - namespace: "sample".parse().unwrap(), - spec: ContextSpec::Local(LocalContext::ManagedK3d), - }; - let ctx = AppContext::load_metadata(&context, "1.2.3", None); - let specs = >::images(&app, &ctx).unwrap(); - assert_eq!(specs.len(), 1); - assert_eq!(specs[0].image, "localhost/api:1.2.3"); - assert_eq!( - specs[0].dockerfile.to_str(), - Some("services/api/Containerfile") - ); - assert_eq!(specs[0].build_args[0].0, "PROFILE"); - } - - #[tokio::test] - async fn local_public_endpoints_are_rejected_before_lowering() { - let context = Context { - name: "dev".parse().unwrap(), - namespace: "sample".parse().unwrap(), - spec: ContextSpec::Local(LocalContext::ManagedK3d), - }; - let ctx = AppContext::load_metadata(&context, "1.0.0", None); - let error = fixture() - .scores(&ctx, &ImageRefs::default()) - .await - .err() - .unwrap(); - assert!( - error - .to_string() - .contains("local public endpoints are not supported") - ); - } - - #[tokio::test] - async fn managed_zitadel_scores_are_dependency_ordered() { - let web_endpoint = LogicalEndpoint::new("web"); - let auth_endpoint = LogicalEndpoint::new("auth"); - let project = ZitadelProjectRef::new("recipe"); - let oidc = ZitadelApplicationRef::new(project.clone(), "web"); - let contract = ZitadelContract::default() - .project(ZitadelProjectDeclaration::new(project)) - .application(ZitadelOidcApplicationDeclaration::web_pkce( - oidc.clone(), - Vec::new(), - )); - let zitadel = ManagedZitadel::new("identity", auth_endpoint.reference()) - .contract(contract) - .redirect(oidc, web_endpoint.reference(), "/auth/callback"); - let app = Application::new("sample") - .image(Image::new("web", "example/web:1")) - .endpoint(web_endpoint) - .endpoint(auth_endpoint) - .resource(zitadel) - .service(Service::new( - "web", - crate::application::ImageRef::new("web"), - )); - let context = remote_context(None); - let ctx = AppContext::load_metadata(&context, "1.0.0", None); - let scores = app.scores(&ctx, &ImageRefs::default()).await.unwrap(); - let names: Vec<_> = scores.iter().map(|score| score.name()).collect(); - assert!(names[0].starts_with("PostgreSQLScore")); - assert_eq!(names[1], "ZitadelScore"); - assert_eq!(names[2], "ZitadelContractSetupScore"); - assert_eq!(names[3], "K8sAnywhereApplicationScore(sample)"); - } -} diff --git a/harmony_app/src/application/mod.rs b/harmony_app/src/application/mod.rs deleted file mode 100644 index efe74cab..00000000 --- a/harmony_app/src/application/mod.rs +++ /dev/null @@ -1,20 +0,0 @@ -//! Topology-neutral application declarations. K8sAnywhere is the first adapter; -//! declarations are not deployable until an adapter exists for the target topology. - -mod k8s_anywhere; -mod model; -mod resources; -mod validation; - -pub use harmony::modules::zitadel::contract as zitadel; -pub use model::{ - Application, Command, Cpu, FileRef, HealthCheck, Image, ImageBuild, ImageRef, ImageSource, - LogicalEndpoint, ManagedTls, Memory, Port, PortRef, Protocol, PublicEndpointRef, - ReadinessIntent, ResourceIntent, RolloutIntent, RolloutStrategy, Route, Service, ServiceRef, - ValueRef, -}; -pub use resources::{ - BucketRef, DatabaseRef, ManagedBucket, ManagedPostgres, ManagedResource, ManagedZitadel, - OidcRedirect, ZitadelRef, -}; -pub use validation::ApplicationValidationError; diff --git a/harmony_app/src/application/model.rs b/harmony_app/src/application/model.rs deleted file mode 100644 index 5d6e500f..00000000 --- a/harmony_app/src/application/model.rs +++ /dev/null @@ -1,562 +0,0 @@ -use std::path::PathBuf; -use std::time::Duration; - -use serde::Serialize; - -use super::{ApplicationValidationError, BucketRef, DatabaseRef, ManagedResource, ZitadelRef}; -use harmony::modules::zitadel::{ZitadelApplicationRef, ZitadelMachineRef, ZitadelProjectRef}; - -/// A topology-neutral application declaration. K8sAnywhere is currently its first adapter. -#[derive(Debug, Clone, Serialize)] -pub struct Application { - pub name: String, - pub images: Vec, - pub endpoints: Vec, - pub resources: Vec, - pub services: Vec, - /// Routes are evaluated in declaration order. - pub routes: Vec, - pub rollout: RolloutIntent, -} - -impl Application { - pub fn new(name: impl Into) -> Self { - Self { - name: name.into(), - images: Vec::new(), - endpoints: Vec::new(), - resources: Vec::new(), - services: Vec::new(), - routes: Vec::new(), - rollout: RolloutIntent::default(), - } - } - - pub fn image(mut self, image: Image) -> Self { - self.images.push(image); - self - } - - pub fn service(mut self, service: Service) -> Self { - self.services.push(service); - self - } - - pub fn endpoint(mut self, endpoint: LogicalEndpoint) -> Self { - self.endpoints.push(endpoint); - self - } - - pub fn resource(mut self, resource: impl Into) -> Self { - self.resources.push(resource.into()); - self - } - - pub fn route(mut self, route: Route) -> Self { - self.routes.push(route); - self - } - - pub fn rollout(mut self, rollout: RolloutIntent) -> Self { - self.rollout = rollout; - self - } - - pub fn validate(&self) -> Result<(), ApplicationValidationError> { - super::validation::validate(self) - } -} - -#[derive(Debug, Clone, Serialize)] -pub struct Image { - pub name: String, - pub source: ImageSource, -} - -impl Image { - pub fn new(name: impl Into, reference: impl Into) -> Self { - Self { - name: name.into(), - source: ImageSource::Reference(reference.into()), - } - } - - pub fn reference(&self) -> ImageRef { - ImageRef(self.name.clone()) - } - - pub fn build(name: impl Into, context: impl Into) -> Self { - Self { - name: name.into(), - source: ImageSource::Build(ImageBuild { - context: context.into(), - dockerfile: PathBuf::from("Dockerfile"), - platform: None, - build_args: Vec::new(), - }), - } - } - - pub fn dockerfile(mut self, dockerfile: impl Into) -> Self { - if let ImageSource::Build(build) = &mut self.source { - build.dockerfile = dockerfile.into(); - } - self - } - - pub fn platform(mut self, platform: impl Into) -> Self { - if let ImageSource::Build(build) = &mut self.source { - build.platform = Some(platform.into()); - } - self - } - - pub fn build_arg(mut self, name: impl Into, value: Option>) -> Self { - if let ImageSource::Build(build) = &mut self.source { - build.build_args.push((name.into(), value.map(Into::into))); - } - self - } -} - -#[derive(Debug, Clone, Serialize)] -pub enum ImageSource { - Reference(String), - Build(ImageBuild), -} - -#[derive(Debug, Clone, Serialize)] -pub struct ImageBuild { - pub context: PathBuf, - pub dockerfile: PathBuf, - pub platform: Option, - pub build_args: Vec<(String, Option)>, -} - -#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize)] -pub struct ImageRef(pub(crate) String); - -impl ImageRef { - pub fn new(name: impl Into) -> Self { - Self(name.into()) - } - - pub fn name(&self) -> &str { - &self.0 - } -} - -#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize)] -pub struct ServiceRef(pub(crate) String); - -impl ServiceRef { - pub fn new(name: impl Into) -> Self { - Self(name.into()) - } - - pub fn name(&self) -> &str { - &self.0 - } - - pub fn port(&self, name: impl Into) -> PortRef { - PortRef { - service: self.clone(), - port: name.into(), - } - } -} - -#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize)] -pub struct PortRef { - pub(crate) service: ServiceRef, - pub(crate) port: String, -} - -impl PortRef { - pub fn new(service: ServiceRef, port: impl Into) -> Self { - Self { - service, - port: port.into(), - } - } - - pub fn service(&self) -> &ServiceRef { - &self.service - } - - pub fn name(&self) -> &str { - &self.port - } -} - -#[derive(Debug, Clone, Serialize)] -pub struct Service { - pub name: String, - pub image: ImageRef, - pub command: Option, - pub ports: Vec, - pub values: Vec<(String, ValueRef)>, - pub health: Option, - pub resources: ResourceIntent, -} - -impl Service { - pub fn new(name: impl Into, image: ImageRef) -> Self { - Self { - name: name.into(), - image, - command: None, - ports: Vec::new(), - values: Vec::new(), - health: None, - resources: ResourceIntent::default(), - } - } - - pub fn reference(&self) -> ServiceRef { - ServiceRef(self.name.clone()) - } - - pub fn command(mut self, command: Command) -> Self { - self.command = Some(command); - self - } - - pub fn port(mut self, port: Port) -> Self { - self.ports.push(port); - self - } - - pub fn value(mut self, name: impl Into, value: ValueRef) -> Self { - self.values.push((name.into(), value)); - self - } - - pub fn health(mut self, health: HealthCheck) -> Self { - self.health = Some(health); - self - } - - pub fn resources(mut self, resources: ResourceIntent) -> Self { - self.resources = resources; - self - } -} - -#[derive(Debug, Clone, Serialize)] -pub struct Command { - pub program: String, - pub args: Vec, -} - -impl Command { - pub fn new( - program: impl Into, - args: impl IntoIterator>, - ) -> Self { - Self { - program: program.into(), - args: args.into_iter().map(Into::into).collect(), - } - } -} - -#[derive(Debug, Clone, Serialize)] -pub struct Port { - pub name: String, - pub number: u16, - pub protocol: Protocol, -} - -impl Port { - pub fn tcp(name: impl Into, number: u16) -> Self { - Self { - name: name.into(), - number, - protocol: Protocol::Tcp, - } - } - - pub fn udp(name: impl Into, number: u16) -> Self { - Self { - name: name.into(), - number, - protocol: Protocol::Udp, - } - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] -pub enum Protocol { - Tcp, - Udp, -} - -/// A value is either literal or resolved from desired-state semantics. -#[derive(Debug, Clone, Serialize)] -pub enum ValueRef { - Literal(String), - ServiceHost(ServiceRef), - ServicePort(PortRef), - ServiceUrl { - scheme: String, - port: PortRef, - }, - PublicEndpointOrigin(PublicEndpointRef), - PublicEndpointUrl { - endpoint: PublicEndpointRef, - path: String, - }, - DatabaseJdbcUrl(DatabaseRef), - DatabaseUsername(DatabaseRef), - DatabasePassword(DatabaseRef), - BucketEndpoint(BucketRef), - BucketName(BucketRef), - BucketAccessKey(BucketRef), - BucketSecretKey(BucketRef), - BucketRegion(BucketRef), - BucketPathStyle(BucketRef), - ZitadelIssuer(ZitadelRef), - ZitadelManagementUrl(ZitadelRef), - OidcProjectId { - zitadel: ZitadelRef, - project: ZitadelProjectRef, - }, - OidcClientId { - zitadel: ZitadelRef, - application: ZitadelApplicationRef, - }, - MachineClientId { - zitadel: ZitadelRef, - machine: ZitadelMachineRef, - }, - MachineClientSecret { - zitadel: ZitadelRef, - machine: ZitadelMachineRef, - }, - /// Mount the secret key at `path` and set the value to that path. - File(FileRef), -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize)] -pub enum FileRef { - MachineJsonKey { - zitadel: ZitadelRef, - machine: ZitadelMachineRef, - path: String, - }, -} - -impl FileRef { - pub fn path(&self) -> &str { - match self { - Self::MachineJsonKey { path, .. } => path, - } - } -} - -impl ValueRef { - pub fn literal(value: impl Into) -> Self { - Self::Literal(value.into()) - } - - pub fn service_url(scheme: impl Into, port: PortRef) -> Self { - Self::ServiceUrl { - scheme: scheme.into(), - port, - } - } -} - -#[derive(Debug, Clone, Serialize)] -pub enum HealthCheck { - Http { - port: PortRef, - path: String, - interval: Duration, - timeout: Duration, - initial_delay: Duration, - }, - Tcp { - port: PortRef, - interval: Duration, - timeout: Duration, - initial_delay: Duration, - }, -} - -impl HealthCheck { - pub fn http(port: PortRef, path: impl Into) -> Self { - Self::Http { - port, - path: path.into(), - interval: Duration::from_secs(10), - timeout: Duration::from_secs(2), - initial_delay: Duration::from_secs(0), - } - } - - pub fn tcp(port: PortRef) -> Self { - Self::Tcp { - port, - interval: Duration::from_secs(10), - timeout: Duration::from_secs(2), - initial_delay: Duration::from_secs(0), - } - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] -pub enum Cpu { - Millicores(u32), - Cores(u16), -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] -pub enum Memory { - Mebibytes(u32), - Gibibytes(u32), -} - -/// Portable scheduler intent with typed CPU and memory units. -#[derive(Debug, Clone, Default, Serialize)] -pub struct ResourceIntent { - pub cpu_request: Option, - pub cpu_limit: Option, - pub memory_request: Option, - pub memory_limit: Option, -} - -#[derive(Debug, Clone, Serialize)] -pub struct Route { - pub endpoint: PublicEndpointRef, - pub path: String, - pub target: PortRef, - pub smoke_check: bool, -} - -impl Route { - pub fn new(endpoint: PublicEndpointRef, path: impl Into, target: PortRef) -> Self { - Self { - endpoint, - path: path.into(), - target, - smoke_check: true, - } - } - - pub fn smoke_check(mut self, enabled: bool) -> Self { - self.smoke_check = enabled; - self - } -} - -#[derive(Debug, Clone, Serialize)] -pub struct LogicalEndpoint { - pub name: String, - pub tls: ManagedTls, -} - -impl LogicalEndpoint { - pub fn new(name: impl Into) -> Self { - Self { - name: name.into(), - tls: ManagedTls::Disabled, - } - } - - pub fn managed_tls(mut self) -> Self { - self.tls = ManagedTls::Managed; - self - } - - pub fn reference(&self) -> PublicEndpointRef { - PublicEndpointRef(self.name.clone()) - } -} - -#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize)] -pub struct PublicEndpointRef(pub(crate) String); - -impl PublicEndpointRef { - pub fn new(name: impl Into) -> Self { - Self(name.into()) - } - - pub fn name(&self) -> &str { - &self.0 - } - - pub fn origin(&self) -> ValueRef { - ValueRef::PublicEndpointOrigin(self.clone()) - } - - pub fn url(&self, path: impl Into) -> ValueRef { - ValueRef::PublicEndpointUrl { - endpoint: self.clone(), - path: path.into(), - } - } -} - -impl From for ManagedResource { - fn from(value: super::ManagedPostgres) -> Self { - Self::Postgres(value) - } -} - -impl From for ManagedResource { - fn from(value: super::ManagedBucket) -> Self { - Self::Bucket(value) - } -} - -impl From for ManagedResource { - fn from(value: super::ManagedZitadel) -> Self { - Self::Zitadel(value) - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] -pub enum ManagedTls { - Disabled, - Managed, -} - -#[derive(Debug, Clone, Serialize)] -pub struct RolloutIntent { - pub replicas: u32, - pub strategy: RolloutStrategy, - pub readiness: ReadinessIntent, -} - -impl Default for RolloutIntent { - fn default() -> Self { - Self { - replicas: 1, - strategy: RolloutStrategy::Rolling, - readiness: ReadinessIntent::default(), - } - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] -pub enum RolloutStrategy { - Rolling, - Replace, -} - -#[derive(Debug, Clone, Serialize)] -pub struct ReadinessIntent { - pub wait: bool, - pub timeout: Duration, -} - -impl Default for ReadinessIntent { - fn default() -> Self { - Self { - wait: true, - timeout: Duration::from_secs(180), - } - } -} diff --git a/harmony_app/src/application/resources.rs b/harmony_app/src/application/resources.rs deleted file mode 100644 index f1458ec0..00000000 --- a/harmony_app/src/application/resources.rs +++ /dev/null @@ -1,266 +0,0 @@ -use serde::Serialize; - -use harmony::modules::zitadel::{ - ZitadelApplicationRef, ZitadelContract, ZitadelMachineRef, ZitadelProjectRef, -}; - -use super::{FileRef, PublicEndpointRef, ValueRef}; - -#[derive(Debug, Clone, Serialize)] -pub enum ManagedResource { - Postgres(ManagedPostgres), - Bucket(ManagedBucket), - Zitadel(ManagedZitadel), -} - -/// S3-compatible object bucket (Rook ObjectBucketClaim / ceph-bucket by default). -#[derive(Debug, Clone, Serialize)] -pub struct ManagedBucket { - pub name: String, - pub storage_class: String, - /// Rook `additionalConfig.maxSize` (e.g. `"10G"`). - pub max_size: String, - /// Public/browser endpoint override (e.g. context `object_storage_endpoint`). - /// When set, app credentials use this URL instead of cluster-internal RGW DNS. - pub endpoint: Option, - /// Public app endpoints whose origins are allowed by bucket CORS. - pub cors: Vec, -} - -impl ManagedBucket { - pub fn new(name: impl Into) -> Self { - Self { - name: name.into(), - storage_class: "ceph-bucket".into(), - max_size: "10G".into(), - endpoint: None, - cors: Vec::new(), - } - } - - pub fn storage_class(mut self, storage_class: impl Into) -> Self { - self.storage_class = storage_class.into(); - self - } - - pub fn max_size(mut self, max_size: impl Into) -> Self { - self.max_size = max_size.into(); - self - } - - pub fn endpoint(mut self, endpoint: impl Into) -> Self { - self.endpoint = Some(endpoint.into()); - self - } - - pub fn cors(mut self, endpoint: PublicEndpointRef) -> Self { - self.cors.push(endpoint); - self - } - - pub fn reference(&self) -> BucketRef { - BucketRef(self.name.clone()) - } -} - -#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize)] -pub struct BucketRef(pub(crate) String); - -impl BucketRef { - pub fn new(name: impl Into) -> Self { - Self(name.into()) - } - pub fn name(&self) -> &str { - &self.0 - } - pub fn endpoint(&self) -> ValueRef { - ValueRef::BucketEndpoint(self.clone()) - } - pub fn bucket(&self) -> ValueRef { - ValueRef::BucketName(self.clone()) - } - pub fn access_key(&self) -> ValueRef { - ValueRef::BucketAccessKey(self.clone()) - } - pub fn secret_key(&self) -> ValueRef { - ValueRef::BucketSecretKey(self.clone()) - } - pub fn region(&self) -> ValueRef { - ValueRef::BucketRegion(self.clone()) - } - pub fn path_style(&self) -> ValueRef { - ValueRef::BucketPathStyle(self.clone()) - } -} - -#[derive(Debug, Clone, Serialize)] -pub struct ManagedPostgres { - pub name: String, - pub instances: u32, - pub version: Option, - /// Companion Service `{name}-rw-debug` for VPN/debug (NodePort toggle). - /// Default **off** (`ClusterIP`). In the OKD console set `spec.type: NodePort` to enable. - /// Ships preserve live type/nodePort. (Not a TLS Route: PG16 lacks direct TLS/SNI for routers.) - pub debug_route: bool, -} - -impl ManagedPostgres { - pub fn new(name: impl Into) -> Self { - Self { - name: name.into(), - instances: 1, - version: None, - debug_route: false, - } - } - pub fn instances(mut self, instances: u32) -> Self { - self.instances = instances; - self - } - /// PostgreSQL major/minor tag (e.g. `"16"` / `"16.4"`) or a full - /// container image. Mapped to CNPG `spec.imageName`. - pub fn version(mut self, version: impl Into) -> Self { - self.version = Some(version.into()); - self - } - /// Declare a debug Service (ClusterIP off / NodePort on in the console). - pub fn debug_route(mut self) -> Self { - self.debug_route = true; - self - } - pub fn reference(&self) -> DatabaseRef { - DatabaseRef(self.name.clone()) - } -} - -#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize)] -pub struct DatabaseRef(pub(crate) String); - -impl DatabaseRef { - pub fn new(name: impl Into) -> Self { - Self(name.into()) - } - pub fn name(&self) -> &str { - &self.0 - } - pub fn jdbc_url(&self) -> ValueRef { - ValueRef::DatabaseJdbcUrl(self.clone()) - } - pub fn username(&self) -> ValueRef { - ValueRef::DatabaseUsername(self.clone()) - } - pub fn password(&self) -> ValueRef { - ValueRef::DatabasePassword(self.clone()) - } -} - -#[derive(Debug, Clone, Serialize)] -pub struct ManagedZitadel { - pub name: String, - pub endpoint: PublicEndpointRef, - pub version: String, - pub contract: ZitadelContract, - pub redirects: Vec, -} - -impl ManagedZitadel { - pub fn new(name: impl Into, endpoint: PublicEndpointRef) -> Self { - Self { - name: name.into(), - endpoint, - version: "v4.12.1".to_string(), - contract: ZitadelContract::default(), - redirects: Vec::new(), - } - } - pub fn version(mut self, version: impl Into) -> Self { - self.version = version.into(); - self - } - pub fn contract(mut self, contract: ZitadelContract) -> Self { - self.contract = contract; - self - } - pub fn redirect( - mut self, - application: ZitadelApplicationRef, - endpoint: PublicEndpointRef, - path: impl Into, - ) -> Self { - self.redirects.push(OidcRedirect { - application, - endpoint, - path: path.into(), - post_logout: false, - }); - self - } - pub fn post_logout( - mut self, - application: ZitadelApplicationRef, - endpoint: PublicEndpointRef, - path: impl Into, - ) -> Self { - self.redirects.push(OidcRedirect { - application, - endpoint, - path: path.into(), - post_logout: true, - }); - self - } - pub fn reference(&self) -> ZitadelRef { - ZitadelRef(self.name.clone()) - } -} - -#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize)] -pub struct ZitadelRef(pub(crate) String); - -impl ZitadelRef { - pub fn new(name: impl Into) -> Self { - Self(name.into()) - } - pub fn name(&self) -> &str { - &self.0 - } - pub fn project_id(&self, project: ZitadelProjectRef) -> ValueRef { - ValueRef::OidcProjectId { - zitadel: self.clone(), - project, - } - } - pub fn oidc_client_id(&self, application: ZitadelApplicationRef) -> ValueRef { - ValueRef::OidcClientId { - zitadel: self.clone(), - application, - } - } - pub fn machine_client_id(&self, machine: ZitadelMachineRef) -> ValueRef { - ValueRef::MachineClientId { - zitadel: self.clone(), - machine, - } - } - pub fn machine_client_secret(&self, machine: ZitadelMachineRef) -> ValueRef { - ValueRef::MachineClientSecret { - zitadel: self.clone(), - machine, - } - } - pub fn machine_json_key(&self, machine: ZitadelMachineRef, path: impl Into) -> FileRef { - FileRef::MachineJsonKey { - zitadel: self.clone(), - machine, - path: path.into(), - } - } -} - -#[derive(Debug, Clone, Serialize)] -pub struct OidcRedirect { - pub application: ZitadelApplicationRef, - pub endpoint: PublicEndpointRef, - pub path: String, - pub post_logout: bool, -} diff --git a/harmony_app/src/application/validation.rs b/harmony_app/src/application/validation.rs deleted file mode 100644 index 129f41ad..00000000 --- a/harmony_app/src/application/validation.rs +++ /dev/null @@ -1,597 +0,0 @@ -use std::collections::{BTreeMap, BTreeSet}; - -use thiserror::Error; - -use super::{ - Application, FileRef, HealthCheck, ImageSource, ManagedResource, PortRef, Protocol, ValueRef, -}; - -#[derive(Debug, Error, PartialEq, Eq)] -pub enum ApplicationValidationError { - #[error("{field} must be non-empty")] - Empty { field: String }, - #[error("duplicate {kind} '{name}'")] - Duplicate { kind: &'static str, name: String }, - #[error("unknown image '{0}'")] - UnknownImage(String), - #[error("unknown {kind} '{name}'")] - UnknownResource { kind: &'static str, name: String }, - #[error("unknown service '{0}'")] - UnknownService(String), - #[error("service '{0}' has no addressable ports")] - ServiceHasNoPorts(String), - #[error("unknown port '{service}.{port}'")] - UnknownPort { service: String, port: String }, - #[error("route target '{service}.{port}' must use TCP")] - NonTcpRoute { service: String, port: String }, - #[error("route '{host}{path}' must start with '/'")] - InvalidRoutePath { host: String, path: String }, - #[error("rollout replicas must be greater than zero")] - ZeroReplicas, - #[error("file value path '{0}' must be absolute")] - RelativeFilePath(String), - #[error("health check for '{service}' references another service '{target}'")] - CrossServiceHealthCheck { service: String, target: String }, - #[error("invalid Zitadel contract '{name}': {reason}")] - InvalidZitadelContract { name: String, reason: String }, - #[error("machine identity '{machine}' does not produce {credential}")] - MissingMachineCredential { - machine: String, - credential: &'static str, - }, -} - -pub(crate) fn validate(app: &Application) -> Result<(), ApplicationValidationError> { - non_empty(&app.name, "application name")?; - if app.rollout.replicas == 0 { - return Err(ApplicationValidationError::ZeroReplicas); - } - if app.services.is_empty() { - return Err(ApplicationValidationError::Empty { - field: "application services".to_string(), - }); - } - - let mut images = BTreeSet::new(); - for image in &app.images { - non_empty(&image.name, "image name")?; - match &image.source { - ImageSource::Reference(reference) => { - non_empty(reference, &format!("image '{}' reference", image.name))? - } - ImageSource::Build(build) => { - if build.context.as_os_str().is_empty() { - return Err(ApplicationValidationError::Empty { - field: format!("image '{}' build context", image.name), - }); - } - } - } - if !images.insert(image.name.as_str()) { - return Err(ApplicationValidationError::Duplicate { - kind: "image", - name: image.name.clone(), - }); - } - } - - let endpoints: BTreeSet<_> = app - .endpoints - .iter() - .map(|endpoint| endpoint.name.as_str()) - .collect(); - if endpoints.len() != app.endpoints.len() { - return Err(ApplicationValidationError::Duplicate { - kind: "public endpoint", - name: "declaration".to_string(), - }); - } - let mut databases = BTreeSet::new(); - let mut buckets = BTreeSet::new(); - let mut zitadels = BTreeMap::new(); - for resource in &app.resources { - match resource { - ManagedResource::Postgres(database) => { - non_empty(&database.name, "database name")?; - if !databases.insert(database.name.as_str()) { - return Err(ApplicationValidationError::Duplicate { - kind: "database", - name: database.name.clone(), - }); - } - } - ManagedResource::Bucket(bucket) => { - non_empty(&bucket.name, "bucket name")?; - non_empty(&bucket.storage_class, "bucket storage class")?; - non_empty(&bucket.max_size, "bucket max size")?; - if let Some(endpoint) = &bucket.endpoint { - non_empty(endpoint, "bucket endpoint")?; - } - for endpoint in &bucket.cors { - if !endpoints.contains(endpoint.name()) { - return Err(ApplicationValidationError::UnknownResource { - kind: "public endpoint", - name: endpoint.name().to_string(), - }); - } - } - if !buckets.insert(bucket.name.as_str()) { - return Err(ApplicationValidationError::Duplicate { - kind: "bucket", - name: bucket.name.clone(), - }); - } - } - ManagedResource::Zitadel(zitadel) => { - non_empty(&zitadel.name, "Zitadel name")?; - if zitadels.contains_key(zitadel.name.as_str()) { - return Err(ApplicationValidationError::Duplicate { - kind: "Zitadel", - name: zitadel.name.clone(), - }); - } - zitadel.contract.validate().map_err(|reason| { - ApplicationValidationError::InvalidZitadelContract { - name: zitadel.name.clone(), - reason, - } - })?; - if !endpoints.contains(zitadel.endpoint.name()) { - return Err(ApplicationValidationError::UnknownResource { - kind: "public endpoint", - name: zitadel.endpoint.name().to_string(), - }); - } - zitadels.insert(zitadel.name.as_str(), zitadel); - for redirect in &zitadel.redirects { - if !endpoints.contains(redirect.endpoint.name()) { - return Err(ApplicationValidationError::UnknownResource { - kind: "public endpoint", - name: redirect.endpoint.name().to_string(), - }); - } - if !redirect.path.starts_with('/') { - return Err(ApplicationValidationError::InvalidRoutePath { - host: redirect.endpoint.name().to_string(), - path: redirect.path.clone(), - }); - } - if !zitadel - .contract - .applications - .iter() - .any(|application| application.application == redirect.application) - { - return Err(ApplicationValidationError::UnknownResource { - kind: "OIDC application", - name: format!( - "{}/{}", - redirect.application.project().name(), - redirect.application.name() - ), - }); - } - } - } - } - } - - let mut services = BTreeMap::new(); - for service in &app.services { - non_empty(&service.name, "service name")?; - if !images.contains(service.image.name()) { - return Err(ApplicationValidationError::UnknownImage( - service.image.name().to_string(), - )); - } - if services.insert(service.name.as_str(), service).is_some() { - return Err(ApplicationValidationError::Duplicate { - kind: "service", - name: service.name.clone(), - }); - } - let mut ports = BTreeSet::new(); - for port in &service.ports { - non_empty(&port.name, &format!("service '{}' port name", service.name))?; - if port.number == 0 { - return Err(ApplicationValidationError::Empty { - field: format!("service '{}' port number", service.name), - }); - } - if !ports.insert(port.name.as_str()) { - return Err(ApplicationValidationError::Duplicate { - kind: "port", - name: format!("{}.{}", service.name, port.name), - }); - } - } - } - - let resolve_port = |reference: &PortRef| { - let service = services.get(reference.service.name()).ok_or_else(|| { - ApplicationValidationError::UnknownService(reference.service.name().to_string()) - })?; - service - .ports - .iter() - .find(|port| port.name == reference.name()) - .ok_or_else(|| ApplicationValidationError::UnknownPort { - service: reference.service.name().to_string(), - port: reference.name().to_string(), - }) - }; - - for service in &app.services { - let mut values = BTreeSet::new(); - for (name, value) in &service.values { - non_empty(name, &format!("service '{}' value name", service.name))?; - if !values.insert(name.as_str()) { - return Err(ApplicationValidationError::Duplicate { - kind: "value", - name: format!("{}.{}", service.name, name), - }); - } - match value { - ValueRef::ServiceHost(reference) => match services.get(reference.name()) { - None => { - return Err(ApplicationValidationError::UnknownService( - reference.name().to_string(), - )); - } - Some(service) if service.ports.is_empty() => { - return Err(ApplicationValidationError::ServiceHasNoPorts( - reference.name().to_string(), - )); - } - Some(_) => {} - }, - ValueRef::ServicePort(reference) - | ValueRef::ServiceUrl { - port: reference, .. - } => { - resolve_port(reference)?; - } - ValueRef::PublicEndpointOrigin(reference) => { - if !endpoints.contains(reference.name()) { - return Err(ApplicationValidationError::UnknownResource { - kind: "public endpoint", - name: reference.name().to_string(), - }); - } - } - ValueRef::PublicEndpointUrl { endpoint, path } => { - if !endpoints.contains(endpoint.name()) { - return Err(ApplicationValidationError::UnknownResource { - kind: "public endpoint", - name: endpoint.name().to_string(), - }); - } - if !path.starts_with('/') { - return Err(ApplicationValidationError::InvalidRoutePath { - host: endpoint.name().to_string(), - path: path.clone(), - }); - } - } - ValueRef::DatabaseJdbcUrl(reference) - | ValueRef::DatabaseUsername(reference) - | ValueRef::DatabasePassword(reference) => { - if !databases.contains(reference.name()) { - return Err(ApplicationValidationError::UnknownResource { - kind: "database", - name: reference.name().to_string(), - }); - } - } - ValueRef::BucketEndpoint(reference) - | ValueRef::BucketName(reference) - | ValueRef::BucketAccessKey(reference) - | ValueRef::BucketSecretKey(reference) - | ValueRef::BucketRegion(reference) - | ValueRef::BucketPathStyle(reference) => { - if !buckets.contains(reference.name()) { - return Err(ApplicationValidationError::UnknownResource { - kind: "bucket", - name: reference.name().to_string(), - }); - } - } - ValueRef::ZitadelIssuer(reference) | ValueRef::ZitadelManagementUrl(reference) => { - validate_zitadel(&zitadels, reference.name())?; - } - ValueRef::OidcProjectId { - zitadel: producer, - project, - } => { - let zitadel = validate_zitadel(&zitadels, producer.name())?; - if !zitadel - .contract - .projects - .iter() - .any(|item| item.project == *project) - { - return Err(ApplicationValidationError::UnknownResource { - kind: "OIDC project", - name: project.name().to_string(), - }); - } - } - ValueRef::OidcClientId { - zitadel: producer, - application, - } => { - let zitadel = validate_zitadel(&zitadels, producer.name())?; - if !zitadel - .contract - .applications - .iter() - .any(|item| item.application == *application) - { - return Err(ApplicationValidationError::UnknownResource { - kind: "OIDC application", - name: format!( - "{}/{}", - application.project().name(), - application.name() - ), - }); - } - } - ValueRef::MachineClientId { zitadel, machine } => { - let declaration = validate_machine(&zitadels, zitadel, machine)?; - if !declaration.client_secret { - return Err(ApplicationValidationError::MissingMachineCredential { - machine: machine.name().to_string(), - credential: "a client ID", - }); - } - } - ValueRef::MachineClientSecret { zitadel, machine } => { - let declaration = validate_machine(&zitadels, zitadel, machine)?; - if !declaration.client_secret { - return Err(ApplicationValidationError::MissingMachineCredential { - machine: machine.name().to_string(), - credential: "a client secret", - }); - } - } - ValueRef::File(reference) => { - let FileRef::MachineJsonKey { - zitadel, machine, .. - } = reference; - let declaration = validate_machine(&zitadels, zitadel, machine)?; - if declaration.key - != Some(harmony::modules::zitadel::ZitadelMachineKeyDeclaration::Json) - { - return Err(ApplicationValidationError::MissingMachineCredential { - machine: machine.name().to_string(), - credential: "a JSON key", - }); - } - if !reference.path().starts_with('/') { - return Err(ApplicationValidationError::RelativeFilePath( - reference.path().to_string(), - )); - } - } - ValueRef::Literal(_) => {} - } - } - if let Some(health) = &service.health { - let reference = match health { - HealthCheck::Http { port, path, .. } => { - if !path.starts_with('/') { - return Err(ApplicationValidationError::InvalidRoutePath { - host: service.name.clone(), - path: path.clone(), - }); - } - port - } - HealthCheck::Tcp { port, .. } => port, - }; - resolve_port(reference)?; - if reference.service.name() != service.name { - return Err(ApplicationValidationError::CrossServiceHealthCheck { - service: service.name.clone(), - target: reference.service.name().to_string(), - }); - } - } - } - - for route in &app.routes { - if !endpoints.contains(route.endpoint.name()) { - return Err(ApplicationValidationError::UnknownResource { - kind: "public endpoint", - name: route.endpoint.name().to_string(), - }); - } - if !route.path.starts_with('/') { - return Err(ApplicationValidationError::InvalidRoutePath { - host: route.endpoint.name().to_string(), - path: route.path.clone(), - }); - } - let port = resolve_port(&route.target)?; - if port.protocol != Protocol::Tcp { - return Err(ApplicationValidationError::NonTcpRoute { - service: route.target.service.name().to_string(), - port: route.target.name().to_string(), - }); - } - } - Ok(()) -} - -fn validate_zitadel<'a>( - zitadels: &'a BTreeMap<&str, &super::ManagedZitadel>, - name: &str, -) -> Result<&'a super::ManagedZitadel, ApplicationValidationError> { - zitadels - .get(name) - .copied() - .ok_or_else(|| ApplicationValidationError::UnknownResource { - kind: "Zitadel", - name: name.to_string(), - }) -} - -fn validate_machine<'a>( - zitadels: &'a BTreeMap<&str, &'a super::ManagedZitadel>, - producer: &super::ZitadelRef, - machine: &harmony::modules::zitadel::ZitadelMachineRef, -) -> Result<&'a harmony::modules::zitadel::ZitadelMachineDeclaration, ApplicationValidationError> { - let zitadel = validate_zitadel(zitadels, producer.name())?; - zitadel - .contract - .machines - .iter() - .find(|item| item.machine == *machine) - .ok_or_else(|| ApplicationValidationError::UnknownResource { - kind: "machine identity", - name: machine.name().to_string(), - }) -} - -fn non_empty(value: &str, field: &str) -> Result<(), ApplicationValidationError> { - if value.trim().is_empty() { - Err(ApplicationValidationError::Empty { - field: field.to_string(), - }) - } else { - Ok(()) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::application::{Image, LogicalEndpoint, ManagedZitadel, Port, Route, Service}; - use harmony::modules::zitadel::{ - ZitadelContract, ZitadelMachineDeclaration, ZitadelMachineRef, - }; - - fn valid_app() -> Application { - let image = Image::new("web", "example/web:1"); - let web = Service::new("web", image.reference()).port(Port::tcp("http", 8080)); - let web_ref = web.reference(); - let endpoint = LogicalEndpoint::new("web"); - let endpoint_ref = endpoint.reference(); - Application::new("example") - .image(image) - .endpoint(endpoint) - .service(web) - .route(Route::new(endpoint_ref, "/", web_ref.port("http"))) - } - - #[test] - fn accepts_typed_references() { - valid_app().validate().unwrap(); - } - - #[test] - fn rejects_unknown_route_port() { - let mut app = valid_app(); - app.routes[0].target = app.services[0].reference().port("admin"); - assert_eq!( - app.validate().unwrap_err(), - ApplicationValidationError::UnknownPort { - service: "web".to_string(), - port: "admin".to_string(), - } - ); - } - - #[test] - fn rejects_unknown_semantic_endpoint() { - let mut app = valid_app(); - app.services[0].values.push(( - "ORIGIN".to_string(), - super::super::PublicEndpointRef::new("missing").origin(), - )); - assert_eq!( - app.validate().unwrap_err(), - ApplicationValidationError::UnknownResource { - kind: "public endpoint", - name: "missing".to_string(), - } - ); - } - - #[test] - fn rejects_unproduced_machine_client_secret() { - let mut app = valid_app(); - let endpoint = LogicalEndpoint::new("identity"); - let machine = ZitadelMachineRef::new("backend"); - let identity = ManagedZitadel::new("identity", endpoint.reference()).contract( - ZitadelContract::default().machine(ZitadelMachineDeclaration { - machine: machine.clone(), - name: "Backend".to_string(), - key: None, - client_secret: false, - }), - ); - app.services[0].values.push(( - "CLIENT_SECRET".to_string(), - identity.reference().machine_client_secret(machine), - )); - app.endpoints.push(endpoint); - app.resources.push(identity.into()); - - assert_eq!( - app.validate().unwrap_err(), - ApplicationValidationError::MissingMachineCredential { - machine: "backend".to_string(), - credential: "a client secret", - } - ); - } - - #[test] - fn rejects_unproduced_machine_client_id() { - let mut app = valid_app(); - let endpoint = LogicalEndpoint::new("identity"); - let machine = ZitadelMachineRef::new("backend"); - let identity = ManagedZitadel::new("identity", endpoint.reference()).contract( - ZitadelContract::default().machine(ZitadelMachineDeclaration { - machine: machine.clone(), - name: "Backend".to_string(), - key: None, - client_secret: false, - }), - ); - app.services[0].values.push(( - "CLIENT_ID".to_string(), - identity.reference().machine_client_id(machine), - )); - app.endpoints.push(endpoint); - app.resources.push(identity.into()); - - assert_eq!( - app.validate().unwrap_err(), - ApplicationValidationError::MissingMachineCredential { - machine: "backend".to_string(), - credential: "a client ID", - } - ); - } - - #[test] - fn rejects_duplicate_managed_zitadel() { - let mut app = valid_app(); - let endpoint = LogicalEndpoint::new("identity"); - let identity = ManagedZitadel::new("identity", endpoint.reference()); - app.endpoints.push(endpoint); - app.resources.push(identity.clone().into()); - app.resources.push(identity.into()); - - assert_eq!( - app.validate().unwrap_err(), - ApplicationValidationError::Duplicate { - kind: "Zitadel", - name: "identity".to_string(), - } - ); - } -} diff --git a/harmony_app/src/capabilities.rs b/harmony_app/src/capabilities.rs deleted file mode 100644 index 15c118ff..00000000 --- a/harmony_app/src/capabilities.rs +++ /dev/null @@ -1,386 +0,0 @@ -//! Composable **capabilities** — the `.with(...)` menu. A capability -//! contributes its own Scores (e.g. a database) and injects env into the -//! app's containers, **wired by k8s reference** (service DNS, `secretKeyRef`), -//! so generated secrets never pass through Harmony and a published chart stays -//! value-free. This is the framework-level, discoverable home: `Postgres` -//! today; `Monitoring` and `ZitadelAuth` next. -//! -//! To add one: implement [`Capability`] — `scores()` (what it deploys) and/or -//! `env()` (how the app reaches it, by reference) — then `app.with(MyThing)`. - -use harmony::modules::monitoring::alert_rule::prometheus_alert_rule::{ - AlertManagerRuleGroup, PrometheusAlertRule, -}; -use harmony::modules::monitoring::kube_prometheus::helm_prometheus_alert_score::HelmPrometheusAlertingScore; -use harmony::modules::monitoring::kube_prometheus::prometheus::KubePrometheus; -use harmony::modules::postgresql::K8sPostgreSQLScore; -use harmony::modules::postgresql::capability::PostgreSQLConfig; -use harmony::modules::zitadel::{ - ZitadelAppType, ZitadelApplication, ZitadelClientIdExportScore, ZitadelSetupScore, -}; -use harmony::score::Score; -use harmony::topology::oberservability::monitoring::AlertReceiver; -use harmony::topology::tenant::TenantManager; -use harmony::topology::{HelmCommand, K8sclient, Topology}; -use k8s_openapi::api::core::v1::{ConfigMapKeySelector, EnvVar, EnvVarSource, SecretKeySelector}; - -use crate::Profile; - -/// What a capability needs to know about the app it augments. -pub struct AppRef<'a> { - pub name: &'a str, - pub namespace: &'a str, - pub profile: Profile, -} - -/// An add-on a deployment declares with `.with(...)`: it deploys its own -/// Scores and injects env wired by k8s reference, never by value. **Generic -/// over the target `Topology`** — a concrete capability is implemented only -/// for the topologies whose capabilities can host its Scores, so `.with(X)` -/// is a compile error on a topology that can't run `X`. -pub trait Capability: Send + Sync { - fn scores(&self, _app: &AppRef) -> Vec>> { - vec![] - } - fn env(&self, _app: &AppRef) -> Vec { - vec![] - } -} - -/// A managed PostgreSQL database (CNPG). Deploys a cluster `-db` and -/// wires `DATABASE_URL` into the app from CNPG's generated `-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 Capability for Postgres { - fn scores(&self, app: &AppRef) -> Vec>> { - 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 { - 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>>, -} - -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 + '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 Capability for Monitoring { - fn scores(&self, app: &AppRef) -> Vec>> { - 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 `-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, - redirect_uris: Vec, - post_logout_redirect_uris: Vec, - endpoint: Option, - 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) -> 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) -> Self { - self.project = Some(project.into()); - self - } - pub fn redirect(mut self, uri: impl Into) -> Self { - self.redirect_uris.push(uri.into()); - self - } - pub fn post_logout(mut self, uri: impl Into) -> 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) -> 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) -> 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 Capability for ZitadelAuth { - fn scores(&self, app: &AppRef) -> Vec>> { - let project = self.project.clone().unwrap_or_else(|| app.name.to_string()); - let setup = ZitadelSetupScore { - host: host_of(self.endpoint.as_deref().unwrap_or(&self.issuer)), - scheme: Default::default(), - port: None, - skip_tls: false, - endpoint: self.endpoint.clone(), - namespace: self.pat_namespace.clone(), - admin_org_id: None, - applications: vec![ZitadelApplication { - project_name: project, - app_name: app.name.to_string(), - app_type: ZitadelAppType::WebPkce { - redirect_uris: self.redirect_uris.clone(), - post_logout_redirect_uris: self.post_logout_redirect_uris.clone(), - // TODO this should be configurable - id_token_role_assertion: false, - }, - }], - api_apps: vec![], - roles: vec![], - machine_users: vec![], - groups_claim_action: false, - ..Default::default() - }; - let export = ZitadelClientIdExportScore { - app_name: app.name.to_string(), - configmap_name: Self::configmap(app), - namespace: app.namespace.to_string(), - key: "OIDC_CLIENT_ID".to_string(), - }; - vec![Box::new(setup), Box::new(export)] - } - - fn env(&self, app: &AppRef) -> Vec { - 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, - a: &AppRef, - ) -> Vec>> { - c.scores(a) - } - fn env_of(c: &impl Capability, a: &AppRef) -> Vec { - 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"); - } -} diff --git a/harmony_app/src/chart.rs b/harmony_app/src/chart.rs deleted file mode 100644 index 0a1aceab..00000000 --- a/harmony_app/src/chart.rs +++ /dev/null @@ -1,546 +0,0 @@ -//! Generate a helm chart from an imported [`ComposeApp`]. -//! -//! Typed `k8s_openapi` resources serialized at build time (ADR-018, the -//! fleet operator chart pattern) — no `{{ .Values }}` templating. One -//! Deployment + Service per compose service, one PVC per mounted named -//! volume (RWX under RollingUpdate, RWO under Recreate). Ingress is -//! layered at deploy time by the -//! [`Score`](crate::score), not baked here, so the host stays a deploy -//! knob. - -use std::collections::BTreeMap; -use std::path::{Path, PathBuf}; - -use k8s_openapi::api::apps::v1::{ - Deployment, DeploymentSpec, DeploymentStrategy, RollingUpdateDeployment, -}; -use k8s_openapi::api::core::v1::{ - Container, ContainerPort, EnvFromSource, EnvVar, KeyToPath, LocalObjectReference, - PersistentVolumeClaim, PersistentVolumeClaimSpec, PersistentVolumeClaimVolumeSource, PodSpec, - PodTemplateSpec, SecretEnvSource, SecretVolumeSource, Service, ServicePort, ServiceSpec, - Volume, VolumeMount, VolumeResourceRequirements, -}; -use k8s_openapi::apimachinery::pkg::api::resource::Quantity; -use k8s_openapi::apimachinery::pkg::apis::meta::v1::LabelSelector; -use k8s_openapi::apimachinery::pkg::apis::meta::v1::ObjectMeta; -use k8s_openapi::apimachinery::pkg::util::intstr::IntOrString; - -use crate::AppError; -use crate::Profile; -use harmony::modules::application::helm::{HelmChart, HelmResourceKind}; - -use crate::compose::{ComposeApp, ComposeService}; - -/// Deploy-only knobs (ADR-026 §6) — the typed home for everything the -/// compose file deliberately does not carry. -#[derive(Debug, Clone, serde::Serialize)] -pub struct DeployConfig { - pub app_name: String, - pub chart_version: String, - /// Image coordinates for services that build from source. - pub registry: String, - pub project: String, - pub version: String, - /// RWX-capable storage class for PVCs (e.g. ceph `cephfs`). `None` - /// uses the cluster default — only correct if that default is RWX. - pub storage_class: Option, - pub volume_size: String, - pub replicas: i32, - /// `true` → RollingUpdate (when persistence enabled needs RWX so old+new pods share volumes); - /// `false` → Recreate (safe for single-writer stores like sqlite). - pub rolling: bool, - /// Extra env injected into every service container by capabilities — - /// wired by reference (e.g. a DB URL via `secretKeyRef`), never by value, - /// so the chart stays publishable. - pub extra_env: Vec, - /// Secret values projected into every container as files (e.g. a service - /// account key whose consumer wants a path, not an env var). By reference, - /// like `extra_env`, so the chart carries no secret material. - pub secret_file_mounts: Vec, - /// Names of `kubernetes.io/dockerconfigjson` Secrets the kubelet uses to - /// pull this app's images from a private registry, set on every pod's - /// `imagePullSecrets`. By reference — the Secret is applied separately. - pub image_pull_secrets: Vec, - pub images: BTreeMap, -} - -/// Mount one key of a Secret as a file at `path` (its parent dir is the mount -/// point; the basename is the projected filename). -#[derive(Debug, Clone, serde::Serialize)] -pub struct SecretFileMount { - pub secret: String, - pub key: String, - pub path: String, -} - -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, - registry: impl Into, - project: impl Into, - version: impl Into, - 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: None, - volume_size: "1Gi".to_string(), - replicas: 1, - rolling: prod, - extra_env: Vec::new(), - secret_file_mounts: Vec::new(), - image_pull_secrets: Vec::new(), - images: BTreeMap::new(), - } - } -} - -/// cert-manager ClusterIssuer for a profile: prod terminates TLS, local -/// serves plain HTTP. -pub fn cluster_issuer_for(profile: Profile) -> Option { - (profile == Profile::Prod).then(|| "letsencrypt-prod".to_string()) -} - -const ACCESS_MODE_RWX: &str = "ReadWriteMany"; -const ACCESS_MODE_RWO: &str = "ReadWriteOnce"; - -/// The published image ref a service's Deployment should pull: built -/// services get our registry coordinates, prebuilt `image:` services are -/// used verbatim. -pub fn service_image(cfg: &DeployConfig, svc: &ComposeService) -> String { - if svc.build_context.is_some() { - if let Some(image) = cfg.images.get(&svc.name) { - return image.clone(); - } - format!( - "{}/{}/{}-{}:{}", - cfg.registry, cfg.project, cfg.app_name, svc.name, cfg.version - ) - } else { - svc.image - .clone() - .expect("validated: image or build present") - } -} - -/// Name of the per-app Opaque Secret the deploy applies (from OpenBao, at -/// deploy time) and every container loads via `envFrom`. The chart only -/// *references* it (optional), so secret values never enter a published -/// chart — the [`Score`](crate::score) applies the Secret separately. -pub fn app_secret_name(app_name: &str) -> String { - format!("{app_name}-secrets") -} - -/// Build + write the chart to `out_dir`; returns the chart directory -/// `helm install ` wants. -pub fn build_chart( - app: &ComposeApp, - cfg: &DeployConfig, - out_dir: &Path, -) -> Result { - let mut chart = HelmChart::new(cfg.app_name.clone(), cfg.version.clone()); - chart.version = cfg.chart_version.clone(); - chart.description = format!("{} — imported from docker-compose by Harmony", cfg.app_name); - - for svc in &app.services { - chart.add_resource( - HelmResourceKind::from_serializable( - format!("deployment-{}.yaml", svc.name), - &deployment(svc, cfg), - ) - .map_err(|e| { - AppError::InvalidComposition(format!("serialize deployment {}: {e}", svc.name)) - })?, - ); - if let Some(service) = service(svc) { - chart.add_resource( - HelmResourceKind::from_serializable(format!("service-{}.yaml", svc.name), &service) - .map_err(|e| { - AppError::InvalidComposition(format!("serialize service {}: {e}", svc.name)) - })?, - ); - } - } - - for vol in app.mounted_volumes() { - chart.add_resource( - HelmResourceKind::from_serializable(format!("pvc-{vol}.yaml"), &pvc(&vol, cfg)) - .map_err(|e| AppError::InvalidComposition(format!("serialize pvc {vol}: {e}")))?, - ); - } - - chart - .write_to(out_dir) - .map_err(|e| AppError::InvalidComposition(format!("writing chart: {e}"))) -} - -fn labels(name: &str) -> BTreeMap { - BTreeMap::from([ - ("app.kubernetes.io/name".to_string(), name.to_string()), - ( - "app.kubernetes.io/managed-by".to_string(), - "harmony".to_string(), - ), - ]) -} - -fn deployment(svc: &ComposeService, cfg: &DeployConfig) -> Deployment { - let labels = labels(&svc.name); - - let ports: Vec = svc - .ports - .iter() - .map(|p| ContainerPort { - container_port: i32::from(p.container), - protocol: Some(p.protocol.clone()), - ..Default::default() - }) - .collect(); - - let mut env: Vec = svc - .env - .iter() - .map(|(k, v)| EnvVar { - name: k.clone(), - value: Some(v.clone()), - ..Default::default() - }) - .collect(); - // Capability-contributed env (e.g. a DB connection by `secretKeyRef`). - env.extend(cfg.extra_env.iter().cloned()); - - let mut volume_mounts: Vec = svc - .mounts - .iter() - .map(|m| VolumeMount { - name: m.volume.clone(), - mount_path: m.path.clone(), - ..Default::default() - }) - .collect(); - - let mut volumes: Vec = svc - .mounts - .iter() - .map(|m| Volume { - name: m.volume.clone(), - persistent_volume_claim: Some(PersistentVolumeClaimVolumeSource { - claim_name: m.volume.clone(), - ..Default::default() - }), - ..Default::default() - }) - .collect(); - - // Secret-as-file mounts (by reference): one volume per mount, projecting - // `key` to the file basename under the parent dir of `path`. - for (i, m) in cfg.secret_file_mounts.iter().enumerate() { - let name = format!("secret-file-{i}"); - let (dir, file) = m.path.rsplit_once('/').unwrap_or((".", m.path.as_str())); - volumes.push(Volume { - name: name.clone(), - secret: Some(SecretVolumeSource { - secret_name: Some(m.secret.clone()), - items: Some(vec![KeyToPath { - key: m.key.clone(), - path: file.to_string(), - ..Default::default() - }]), - ..Default::default() - }), - ..Default::default() - }); - volume_mounts.push(VolumeMount { - name, - mount_path: dir.to_string(), - read_only: Some(true), - ..Default::default() - }); - } - - let strategy = Some(if cfg.rolling { - DeploymentStrategy { - type_: Some("RollingUpdate".to_string()), - rolling_update: Some(RollingUpdateDeployment { - max_surge: Some(IntOrString::Int(1)), - max_unavailable: Some(IntOrString::Int(0)), - }), - } - } else { - DeploymentStrategy { - type_: Some("Recreate".to_string()), - rolling_update: None, - } - }); - - Deployment { - metadata: ObjectMeta { - name: Some(svc.name.clone()), - labels: Some(labels.clone()), - ..Default::default() - }, - spec: Some(DeploymentSpec { - replicas: Some(cfg.replicas), - strategy, - selector: LabelSelector { - match_labels: Some(labels.clone()), - ..Default::default() - }, - template: PodTemplateSpec { - metadata: Some(ObjectMeta { - labels: Some(labels), - ..Default::default() - }), - spec: Some(PodSpec { - containers: vec![Container { - name: svc.name.clone(), - image: Some(service_image(cfg, svc)), - image_pull_policy: Some("IfNotPresent".to_string()), - ports: (!ports.is_empty()).then_some(ports), - env: (!env.is_empty()).then_some(env), - // App secrets load here; `optional` so a secretless app - // needs no Secret. One app-wide bag — per-service - // targeting is deferred (Rule of Three). - env_from: Some(vec![EnvFromSource { - secret_ref: Some(SecretEnvSource { - name: app_secret_name(&cfg.app_name), - optional: Some(true), - }), - ..Default::default() - }]), - volume_mounts: (!volume_mounts.is_empty()).then_some(volume_mounts), - ..Default::default() - }], - image_pull_secrets: (!cfg.image_pull_secrets.is_empty()).then(|| { - cfg.image_pull_secrets - .iter() - .map(|name| LocalObjectReference { name: name.clone() }) - .collect() - }), - volumes: (!volumes.is_empty()).then_some(volumes), - ..Default::default() - }), - }, - ..Default::default() - }), - ..Default::default() - } -} - -fn service(svc: &ComposeService) -> Option { - if svc.ports.is_empty() { - return None; - } - let labels = labels(&svc.name); - let ports = svc - .ports - .iter() - .map(|p| ServicePort { - name: Some(format!("port-{}", p.container)), - port: i32::from(p.container), - target_port: Some(IntOrString::Int(i32::from(p.container))), - protocol: Some(p.protocol.clone()), - ..Default::default() - }) - .collect(); - Some(Service { - metadata: ObjectMeta { - name: Some(svc.name.clone()), - labels: Some(labels.clone()), - ..Default::default() - }, - spec: Some(ServiceSpec { - type_: Some("ClusterIP".to_string()), - selector: Some(labels), - ports: Some(ports), - ..Default::default() - }), - ..Default::default() - }) -} - -fn pvc(name: &str, cfg: &DeployConfig) -> PersistentVolumeClaim { - PersistentVolumeClaim { - metadata: ObjectMeta { - name: Some(name.to_string()), - labels: Some(labels(name)), - ..Default::default() - }, - spec: Some(PersistentVolumeClaimSpec { - // RollingUpdate keeps old+new pods alive together, so the volume - // must be shared (RWX); Recreate runs one pod at a time, so RWO - // suffices — and RWO is what non-distributed stores (k3d - // local-path) actually offer. - access_modes: Some(vec![ - if cfg.rolling { - ACCESS_MODE_RWX - } else { - ACCESS_MODE_RWO - } - .to_string(), - ]), - storage_class_name: cfg.storage_class.clone(), - resources: Some(VolumeResourceRequirements { - requests: Some(BTreeMap::from([( - "storage".to_string(), - Quantity(cfg.volume_size.clone()), - )])), - ..Default::default() - }), - ..Default::default() - }), - ..Default::default() - } -} - -#[cfg(test)] -mod tests { - use super::*; - use std::path::Path; - - fn fixture() -> (ComposeApp, DeployConfig, tempfile::TempDir) { - let yaml = "name: t\nservices:\n backend:\n build: ./backend\n ports: [\"8080:8080\"]\n volumes:\n - data:/data\n db:\n image: postgres:16\nvolumes:\n data:\n"; - let app = ComposeApp::parse(yaml, Path::new("/p")).unwrap(); - let cfg = DeployConfig { - app_name: "timesheet".to_string(), - chart_version: "0.1.0".to_string(), - registry: "hub.example".to_string(), - project: "harmony".to_string(), - version: "1.2.3".to_string(), - storage_class: Some("cephfs".to_string()), - volume_size: "2Gi".to_string(), - replicas: 2, - rolling: true, - extra_env: vec![], - secret_file_mounts: vec![], - image_pull_secrets: vec![], - images: BTreeMap::new(), - }; - let tmp = tempfile::tempdir().unwrap(); - build_chart(&app, &cfg, tmp.path()).unwrap(); - (app, cfg, tmp) - } - - fn read(tmp: &tempfile::TempDir, file: &str) -> String { - std::fs::read_to_string(tmp.path().join("timesheet/templates").join(file)).unwrap() - } - - #[test] - fn built_service_gets_registry_image_prebuilt_stays_verbatim() { - let cfg = fixture().1; - let app = ComposeApp::parse( - "services:\n backend:\n build: .\n db:\n image: postgres:16\n", - Path::new("/p"), - ) - .unwrap(); - assert_eq!( - service_image(&cfg, app.service("backend").unwrap()), - "hub.example/harmony/timesheet-backend:1.2.3" - ); - assert_eq!( - service_image(&cfg, app.service("db").unwrap()), - "postgres:16" - ); - } - - #[test] - fn each_service_renders_its_own_deployment_file() { - let (_, _, tmp) = fixture(); - // Per-service filenames — a shared "deployment.yaml" would collide. - assert!( - tmp.path() - .join("timesheet/templates/deployment-backend.yaml") - .exists() - ); - assert!( - tmp.path() - .join("timesheet/templates/deployment-db.yaml") - .exists() - ); - } - - #[test] - fn image_pull_secrets_render_on_pods_when_set() { - let (app, mut cfg, _t) = fixture(); - cfg.image_pull_secrets = vec!["registry-pull".to_string()]; - let tmp = tempfile::tempdir().unwrap(); - build_chart(&app, &cfg, tmp.path()).unwrap(); - let dep = read(&tmp, "deployment-backend.yaml"); - assert!(dep.contains("imagePullSecrets"), "{dep}"); - assert!(dep.contains("registry-pull"), "{dep}"); - } - - #[test] - fn no_image_pull_secrets_block_when_empty() { - // `fixture()` leaves image_pull_secrets empty → no block rendered. - let (_, _, tmp) = fixture(); - let dep = read(&tmp, "deployment-backend.yaml"); - assert!(!dep.contains("imagePullSecrets"), "{dep}"); - } - - #[test] - fn portless_service_emits_no_service_object() { - let (_, _, tmp) = fixture(); - assert!( - tmp.path() - .join("timesheet/templates/service-backend.yaml") - .exists() - ); - assert!( - !tmp.path() - .join("timesheet/templates/service-db.yaml") - .exists() - ); - } - - #[test] - fn pvc_is_readwritemany_with_storage_class() { - let (_, _, tmp) = fixture(); - let pvc = read(&tmp, "pvc-data.yaml"); - assert!(pvc.contains("ReadWriteMany"), "{pvc}"); - assert!(pvc.contains("cephfs"), "{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] - fn rolling_strategy_and_replicas_render() { - let (_, _, tmp) = fixture(); - let dep = read(&tmp, "deployment-backend.yaml"); - assert!(dep.contains("RollingUpdate"), "{dep}"); - assert!(dep.contains("replicas: 2"), "{dep}"); - assert!(dep.contains("claimName: data"), "{dep}"); - } - - #[test] - fn recreate_strategy_when_not_rolling() { - let yaml = "services:\n a:\n build: .\n"; - let app = ComposeApp::parse(yaml, Path::new("/p")).unwrap(); - let mut cfg = fixture().1; - cfg.rolling = false; - let tmp = tempfile::tempdir().unwrap(); - build_chart(&app, &cfg, tmp.path()).unwrap(); - let dep = std::fs::read_to_string(tmp.path().join("timesheet/templates/deployment-a.yaml")) - .unwrap(); - assert!(dep.contains("Recreate"), "{dep}"); - } -} diff --git a/harmony_app/src/compose.rs b/harmony_app/src/compose.rs deleted file mode 100644 index eb4060b6..00000000 --- a/harmony_app/src/compose.rs +++ /dev/null @@ -1,443 +0,0 @@ -//! Import an existing `docker-compose.yml` into a typed model. -//! -//! The compose file is the source of truth for the app's *base* shape — -//! images, ports, env, volumes (ADR-026). We translate only that subset -//! into typed Rust at the boundary; deploy-only knobs (replicas, storage -//! class, ingress host, rolling strategy) are NOT read from here — they -//! live in the deploy [`Score`](crate::score). This is an adapter, like -//! reading a Dockerfile, not a YAML deployment interface. -//! -//! What we deliberately do NOT support is rejected loudly (bind mounts, -//! unparseable ports) or warned about (depends_on, networks, …) — a -//! deploy tool must never silently drop config it can't honor. - -use std::path::{Path, PathBuf}; - -use docker_compose_types::{Compose, Environment, Ports, PublishedPort, Volumes}; -use thiserror::Error; - -#[derive(Debug, Error)] -pub enum ComposeError { - #[error("reading {path}: {source}")] - Read { - path: PathBuf, - source: std::io::Error, - }, - #[error("parsing compose yaml: {0}")] - Parse(#[from] serde_yaml::Error), - #[error("service '{service}' has neither `image` nor `build` — nothing to deploy")] - NoImageOrBuild { service: String }, - #[error("service '{service}': port '{spec}' is not parseable")] - BadPort { service: String, spec: String }, - #[error( - "service '{service}': volume '{spec}' is a bind mount; only named volumes map to a k8s PVC" - )] - BindMount { service: String, spec: String }, - #[error("service '{service}': volume source '{volume}' is not a named top-level volume")] - UnknownVolume { service: String, volume: String }, -} - -/// The imported app: an ordered list of services plus the named volumes -/// declared at the compose top level. -#[derive(Debug, Clone, serde::Serialize)] -pub struct ComposeApp { - pub services: Vec, - pub named_volumes: Vec, -} - -#[derive(Debug, Clone, serde::Serialize)] -pub struct ComposeService { - pub name: String, - /// Explicit prebuilt image (e.g. `postgres:16`) — used as-is, never - /// built. Mutually informative with `build_context`. - pub image: Option, - /// Build context dir (resolved relative to the compose file) — when - /// set, `publish` builds + pushes the image under our registry. - pub build_context: Option, - pub dockerfile: Option, - /// `build.args` — baked into the image at `docker build` time (forwarded as - /// `--build-arg`). Only the advanced build form carries them. - pub build_args: Vec<(String, Option)>, - pub ports: Vec, - pub env: Vec<(String, String)>, - pub mounts: Vec, -} - -#[derive(Debug, Clone, serde::Serialize)] -pub struct PortMap { - pub container: u16, - pub published: Option, - pub protocol: String, -} - -#[derive(Debug, Clone, serde::Serialize)] -pub struct VolumeMount { - pub volume: String, - pub path: String, -} - -impl ComposeApp { - /// Parse `/docker-compose.yml` (falling back to `.yaml`). - pub fn from_dir(dir: &Path) -> Result { - let path = ["docker-compose.yml", "docker-compose.yaml", "compose.yaml"] - .iter() - .map(|f| dir.join(f)) - .find(|p| p.exists()) - .unwrap_or_else(|| dir.join("docker-compose.yml")); - let raw = std::fs::read_to_string(&path).map_err(|source| ComposeError::Read { - path: path.clone(), - source, - })?; - Self::parse(&raw, dir) - } - - /// Parse compose YAML, resolving build contexts relative to `base_dir`. - pub fn parse(raw: &str, base_dir: &Path) -> Result { - let compose: Compose = serde_yaml::from_str(raw)?; - let named_volumes: Vec = compose.volumes.0.keys().cloned().collect(); - - let mut services = Vec::new(); - for (name, svc) in compose.services.0 { - let svc = svc.unwrap_or_default(); - - let build_context = match &svc.build_ { - Some(docker_compose_types::BuildStep::Simple(ctx)) => Some(base_dir.join(ctx)), - Some(docker_compose_types::BuildStep::Advanced(a)) => { - Some(base_dir.join(&a.context)) - } - None => None, - }; - let dockerfile = match &svc.build_ { - Some(docker_compose_types::BuildStep::Advanced(a)) => a.dockerfile.clone(), - _ => None, - }; - let build_args = match &svc.build_ { - Some(docker_compose_types::BuildStep::Advanced(a)) => parse_build_args(&a.args), - _ => Vec::new(), - }; - if svc.image.is_none() && build_context.is_none() { - return Err(ComposeError::NoImageOrBuild { service: name }); - } - - warn_ignored(&name, &svc); - - services.push(ComposeService { - ports: parse_ports(&name, &svc.ports)?, - env: parse_env(&svc.environment), - mounts: parse_volumes(&name, &svc.volumes, &named_volumes)?, - image: svc.image, - build_context, - dockerfile, - build_args, - name, - }); - } - - Ok(Self { - services, - named_volumes, - }) - } - - pub fn service(&self, name: &str) -> Option<&ComposeService> { - self.services.iter().find(|s| s.name == name) - } - - /// Named volumes actually referenced by a mount — the ones that need - /// a PVC. A declared-but-unmounted volume needs no k8s resource. - pub fn mounted_volumes(&self) -> Vec { - self.named_volumes - .iter() - .filter(|v| { - self.services - .iter() - .any(|s| s.mounts.iter().any(|m| &&m.volume == v)) - }) - .cloned() - .collect() - } -} - -fn parse_ports(service: &str, ports: &Ports) -> Result, ComposeError> { - match ports { - Ports::Short(specs) => specs.iter().map(|s| parse_short_port(service, s)).collect(), - Ports::Long(ports) => Ok(ports - .iter() - .map(|p| PortMap { - container: p.target, - published: match &p.published { - Some(PublishedPort::Single(n)) => Some(*n), - _ => None, - }, - protocol: p.protocol.clone().unwrap_or_else(|| "TCP".to_string()), - }) - .collect()), - } -} - -/// `"8081:80"`, `"80"`, `"127.0.0.1:8080:80"`, `"80/udp"` → the last -/// colon-token is the container port, the previous (if any) the host -/// port, a host IP before that is ignored (k8s has no host binding). -fn parse_short_port(service: &str, spec: &str) -> Result { - let bad = || ComposeError::BadPort { - service: service.to_string(), - spec: spec.to_string(), - }; - let (ports, protocol) = match spec.split_once('/') { - Some((p, proto)) => (p, proto.to_uppercase()), - None => (spec, "TCP".to_string()), - }; - let tokens: Vec<&str> = ports.split(':').collect(); - let container = tokens.last().ok_or_else(bad)?.parse().map_err(|_| bad())?; - let published = match tokens.len() { - 1 => None, - n => tokens[n - 2].parse().ok(), - }; - Ok(PortMap { - container, - published, - protocol, - }) -} - -fn parse_build_args( - args: &Option, -) -> Vec<(String, Option)> { - use docker_compose_types::BuildArgs; - match args { - None | Some(BuildArgs::Simple(_)) => Vec::new(), - Some(BuildArgs::List(items)) => items - .iter() - .map(|item| match item.split_once('=') { - Some((k, v)) => (k.to_string(), Some(v.to_string())), - None => (item.to_string(), None), - }) - .collect(), - Some(BuildArgs::KvPair(map)) => map - .iter() - .map(|(k, v)| (k.clone(), Some(v.clone()))) - .collect(), - } -} - -fn parse_env(env: &Environment) -> Vec<(String, String)> { - match env { - Environment::List(items) => items - .iter() - .map(|item| match item.split_once('=') { - Some((k, v)) => (k.to_string(), v.to_string()), - None => (item.to_string(), String::new()), - }) - .collect(), - Environment::KvPair(map) => map - .iter() - .map(|(k, v)| { - let v = v.as_ref().map(|s| s.to_string()).unwrap_or_default(); - (k.clone(), v) - }) - .collect(), - } -} - -fn parse_volumes( - service: &str, - volumes: &[Volumes], - named: &[String], -) -> Result, ComposeError> { - let mut mounts = Vec::new(); - for vol in volumes { - let (source, target) = match vol { - Volumes::Simple(spec) => { - let (source, target) = - spec.split_once(':') - .ok_or_else(|| ComposeError::BindMount { - service: service.to_string(), - spec: spec.clone(), - })?; - // strip a trailing `:ro`/`:rw` mode if present. - let target = target.split(':').next().unwrap_or(target); - (source.to_string(), target.to_string()) - } - Volumes::Advanced(a) => (a.source.clone().unwrap_or_default(), a.target.clone()), - }; - // A path-like source is a bind mount — not portable to a cluster. - if source.starts_with('.') || source.starts_with('/') { - return Err(ComposeError::BindMount { - service: service.to_string(), - spec: source, - }); - } - if !named.contains(&source) { - return Err(ComposeError::UnknownVolume { - service: service.to_string(), - volume: source, - }); - } - mounts.push(VolumeMount { - volume: source, - path: target, - }); - } - Ok(mounts) -} - -/// Compose keys we recognize but don't translate. A deploy tool that -/// drops these silently is a footgun, so we say so. -fn warn_ignored(name: &str, svc: &docker_compose_types::Service) { - if !svc.depends_on.is_empty() { - log::warn!( - "service '{name}': `depends_on` ignored — k8s ordering is via readiness, not startup order" - ); - } - if svc.command.is_some() { - log::warn!("service '{name}': `command` override ignored — bake it into the image"); - } - if svc.restart.is_some() { - log::warn!("service '{name}': `restart` ignored — k8s restarts via the Deployment policy"); - } -} - -#[cfg(test)] -mod tests { - use super::*; - use std::path::Path; - - const COMPOSE: &str = r#" -name: timesheet -services: - backend: - build: ./backend - ports: ["8080:8080"] - environment: - - DB_PATH=/data/timesheet.db - volumes: - - timesheet-data:/data - frontend: - build: ./frontend - ports: ["8081:80"] - environment: - - BACKEND_URL=http://backend:8080 - depends_on: - - backend -volumes: - timesheet-data: -"#; - - fn app() -> ComposeApp { - ComposeApp::parse(COMPOSE, Path::new("/proj")).expect("parse") - } - - #[test] - fn imports_both_services_in_order() { - let app = app(); - let names: Vec<&str> = app.services.iter().map(|s| s.name.as_str()).collect(); - assert_eq!(names, ["backend", "frontend"]); - } - - #[test] - fn build_context_resolves_against_base_dir() { - let backend = app().service("backend").unwrap().clone(); - assert_eq!( - backend.build_context, - Some(PathBuf::from("/proj/./backend")) - ); - assert!(backend.image.is_none()); - } - - #[test] - fn ports_split_published_and_container() { - let app = app(); - let be = &app.service("backend").unwrap().ports[0]; - assert_eq!((be.container, be.published), (8080, Some(8080))); - let fe = &app.service("frontend").unwrap().ports[0]; - assert_eq!((fe.container, fe.published), (80, Some(8081))); - } - - #[test] - fn env_parsed_as_key_value() { - let backend = app().service("backend").unwrap().clone(); - assert_eq!( - backend.env, - vec![("DB_PATH".to_string(), "/data/timesheet.db".to_string())] - ); - } - - #[test] - fn build_args_parsed_from_advanced_build() { - let yaml = "services:\n api:\n build:\n context: .\n args:\n QUARKUS_BUILD_PROFILE: dev\n"; - let app = ComposeApp::parse(yaml, Path::new(".")).unwrap(); - assert_eq!( - app.service("api").unwrap().build_args, - vec![("QUARKUS_BUILD_PROFILE".to_string(), Some("dev".to_string()))] - ); - } - - #[test] - fn bare_build_arg_inherits_from_environment() { - let yaml = "services:\n api:\n build:\n context: .\n args: [TOKEN]\n"; - let app = ComposeApp::parse(yaml, Path::new(".")).unwrap(); - assert_eq!( - app.service("api").unwrap().build_args, - vec![("TOKEN".to_string(), None)] - ); - } - - #[test] - fn build_args_empty_for_simple_build() { - let yaml = "services:\n api:\n build: .\n"; - let app = ComposeApp::parse(yaml, Path::new(".")).unwrap(); - assert!(app.service("api").unwrap().build_args.is_empty()); - } - - #[test] - fn named_volume_becomes_a_mount_and_only_mounted_volumes_need_pvcs() { - let app = app(); - let m = &app.service("backend").unwrap().mounts[0]; - assert_eq!( - (m.volume.as_str(), m.path.as_str()), - ("timesheet-data", "/data") - ); - assert_eq!(app.mounted_volumes(), vec!["timesheet-data".to_string()]); - } - - #[test] - fn short_port_forms_parse() { - assert_eq!(parse_short_port("s", "80").unwrap().container, 80); - let hostip = parse_short_port("s", "127.0.0.1:8080:80").unwrap(); - assert_eq!((hostip.container, hostip.published), (80, Some(8080))); - let udp = parse_short_port("s", "53:53/udp").unwrap(); - assert_eq!(udp.protocol, "UDP"); - assert!(parse_short_port("s", "notaport").is_err()); - } - - #[test] - fn bind_mounts_are_rejected() { - let yaml = "services:\n a:\n image: x\n volumes:\n - ./host:/data\n"; - let err = ComposeApp::parse(yaml, Path::new("/p")).unwrap_err(); - assert!(matches!(err, ComposeError::BindMount { .. }), "{err}"); - } - - #[test] - fn volume_not_declared_at_top_level_is_rejected() { - let yaml = "services:\n a:\n image: x\n volumes:\n - ghost:/data\n"; - let err = ComposeApp::parse(yaml, Path::new("/p")).unwrap_err(); - assert!(matches!(err, ComposeError::UnknownVolume { .. }), "{err}"); - } - - #[test] - fn service_without_image_or_build_is_rejected() { - let yaml = "services:\n a:\n ports: [\"80\"]\n"; - let err = ComposeApp::parse(yaml, Path::new("/p")).unwrap_err(); - assert!(matches!(err, ComposeError::NoImageOrBuild { .. }), "{err}"); - } - - /// The example's real compose must stay importable — it's the demo's - /// source of truth and a contract for the chart tests. - #[test] - fn the_examples_compose_file_imports() { - let dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("../examples/compose_java_react/app"); - let app = ComposeApp::from_dir(&dir).expect("import example compose"); - assert_eq!(app.services.len(), 2); - assert_eq!(app.mounted_volumes(), vec!["timesheet-data".to_string()]); - } -} diff --git a/harmony_app/src/context.rs b/harmony_app/src/context.rs index c549c11c..f4ddad1d 100644 --- a/harmony_app/src/context.rs +++ b/harmony_app/src/context.rs @@ -8,7 +8,7 @@ use std::sync::Arc; use harmony::modules::tenant::ClusterAccess; use harmony::topology::{K8sAnywhereConfig, K8sAnywhereTopology}; -use harmony_config::{ConfigClient, ConfigSource, LocalFileSource, StateClient}; +use harmony_config::{ConfigClient, ConfigSource, LocalFileSource}; use harmony_k8s::K8sClient; use harmony_types::context::{ ContextName, DomainName, HttpUrl, OciRegistry, OciRepository, OidcAudience, OpenBaoNamespace, @@ -132,7 +132,6 @@ pub struct AppContext { kubeconfig: Option, _kubeconfig_guard: Option, config_client: Arc, - state_client: StateClient, cluster_target: Option, } @@ -186,14 +185,11 @@ impl AppContext { Profile::from(&context.spec) ); debug!("Context '{name}' definition: {:?}", context.spec); - let (config_client, state_client) = - build_config_clients(&context.spec, local_config_dir.clone()) - .await - .map_err(|e| { - ContextError::Config(format!( - "building config sources for context '{name}': {e}" - )) - })?; + let config_client = build_config_clients(&context.spec, local_config_dir.clone()) + .await + .map_err(|e| { + ContextError::Config(format!("building config sources for context '{name}': {e}")) + })?; harmony_config::init_client(config_client.clone()).await; let (guard, cluster_target) = match &context.spec { ContextSpec::Local(LocalContext::ManagedK3d) => { @@ -232,16 +228,14 @@ impl AppContext { context.namespace ); - let mut context = Self::new( + Ok(Self::new( context, version.into(), local_config_dir, config_client, guard, cluster_target, - ); - context.state_client = state_client; - Ok(context) + )) } pub(crate) fn new( @@ -252,7 +246,6 @@ impl AppContext { guard: Option, cluster_target: Option, ) -> Self { - let state_client = StateClient::new(config_client.clone(), config_client.clone()); Self { context: context.clone(), version, @@ -260,7 +253,6 @@ impl AppContext { kubeconfig: guard.as_ref().map(|guard| guard.path().to_path_buf()), _kubeconfig_guard: guard, config_client, - state_client, cluster_target, } } @@ -329,12 +321,6 @@ impl AppContext { pub fn config_client(&self) -> &ConfigClient { &self.config_client } - pub(crate) fn config_client_arc(&self) -> Arc { - self.config_client.clone() - } - pub(crate) fn state_client(&self, scope: &str, migrate_legacy: bool) -> StateClient { - self.state_client.scoped(scope, migrate_legacy) - } pub fn k3d_cluster(&self) -> Option<&str> { match &self.context.spec { ContextSpec::Local(LocalContext::ManagedK3d) => Some(AUTOPROVISION_CLUSTER), @@ -444,7 +430,7 @@ fn kubeconfig_target(contents: &str) -> Result { async fn build_config_clients( spec: &ContextSpec, local_config_dir: Option, -) -> Result<(Arc, StateClient), ContextError> { +) -> Result, ContextError> { let source: Arc = match spec { ContextSpec::Remote(remote) => { let access = &remote.access; @@ -474,7 +460,7 @@ async fn build_config_clients( Arc::new(LocalFileSource::new(dir)) } }; - Ok(harmony_config::clients_for_source(source)) + Ok(harmony_config::clients_for_source(source).0) } fn write_kubeconfig(contents: &[u8]) -> Result { diff --git a/harmony_app/src/deploy.rs b/harmony_app/src/deploy.rs deleted file mode 100644 index 432f529c..00000000 --- a/harmony_app/src/deploy.rs +++ /dev/null @@ -1,303 +0,0 @@ -//! [`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, AppError, AppIdentity, AppRef, Capability, DeployOptions, HarmonyApp, ImageRefs, - ImageSpec, Profile, -}; -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}; - -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 { - name: String, - project: String, - registry: String, - app: ComposeApp, - expose: Option, - app_secrets: BTreeMap, - capabilities: Vec>>, -} - -impl ComposeDeploy { - /// Import the app from a compose dir. The project defaults to the app name. - pub fn from_dir(name: impl Into, dir: impl AsRef) -> Result { - let app = ComposeApp::from_dir(dir.as_ref())?; - Ok(Self::from_compose(name, app)) - } - - pub fn from_compose(name: impl Into, app: ComposeApp) -> Self { - let name = name.into(); - Self { - project: name.clone(), - registry: "localhost".to_string(), - name, - app, - expose: None, - app_secrets: BTreeMap::new(), - capabilities: Vec::new(), - } - } - - pub fn project(mut self, p: impl Into) -> Self { - self.project = p.into(); - self - } - pub fn registry(mut self, r: impl Into) -> 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, host: impl Into) -> Self { - self.expose = Some(Endpoint { - service: service.into(), - host: host.into(), - }); - self - } - pub fn secret(mut self, key: impl Into, value: impl Into) -> 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 + 'static) -> Self { - self.capabilities.push(Box::new(capability)); - self - } - - fn app_ref<'a>(&'a self, namespace: &'a str, profile: Profile) -> AppRef<'a> { - AppRef { - name: &self.name, - 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, - namespace: &str, - profile: Profile, - version: &str, - force_conflicts: bool, - ) -> Result { - 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(namespace, profile))) - .collect(); - Ok(ComposeAppScore { - namespace: namespace.to_string(), - release_name: self.name.clone(), - public_endpoint, - app: self.app.clone(), - deploy, - app_secrets: self.app_secrets.clone(), - force_conflicts, - }) - } -} - -#[async_trait] -impl HarmonyApp for ComposeDeploy { - fn identity(&self, ctx: &AppContext) -> AppIdentity { - AppIdentity { - name: self.name.clone(), - namespace: ctx.namespace().to_string(), - } - } - - async fn scores( - &self, - ctx: &AppContext, - images: &ImageRefs, - ) -> Result>>, AppError> { - self.scores_with_options( - ctx, - DeployOptions { - images: images.clone(), - ..Default::default() - }, - ) - .await - } - - async fn scores_with_options( - &self, - ctx: &AppContext, - options: DeployOptions, - ) -> Result>>, AppError> { - let mut app_score = self.score( - ctx.namespace(), - ctx.profile(), - ctx.version(), - options.force_conflicts, - )?; - app_score.deploy.images = self - .app - .services - .iter() - .filter(|service| service.build_context.is_some()) - .map(|service| { - Ok(( - service.name.clone(), - options.images.require(&service.name)?.to_string(), - )) - }) - .collect::>()?; - let mut scores: Vec>> = vec![Box::new(app_score)]; - let app_ref = self.app_ref(ctx.namespace(), ctx.profile()); - for capability in &self.capabilities { - scores.extend(capability.scores(&app_ref)); - } - Ok(scores) - } - - fn images(&self, ctx: &AppContext) -> Result, AppError> { - let config = self.deploy_config(ctx.profile(), ctx.version()); - self.app - .services - .iter() - .filter_map(|service| { - let context = service.build_context.clone()?; - Some(Ok(ImageSpec { - name: service.name.clone(), - image: crate::service_image(&config, service), - dockerfile: context.join(service.dockerfile.as_deref().unwrap_or("Dockerfile")), - context, - platform: None, - build_args: service.build_args.clone(), - })) - }) - .collect() - } -} - -#[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; - - 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("timesheet", Profile::Local, "0.1.0", false) - .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("timesheet", Profile::Local, "1.0.0", false) - .unwrap(); - 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_renders_replicated_tls() { - let s = CD::from_compose("ts", fixture()) - .registry("hub.x") - .project("p") - .expose("frontend", "ts.x") - .score("timesheet", Profile::Prod, "2.0.0", false) - .unwrap(); - assert_eq!(s.deploy.registry, "hub.x"); - assert_eq!(s.deploy.project, "p"); - assert_eq!(s.deploy.version, "2.0.0"); - assert_eq!(s.deploy.replicas, 1); - 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("timesheet", Profile::Local, "0.1.0", false) - .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("timesheet", Profile::Local, "0.1.0", false) - .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"); - } -} diff --git a/harmony_app/src/dx.rs b/harmony_app/src/dx.rs new file mode 100644 index 00000000..9048f4ed --- /dev/null +++ b/harmony_app/src/dx.rs @@ -0,0 +1,266 @@ +//! Application-authoring DX (ADR-029): one component, one file, one type. +//! +//! Runtimes are capabilities (`Command`, `Container`, `Remote`), not sibling +//! types. A context is an ordinary struct literal — fill every field, or it +//! does not compile. No macro required. + +use std::marker::PhantomData; + +/// Bind token: run as a host process. Alias: [`Exec`]. +#[allow(non_camel_case_types)] +pub struct command; +pub type Exec = command; + +/// Bind token: run as a cluster workload. +#[allow(non_camel_case_types)] +pub struct container; + +/// Bind token: do not run; only consume refs. +#[allow(non_camel_case_types)] +pub struct remote; + +pub trait Accepts {} + +/// Host process. The launcher is a string, never a Harmony type. +pub trait Command { + fn launch(&self) -> Launch; +} + +/// Cluster workload. +pub trait Container { + fn image(&self) -> Image; +} + +/// This context does not run the component. +pub trait Remote {} + +impl Accepts for T {} +impl Accepts for T {} +impl Accepts for T {} + +pub trait AsCommand: Command + Sized { + fn as_command(&self) -> BoundComp<'_, Self, command> { + BoundComp::new(self) + } +} +impl AsCommand for T {} + +pub trait AsContainer: Container + Sized { + fn as_container(&self) -> BoundComp<'_, Self, container> { + BoundComp::new(self) + } +} +impl AsContainer for T {} + +pub trait AsRemote: Remote + Sized { + fn as_remote(&self) -> BoundComp<'_, Self, remote> { + BoundComp::new(self) + } +} +impl AsRemote for T {} + +pub struct BoundComp<'a, C, R> { + pub inner: &'a C, + _runtime: PhantomData, +} + +impl<'a, C, R> BoundComp<'a, C, R> { + pub fn new(inner: &'a C) -> Self { + Self { + inner, + _runtime: PhantomData, + } + } +} + +/// Advertised handle (exists before either side runs). +pub struct Slot { + _t: PhantomData, +} +impl Copy for Slot {} +impl Clone for Slot { + fn clone(&self) -> Self { + *self + } +} + +impl Slot { + pub const fn new() -> Self { + Self { _t: PhantomData } + } + pub fn as_ref(self) -> Ref { + Ref { _t: PhantomData } + } + pub fn public(self) -> Ref { + self.as_ref() + } +} + +/// Desired-state reference. No I/O. Cycles are two slots. +pub struct Ref { + _t: PhantomData, +} +impl Copy for Ref {} +impl Clone for Ref { + fn clone(&self) -> Self { + *self + } +} + +pub struct Secret { + _t: PhantomData, +} + +pub struct Jdbc; +pub struct PgUrl; +pub struct HttpUrl; + +#[derive(Clone, Debug)] +pub struct Launch { + pub program: String, + pub args: Vec, + pub cwd: Option, + pub env: Vec<(String, String)>, + pub port: Option, + pub bin: bool, +} + +impl Launch { + pub fn sh(program: impl Into) -> Self { + Self { + program: program.into(), + args: Vec::new(), + cwd: None, + env: Vec::new(), + port: None, + bin: false, + } + } + + /// Sibling of the ship binary (`current_exe` parent / name). + pub fn bin(name: impl Into) -> Self { + Self { + program: name.into(), + args: Vec::new(), + cwd: None, + env: Vec::new(), + port: None, + bin: true, + } + } + + pub fn args(mut self, args: impl IntoIterator>) -> Self { + self.args.extend(args.into_iter().map(Into::into)); + self + } + + pub fn cwd(mut self, cwd: impl Into) -> Self { + self.cwd = Some(cwd.into()); + self + } + + pub fn env(mut self, key: impl Into, value: impl Into) -> Self { + self.env.push((key.into(), value.into())); + self + } + + pub fn publish(mut self, _url: Slot, port: u16) -> Self { + self.port = Some(port); + self + } + + pub fn listen(mut self, port: u16) -> Self { + self.port = Some(port); + self + } +} + +#[derive(Clone, Debug)] +pub struct Image { + pub name: String, + pub context: String, + pub dockerfile: Option, + pub port: Option, +} + +impl Image { + pub fn build(name: impl Into, context: impl Into) -> Self { + Self { + name: name.into(), + context: context.into(), + dockerfile: None, + port: None, + } + } + + pub fn dockerfile(mut self, dockerfile: impl Into) -> Self { + self.dockerfile = Some(dockerfile.into()); + self + } + + pub fn port(mut self, port: u16) -> Self { + self.port = Some(port); + self + } + + pub fn from_registry(reference: impl Into) -> Self { + Self { + name: reference.into(), + context: String::new(), + dockerfile: None, + port: None, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + struct Front; + impl Command for Front { + fn launch(&self) -> Launch { + Launch::sh("true") + } + } + impl Container for Front { + fn image(&self) -> Image { + Image::from_registry("nginx") + } + } + + struct Pg; + impl Container for Pg { + fn image(&self) -> Image { + Image::from_registry("postgres") + } + } + + struct App { + frontend: Front, + postgres: Pg, + } + struct Bound<'a, F, P> { + frontend: BoundComp<'a, Front, F>, + postgres: BoundComp<'a, Pg, P>, + } + + #[test] + fn legal_bind_compiles() { + let app = App { + frontend: Front, + postgres: Pg, + }; + let _b: Bound = Bound { + frontend: app.frontend.as_command(), + postgres: app.postgres.as_container(), + }; + } + + #[test] + fn jdbc_is_not_pg_url() { + fn take_pg(_: Ref>) {} + let pg = Slot::>::new().as_ref(); + take_pg(pg); + } +} diff --git a/harmony_app/src/error.rs b/harmony_app/src/error.rs index 0b84f76d..80878e8d 100644 --- a/harmony_app/src/error.rs +++ b/harmony_app/src/error.rs @@ -1,7 +1,5 @@ use thiserror::Error; -use crate::compose::ComposeError; - #[derive(Debug, Error)] pub enum ContextError { #[error("{action}: {source}")] @@ -45,8 +43,6 @@ pub enum AppError { Context(#[from] ContextError), #[error(transparent)] Image(#[from] ImageError), - #[error(transparent)] - Composition(#[from] ComposeError), #[error("{0}")] InvalidComposition(String), #[error("{0}")] diff --git a/harmony_app/src/lib.rs b/harmony_app/src/lib.rs index 2e7e4534..a1711fd9 100644 --- a/harmony_app/src/lib.rs +++ b/harmony_app/src/lib.rs @@ -1,55 +1,35 @@ -//! The Harmony **application** layer — the lifecycle of a deployable app -//! (build, publish, deploy, ship, status, logs) expressed once, -//! **independent of any UI** (ADR-026). +//! Application delivery. A [`HarmonyApp`] composes Scores; [`ship`]/[`deploy`]/ +//! [`status`]/[`logs`] interpret them. Independent of UI (`harmony_cli`). //! -//! 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. +//! Infrastructure orchestration belongs in `harmony` (Score / Topology / +//! Interpret). Config and secrets belong in `harmony_config` (OpenBao). //! -//! A [`Context`] defines a compiled deployment target. [`AppContext`] resolves -//! its credentials and runtime state. The verbs converge the same Scores for -//! local and production targets (ADR-026 §1/§10). +//! A [`Context`] is a compiled deploy target. [`AppContext`] resolves it +//! (`HARMONY_ZITADEL_KEY_JSON` → OpenBao → kubeconfig / Harbor). +//! Authoring DX (one component, one type, runtime as capability) lives in [`dx`]. //! -//! [`Application`] is a topology-neutral declaration model. K8sAnywhere is its -//! first adapter; topology-neutral does not imply every runtime is supported. +//! See `README.md` for the crate map and SSO flow. pub mod app; -pub mod application; -pub mod capabilities; -pub mod chart; -pub mod compose; pub mod context; -pub mod deploy; +pub mod dx; pub mod error; pub mod profile; pub mod publish; -pub mod score; pub mod tenant; pub use app::{ AppIdentity, DeployOptions, DeployReport, HarmonyApp, PodLogs, StatusReport, StepOutcome, - WorkloadStatus, deploy, deploy_with_options, interpret_scores, interpret_scores_with_progress, - logs, ship, ship_with_options, status, + WorkloadStatus, build, deploy, deploy_with_options, interpret_scores, + interpret_scores_with_progress, logs, publish, ship, ship_with_options, status, }; -pub use application::{ - Application, ApplicationValidationError, BucketRef, Command, Cpu, DatabaseRef, FileRef, - HealthCheck, Image, ImageBuild, ImageRef, ImageSource, LogicalEndpoint, ManagedBucket, - ManagedPostgres, ManagedResource, ManagedTls, ManagedZitadel, Memory, OidcRedirect, Port, - PortRef, Protocol, PublicEndpointRef, ReadinessIntent, ResourceIntent, RolloutIntent, - RolloutStrategy, Route, Service, ServiceRef, ValueRef, ZitadelRef, zitadel, -}; -pub use capabilities::{AppRef, Capability, Monitoring, Postgres, ZitadelAuth}; -pub use chart::{DeployConfig, SecretFileMount, cluster_issuer_for, service_image}; -pub use compose::ComposeApp; pub use context::{ AppContext, Context, ContextCatalog, ContextSpec, LocalContext, OpenBaoClusterAccess, RemoteContext, }; -pub use deploy::ComposeDeploy; +pub use dx::{ + AsCommand, AsContainer, AsRemote, BoundComp, Command, Container, Launch, Ref, Remote, Slot, +}; pub use error::{AppError, ContextError, ImageError}; pub use harmony::modules::tenant::ClusterAccess; pub use harmony::topology::tenant::{ResourceLimits, TenantConfig, TenantNetworkPolicy}; @@ -58,7 +38,6 @@ pub use publish::{ ImagePublisher, ImageRefs, ImageSpec, PublicationTopology, RegistryCredentials, RegistryPullCredentials, is_digest_pinned, }; -pub use score::{ComposeAppScore, PublicEndpoint}; pub use tenant::{ provision_application_tenant_on_context_with_progress, provision_application_tenant_with_kubeconfig, diff --git a/harmony_app/src/score.rs b/harmony_app/src/score.rs deleted file mode 100644 index 01e45ea4..00000000 --- a/harmony_app/src/score.rs +++ /dev/null @@ -1,284 +0,0 @@ -//! [`ComposeAppScore`] — converge a compose-imported app onto a cluster. -//! -//! Mirrors `FleetOperatorScore`: render a self-contained helm chart from -//! the imported compose at interpret time, `helm upgrade --install` via -//! [`HelmChartScore`], then layer the public Ingress on top. No -//! `ApplicationScore`, no ArgoCD — the same build/publish/deploy split the -//! fleet stack uses. - -use std::collections::BTreeMap; -use std::{str::FromStr, time::Duration}; - -use async_trait::async_trait; -use harmony_types::id::Id; -use k8s_openapi::api::core::v1::Secret; -use k8s_openapi::apimachinery::pkg::apis::meta::v1::ObjectMeta; -use log::info; -use serde::Serialize; - -use harmony::data::Version; -use harmony::interpret::{Interpret, InterpretError, InterpretName, InterpretStatus, Outcome}; -use harmony::inventory::Inventory; -use harmony::modules::helm::chart::{HelmChartScore, NonBlankString}; -use harmony::modules::k8s::ingress::K8sIngressScore; -use harmony::modules::k8s::resource::K8sResourceScore; -use harmony::score::Score; -use harmony::topology::{HelmCommand, K8sclient, Topology}; - -use crate::AppError; -use crate::chart::{DeployConfig, app_secret_name, build_chart}; -use crate::compose::ComposeApp; - -/// Expose one service through an Ingress (a deploy-only knob — the host -/// is never read from compose). -#[derive(Debug, Clone, Serialize)] -pub struct PublicEndpoint { - /// Compose service name to route to (its Service + port). - pub service: String, - pub port: u16, - pub host: String, - /// cert-manager ClusterIssuer for TLS; `None` serves plain HTTP - /// (the right default on issuer-less k3d). - pub cluster_issuer: Option, -} - -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, - cluster_issuer: Option, - ) -> Result { - let port = app - .service(service) - .ok_or_else(|| AppError::InvalidComposition(format!("no compose service '{service}'")))? - .ports - .first() - .ok_or_else(|| { - AppError::InvalidComposition(format!("service '{service}' exposes no port")) - })? - .container; - Ok(Self { - service: service.to_string(), - port, - host: host.into(), - cluster_issuer, - }) - } -} - -#[derive(Debug, Clone, Serialize)] -pub struct ComposeAppScore { - pub namespace: String, - pub release_name: String, - pub app: ComposeApp, - pub deploy: DeployConfig, - pub public_endpoint: Option, - /// App secrets (from OpenBao) applied as one Opaque `-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, - pub force_conflicts: bool, -} - -impl Score for ComposeAppScore { - fn create_interpret(&self) -> Box> { - Box::new(ComposeAppInterpret { - score: self.clone(), - }) - } - - fn name(&self) -> String { - format!("ComposeAppScore({})", self.deploy.app_name) - } -} - -#[derive(Debug)] -struct ComposeAppInterpret { - score: ComposeAppScore, -} - -async fn smoke_test_endpoint(endpoint: &PublicEndpoint) -> Result<(), InterpretError> { - let scheme = if endpoint.cluster_issuer.is_some() { - "https" - } else { - "http" - }; - let url = format!("{scheme}://{}", endpoint.host); - let client = reqwest::Client::builder() - .timeout(Duration::from_secs(10)) - .build() - .map_err(|e| InterpretError::new(format!("build endpoint smoke-test client: {e}")))?; - let mut last_error = "endpoint did not respond".to_string(); - tokio::time::timeout(Duration::from_secs(180), async { - loop { - match client.get(&url).send().await { - Ok(response) if response.status().is_success() => return, - Ok(response) => last_error = format!("HTTP {}", response.status()), - Err(error) => last_error = error.to_string(), - } - tokio::time::sleep(Duration::from_secs(2)).await; - } - }) - .await - .map_err(|_| { - InterpretError::new(format!( - "app endpoint {url} failed its smoke test after 180s: {last_error}" - )) - }) -} - -#[async_trait] -impl Interpret for ComposeAppInterpret { - async fn execute( - &self, - inventory: &Inventory, - topology: &T, - ) -> Result { - let s = &self.score; - let k8s = topology - .k8s_client() - .await - .map_err(|e| InterpretError::new(format!("Failed to get k8s client: {e}")))?; - k8s.ensure_namespace(&s.namespace).await.map_err(|e| { - InterpretError::new(format!("Failed to ensure namespace '{}': {e}", s.namespace)) - })?; - - // 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?; - } - - let tmp = - tempfile::tempdir().map_err(|e| InterpretError::new(format!("chart tempdir: {e}")))?; - let chart_path = build_chart(&s.app, &s.deploy, tmp.path()) - .map_err(|e| InterpretError::new(format!("render chart: {e}")))?; - let chart_path = chart_path - .to_str() - .ok_or_else(|| InterpretError::new("chart path not utf-8".to_string()))?; - info!( - "Installing '{}' from rendered chart {chart_path}", - s.release_name - ); - let helm_outcome = self - .install(inventory, topology, chart_path, &s.deploy.chart_version) - .await?; - - for service in &s.app.services { - k8s.wait_until_deployment_ready( - &service.name, - Some(&s.namespace), - Some(Duration::from_secs(180)), - ) - .await - .map_err(|e| { - InterpretError::new(format!( - "app deployment {}/{} not ready: {e}", - s.namespace, service.name - )) - })?; - } - - // Public Ingress, applied after the chart so the backing Service - // exists. Skipped when no endpoint is configured. - if let Some(ep) = &s.public_endpoint { - let to_fqdn = |v: &str| { - fqdn::FQDN::from_str(v) - .map_err(|e| InterpretError::new(format!("invalid fqdn '{v}': {e}"))) - }; - let scheme = if ep.cluster_issuer.is_some() { - "https" - } else { - "http" - }; - info!( - "Exposing service '{}' at {scheme}://{}", - ep.service, ep.host - ); - K8sIngressScore { - name: to_fqdn(&s.deploy.app_name)?, - host: to_fqdn(&ep.host)?, - backend_service: to_fqdn(&ep.service)?, - port: ep.port, - path: None, - path_type: None, - namespace: Some(to_fqdn(&s.namespace)?), - ingress_class_name: None, - cluster_issuer: ep.cluster_issuer.clone(), - } - .interpret(inventory, topology) - .await?; - smoke_test_endpoint(ep).await?; - } - - Ok(Outcome::success_with_details( - helm_outcome.message, - vec![format!("app: {}", s.deploy.app_name)], - )) - } - - fn get_name(&self) -> InterpretName { - InterpretName::Custom("ComposeAppInterpret") - } - - fn get_version(&self) -> Version { - Version::from("0.1.0").expect("static version literal") - } - - fn get_status(&self) -> InterpretStatus { - InterpretStatus::QUEUED - } - - fn get_children(&self) -> Vec { - vec![] - } -} - -impl ComposeAppInterpret { - async fn install( - &self, - inventory: &Inventory, - topology: &T, - chart_name: &str, - version: &str, - ) -> Result { - let s = &self.score; - HelmChartScore { - namespace: Some(non_blank(&s.namespace, "namespace")?), - release_name: non_blank(&s.release_name, "release_name")?, - chart_name: non_blank(chart_name, "chart_name")?, - chart_version: Some(non_blank(version, "chart_version")?), - values_overrides: None, - values_yaml: None, - create_namespace: false, - install_only: false, - force_conflicts: s.force_conflicts, - repository: None, - } - .interpret(inventory, topology) - .await - } -} - -fn non_blank(value: &str, field: &str) -> Result { - NonBlankString::from_str(value) - .map_err(|e| InterpretError::new(format!("{field} must be non-blank: {e}"))) -} diff --git a/harmony_app/tests/helm_render.rs b/harmony_app/tests/helm_render.rs deleted file mode 100644 index 5f598001..00000000 --- a/harmony_app/tests/helm_render.rs +++ /dev/null @@ -1,73 +0,0 @@ -//! Render the example app's chart from its real `docker-compose.yml` and -//! validate it with actual `helm` — the chart our importer emits must be -//! something helm accepts and that renders to valid k8s. Skips when helm -//! isn't on PATH so CI without helm stays green. - -use std::path::Path; -use std::process::Command; - -use harmony_app::{ComposeApp, DeployConfig, chart::build_chart}; - -fn helm_present() -> bool { - Command::new("helm") - .arg("version") - .output() - .map(|o| o.status.success()) - .unwrap_or(false) -} - -#[test] -fn generated_chart_passes_helm_lint_and_template() { - if !helm_present() { - eprintln!("helm not on PATH — skipping chart validation"); - return; - } - - let app_dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("../examples/compose_java_react/app"); - let app = ComposeApp::from_dir(&app_dir).expect("import compose"); - let cfg = DeployConfig { - app_name: "timesheet".to_string(), - chart_version: "0.1.0".to_string(), - registry: "hub.example".to_string(), - project: "harmony".to_string(), - version: "0.1.0".to_string(), - storage_class: Some("cephfs".to_string()), - volume_size: "1Gi".to_string(), - replicas: 2, - rolling: true, - extra_env: vec![], - secret_file_mounts: vec![], - image_pull_secrets: vec![], - images: Default::default(), - }; - - let tmp = tempfile::tempdir().unwrap(); - let chart = build_chart(&app, &cfg, tmp.path()).expect("render chart"); - let chart = chart.to_str().unwrap(); - - let lint = Command::new("helm").args(["lint", chart]).output().unwrap(); - assert!( - lint.status.success(), - "helm lint failed:\n{}", - String::from_utf8_lossy(&lint.stdout) - ); - - let out = Command::new("helm") - .args(["template", "timesheet", chart]) - .output() - .unwrap(); - assert!( - out.status.success(), - "helm template failed:\n{}", - String::from_utf8_lossy(&out.stderr) - ); - let rendered = String::from_utf8_lossy(&out.stdout); - // Both services + the RWX PVC must survive a real render. - assert!(rendered.contains("kind: Deployment"), "{rendered}"); - assert!(rendered.contains("kind: Service"), "{rendered}"); - assert!( - rendered.contains("kind: PersistentVolumeClaim"), - "{rendered}" - ); - assert!(rendered.contains("ReadWriteMany"), "{rendered}"); -} diff --git a/harmony_cli/Cargo.toml b/harmony_cli/Cargo.toml index 4d539e56..6faf99c6 100644 --- a/harmony_cli/Cargo.toml +++ b/harmony_cli/Cargo.toml @@ -2,8 +2,9 @@ name = "harmony_cli" edition = "2024" version.workspace = true -readme.workspace = true +readme = "README.md" license.workspace = true +description = "CLI front-end only: parse, prompt, render. Calls harmony_app / harmony. No orchestration." [features] default = ["tui"] diff --git a/harmony_cli/README.md b/harmony_cli/README.md index 370a14a4..8656cf5b 100644 --- a/harmony_cli/README.md +++ b/harmony_cli/README.md @@ -1,27 +1,27 @@ -## Quick demo +# harmony_cli -`cargo run -p example-tui` +UI only. Parse argv, prompt, render. Calls [`harmony_app`](../harmony_app) or +[`harmony`](../harmony). Does not compose Scores or talk to clusters itself. -This will launch Harmony's minimalist terminal ui which embeds a few demo scores. +## Two front-ends -Usage instructions will be displayed at the bottom of the TUI. - -`cargo run --bin example-cli -- --help` - -This is the harmony CLI, a minimal implementation - -The current help text: +**Application delivery** — `harmony_cli::app::app_main`: +```text +cargo run -p -- ship --context production --tag $SHA ``` -Usage: example-cli [OPTIONS] -Options: - -y, --yes Run score(s) or not - -f, --filter Filter query - -i, --interactive Run interactive TUI or not - -a, --all Run all or nth, defaults to all - -n, --number Run nth matching, zero indexed [default: 0] - -l, --list list scores, will also be affected by run filter - -h, --help Print help - -V, --version Print version``` +`--context` (or `HARMONY_CONTEXT`) is required. Verbs: `build`, `publish`, +`ship`, `deploy`, `status`, `logs`. `K8sAnywhereTopology` here is the control +plane (kube apply), not “the app runs on Kubernetes”. +**Generic Score runner** — `harmony_cli::run`: + +```text +cargo run --bin example-cli -- --help +``` + +List / filter / confirm / interpret Scores. Interactive TUI when built with +`tui` (`cargo run -p example-tui`). + +Architecture: [`harmony_app/README.md`](../harmony_app/README.md). diff --git a/harmony_cli/src/app.rs b/harmony_cli/src/app.rs index 56d53447..4177a02c 100644 --- a/harmony_cli/src/app.rs +++ b/harmony_cli/src/app.rs @@ -1,8 +1,5 @@ -//! `app_main` — the **CLI front-end** for the app lifecycle (ADR-026 §11 -//! `harmony app `). 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. +//! `app_main` — parse argv, resolve `--context`, call `harmony_app` verbs, +//! render the result. UI only: no Scores, no image policy, no cluster mutation. use std::path::PathBuf; @@ -47,16 +44,16 @@ enum Verb { }, /// Build + publish + deploy. Ship { - /// Pass `--force-conflicts` to the app chart's helm upgrade. + /// Passed through to the app. #[arg(long)] force_conflicts: bool, - /// Accepted for compatibility; deployment always waits for convergence. + /// Accepted for compatibility; Scores that wait do so in interpret. #[arg(long)] wait: bool, }, /// Converge the app's Scores (no build). Deploy { - /// Pass `--force-conflicts` to the app chart's helm upgrade. + /// Passed through to the app. #[arg(long)] force_conflicts: bool, /// Image as NAME=REFERENCE. The app defines accepted deploy references. @@ -76,8 +73,7 @@ enum Verb { } /// Entry point for a per-app deploy binary. -/// The CLI front-end drives `K8sAnywhereTopology`; the app layer itself is -/// topology-generic (a different front-end can drive another topology). +/// `K8sAnywhereTopology` is the control plane (kube apply), not a claim that the app runs on Kubernetes. pub async fn app_main + 'static>( app: A, contexts: ContextCatalog, @@ -102,10 +98,9 @@ pub async fn app_main + 'static>( }; match cli.verb { - Verb::Build => render_images(app.build(&ctx)?, cli.json), + Verb::Build => render_images(harmony_app::build(&app, &ctx)?, cli.json), Verb::Publish { images } => render_images( - app.publish(&ctx, &harmony_app::ImageRefs::new(images)) - .await?, + harmony_app::publish(&app, &ctx, &harmony_app::ImageRefs::new(images)).await?, cli.json, ), Verb::Ship { diff --git a/harmony_cli/src/lib.rs b/harmony_cli/src/lib.rs index 69b66e59..442e0395 100644 --- a/harmony_cli/src/lib.rs +++ b/harmony_cli/src/lib.rs @@ -1,3 +1,11 @@ +//! Front-ends only. +//! +//! - [`app::app_main`]: application-delivery UI (`ship` / `deploy` / …). +//! - [`run`]: generic Score-runner UI over Maestro. +//! +//! No Scores, no image policy, no cluster mutation except by calling +//! `harmony_app` / `harmony`. + use clap::Parser; use clap::builder::ArgPredicate; use harmony::instrumentation; diff --git a/harmony_config/Cargo.toml b/harmony_config/Cargo.toml index 1a0da565..ba16bb2f 100644 --- a/harmony_config/Cargo.toml +++ b/harmony_config/Cargo.toml @@ -4,6 +4,7 @@ edition = "2024" version.workspace = true readme.workspace = true license.workspace = true +description = "Typed config and secrets: schema in Rust, state in OpenBao (or local). SSO via sso.nationtech.io." [dependencies] harmony_secret = { version = "0.1.0", path = "../harmony_secret" } diff --git a/harmony_config/src/lib.rs b/harmony_config/src/lib.rs index 3e66bde4..c2f13086 100644 --- a/harmony_config/src/lib.rs +++ b/harmony_config/src/lib.rs @@ -1,3 +1,11 @@ +//! Typed config and secrets. Schema is a Rust struct; state lives in a store. +//! Default store is OpenBao. Identity is Zitadel (`sso.nationtech.io`). +//! +//! CI: `HARMONY_ZITADEL_KEY_JSON` → OpenBao JWT-bearer → load Harbor / kubeconfig. +//! Humans: device-code OIDC to the same store. +//! +//! Not orchestration (`harmony`) and not application delivery (`harmony_app`). + // Lets the derive macro emit `::harmony_config::…` paths that resolve both // inside this crate and in downstream consumers. extern crate self as harmony_config; -- 2.39.5 From 7ed471a6bf954eadc37f3964df28b8a3c6244530 Mon Sep 17 00:00:00 2001 From: Jean-Gabriel Gill-Couture Date: Tue, 8 Sep 2026 22:10:38 -0400 Subject: [PATCH 2/4] feat: read device pull secrets from the deployment subtree Agent get_raw is {prefix}/{deployment}/{image_pull_secret}. Grant policy is that subtree only. Drop DEVICE_PULL_SECRET_PATH. --- docs/guides/fleet-application-cd.md | 20 +++++++------- examples/fleet_device_enroll/src/main.rs | 2 +- fleet/harmony-fleet-agent/src/podman.rs | 8 +++--- harmony-reconciler-contracts/src/lib.rs | 4 +-- harmony-reconciler-contracts/src/podman.rs | 2 -- harmony_secret/src/deployment_grants.rs | 31 +++++++++------------- 6 files changed, 30 insertions(+), 37 deletions(-) diff --git a/docs/guides/fleet-application-cd.md b/docs/guides/fleet-application-cd.md index 68ab44b5..8f482d8f 100644 --- a/docs/guides/fleet-application-cd.md +++ b/docs/guides/fleet-application-cd.md @@ -52,9 +52,10 @@ hostname, repository, OpenBao URL, Zitadel URL, and OpenBao role. OpenBao holds: - `RegistryCredentials` in the context namespace, using a push-scoped registry robot account; - Kubernetes cluster access for the tenant namespace; -- application secrets; -- pull-only registry credentials under - `/registry/device-pull/`. +- application secrets and pull-only registry credentials under + `//` (same folder; the agent reads + `image_pull_secret` as JSON and `secret_env` as UTF-8 — it never injects + the pull robot into a container). Each device pull secret is JSON scoped to one registry authority. The authority includes the port when the registry uses one: @@ -86,12 +87,13 @@ CI and devices never share registry credentials. | CI publisher | Push to the application's repositories | Tenant CI machine identity | | Device puller | Pull only from the application's repositories | Device groups authorized for the Fleet Deployment | -The Rust manifest places only an `image_pull_secret` reference in each private -service. The operator adds exact referenced pull-secret paths to that -Deployment's OpenBao policy. The agent reads the credential only when Podman -needs to pull a missing image and sends it through Podman's registry-auth -header. It does not run `podman login`, write an auth file, or place credentials -in desired state, container environment, labels, or logs. +The Rust manifest places only an `image_pull_secret` **key name** in each +private service (no slashes). That key lives in the deployment subtree the +operator already grants (`//*`). The agent reads +the credential only when Podman needs to pull a missing image and sends it +through Podman's registry-auth header. It does not run `podman login`, write +an auth file, or place credentials in desired state, container environment, +labels, or logs. The registry should provide separate repository-scoped robot accounts for push and pull. For the first hosted deployments, those repositories can live under diff --git a/examples/fleet_device_enroll/src/main.rs b/examples/fleet_device_enroll/src/main.rs index 5ee75f96..52b6a9bf 100644 --- a/examples/fleet_device_enroll/src/main.rs +++ b/examples/fleet_device_enroll/src/main.rs @@ -124,7 +124,7 @@ struct Cli { #[arg(long, requires = "openbao_secret_prefix")] openbao_url: Option, - /// KV prefix containing this fleet's deployment and pull secrets. + /// KV prefix containing this fleet's deployment secrets (including image pull). #[arg(long, requires = "openbao_url")] openbao_secret_prefix: Option, diff --git a/fleet/harmony-fleet-agent/src/podman.rs b/fleet/harmony-fleet-agent/src/podman.rs index 8d6f5037..a2395fc1 100644 --- a/fleet/harmony-fleet-agent/src/podman.rs +++ b/fleet/harmony-fleet-agent/src/podman.rs @@ -4,9 +4,7 @@ use std::time::Duration; use anyhow::{Result, anyhow, bail}; use futures_util::StreamExt; -use harmony_reconciler_contracts::{ - DEVICE_PULL_SECRET_PATH, PodmanService, PodmanV0Score, RestartPolicy, VolumeMount, -}; +use harmony_reconciler_contracts::{PodmanService, PodmanV0Score, RestartPolicy, VolumeMount}; use harmony_secret::SecretStore; use oci_client::Reference; use podman_api::Podman; @@ -420,13 +418,13 @@ impl PodmanRuntime { service.name ) })?; - let namespace = format!("{}/{}", source.prefix, DEVICE_PULL_SECRET_PATH); + let namespace = format!("{}/{deployment}", source.prefix); let bytes = source .store .get_raw(&namespace, reference) .await .map_err(|error| { - anyhow!("fetching image pull secret '{reference}': {error}") + anyhow!("fetching image pull secret '{namespace}/{reference}': {error}") })?; Some( serde_json::from_slice::(&bytes).map_err(|error| { diff --git a/harmony-reconciler-contracts/src/lib.rs b/harmony-reconciler-contracts/src/lib.rs index 2dc597c0..cffb26e8 100644 --- a/harmony-reconciler-contracts/src/lib.rs +++ b/harmony-reconciler-contracts/src/lib.rs @@ -45,8 +45,8 @@ pub use kv::{ system_upgrade_status_key, }; pub use podman::{ - DEVICE_PULL_SECRET_PATH, EnvVar, PodmanService, PodmanV0Score, ReconcileScore, RestartPolicy, - SecretEnvVar, VolumeMount, validate_image_pull_secret_reference, + EnvVar, PodmanService, PodmanV0Score, ReconcileScore, RestartPolicy, SecretEnvVar, VolumeMount, + validate_image_pull_secret_reference, }; pub use status::{InventorySnapshot, Phase}; pub use system_upgrade::{ diff --git a/harmony-reconciler-contracts/src/podman.rs b/harmony-reconciler-contracts/src/podman.rs index 9ca5f14d..6a8793c7 100644 --- a/harmony-reconciler-contracts/src/podman.rs +++ b/harmony-reconciler-contracts/src/podman.rs @@ -2,8 +2,6 @@ use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; -pub const DEVICE_PULL_SECRET_PATH: &str = "registry/device-pull"; - #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] pub struct EnvVar { pub name: String, diff --git a/harmony_secret/src/deployment_grants.rs b/harmony_secret/src/deployment_grants.rs index ba497f1b..c1f6d004 100644 --- a/harmony_secret/src/deployment_grants.rs +++ b/harmony_secret/src/deployment_grants.rs @@ -9,8 +9,7 @@ use async_trait::async_trait; use harmony_reconciler_contracts::{ - DEVICE_PULL_SECRET_PATH, DeploymentName, DeploymentSecretGrants, SecretAccessError, - validate_image_pull_secret_reference, + DeploymentName, DeploymentSecretGrants, SecretAccessError, validate_image_pull_secret_reference, }; use crate::OpenBaoPolicyManager; @@ -66,20 +65,13 @@ impl OpenBaoDeploymentSecretGrants { validate_image_pull_secret_reference(reference) .map_err(|error| SecretAccessError::Backend(error.to_string()))?; } - let mut hcl = format!( + Ok(format!( r#"path "{kv}/data/{prefix}/{dep}/*" {{ capabilities = ["read"] }} path "{kv}/metadata/{prefix}/{dep}/*" {{ capabilities = ["read", "list"] }}"#, kv = self.kv_mount, prefix = self.secret_prefix, dep = deployment.as_str(), - ); - for reference in references { - hcl.push_str(&format!( - "\npath \"{}/data/{}/{}/{}\" {{ capabilities = [\"read\"] }}", - self.kv_mount, self.secret_prefix, DEVICE_PULL_SECRET_PATH, reference - )); - } - Ok(hcl) + )) } } @@ -116,19 +108,22 @@ mod tests { } #[test] - fn policy_sorts_deduplicates_and_limits_device_pull_secrets() { + fn policy_is_the_deployment_subtree_only() { let policy = grants() .policy_hcl( &DeploymentName::try_new("web").unwrap(), &["z-pull".into(), "a-pull".into(), "z-pull".into()], ) .unwrap(); - let a = policy.find("registry/device-pull/a-pull").unwrap(); - let z = policy.find("registry/device-pull/z-pull").unwrap(); - assert!(a < z); - assert_eq!(policy.matches("registry/device-pull/z-pull").count(), 1); - assert!(!policy.contains("registry/ci-push")); - assert!(!policy.contains("registry/device-pull/*")); + assert!(policy.contains(r#"path "secret/data/fleet/web/*" { capabilities = ["read"] }"#)); + assert!( + policy.contains( + r#"path "secret/metadata/fleet/web/*" { capabilities = ["read", "list"] }"# + ) + ); + assert!(!policy.contains("registry/device-pull")); + assert!(!policy.contains("a-pull")); + assert!(!policy.contains("z-pull")); } #[test] -- 2.39.5 From 8b8fdb56152d161857631d69d32ac4643ac49f66 Mon Sep 17 00:00:00 2001 From: Jean-Gabriel Gill-Couture Date: Tue, 8 Sep 2026 22:10:41 -0400 Subject: [PATCH 3/4] feat: publish fleet-agent with OpenBao RegistryCredentials When HARMONY_ZITADEL_KEY_JSON and --deploy-manifest are set, the tenant release-agent bin publishes via AppContext. Device patch stays in release.sh. REGISTRY_* remains the fallback. --- .../src/agent_artifact.rs | 99 +++++++++++++++++++ .../harmony-fleet-deploy/src/agent_release.rs | 39 ++++++++ .../src/bin/harmony-fleet-release.rs | 91 +++-------------- fleet/harmony-fleet-deploy/src/lib.rs | 4 + fleet/scripts/release.sh | 32 ++++-- 5 files changed, 178 insertions(+), 87 deletions(-) create mode 100644 fleet/harmony-fleet-deploy/src/agent_artifact.rs create mode 100644 fleet/harmony-fleet-deploy/src/agent_release.rs diff --git a/fleet/harmony-fleet-deploy/src/agent_artifact.rs b/fleet/harmony-fleet-deploy/src/agent_artifact.rs new file mode 100644 index 00000000..df551598 --- /dev/null +++ b/fleet/harmony-fleet-deploy/src/agent_artifact.rs @@ -0,0 +1,99 @@ +use std::collections::BTreeMap; +use std::path::Path; + +use anyhow::{Context, bail}; +use harmony_app::RegistryCredentials; +use harmony_reconciler_contracts::upgrade::{AGENT_OCI_ARTIFACT_TYPE, AGENT_OCI_LAYER_MEDIA_TYPE}; +use oci_client::annotations::ORG_OPENCONTAINERS_IMAGE_VERSION; +use oci_client::client::{Config, ImageLayer}; +use oci_client::errors::{OciDistributionError, OciErrorCode}; +use oci_client::manifest::OciImageManifest; +use oci_client::secrets::RegistryAuth; +use oci_client::{Client, Reference}; + +pub async fn publish_agent_artifact( + binary: &Path, + reference: &str, + version: &str, + credentials: &RegistryCredentials, +) -> anyhow::Result { + let reference = Reference::try_from(reference).context("invalid agent OCI reference")?; + if reference.tag().is_none() || reference.digest().is_some() { + bail!("agent publication requires a tag reference"); + } + let auth = RegistryAuth::Basic(credentials.username.clone(), credentials.token.clone()); + let client = Client::default(); + let layer = ImageLayer::new( + std::fs::read(binary) + .with_context(|| format!("reading agent binary {}", binary.display()))?, + AGENT_OCI_LAYER_MEDIA_TYPE.to_string(), + None, + ); + match client.pull_manifest(&reference, &auth).await { + Ok((oci_client::manifest::OciManifest::Image(manifest), digest)) + if manifest.artifact_type.as_deref() == Some(AGENT_OCI_ARTIFACT_TYPE) + && manifest + .annotations + .as_ref() + .and_then(|annotations| annotations.get(ORG_OPENCONTAINERS_IMAGE_VERSION)) + .map(String::as_str) + == Some(version) + && matches!(manifest.layers.as_slice(), [existing] + if existing.media_type == AGENT_OCI_LAYER_MEDIA_TYPE + && existing.digest == layer.sha256_digest()) => + { + return Ok(format!( + "oci://{}/{}@{digest}", + reference.registry(), + reference.repository() + )); + } + Ok(_) => bail!( + "agent artifact tag exists with different content: {}", + reference.whole() + ), + Err(error) if manifest_is_missing(&error) => {} + Err(error) => return Err(error).context("checking agent artifact tag"), + } + + let config = Config::new( + b"{}".to_vec(), + "application/vnd.oci.empty.v1+json".to_string(), + None, + ); + let mut annotations = BTreeMap::new(); + annotations.insert( + ORG_OPENCONTAINERS_IMAGE_VERSION.to_string(), + version.to_string(), + ); + let mut manifest = + OciImageManifest::build(std::slice::from_ref(&layer), &config, Some(annotations)); + manifest.artifact_type = Some(AGENT_OCI_ARTIFACT_TYPE.to_string()); + client + .push(&reference, &[layer], config, &auth, Some(manifest)) + .await + .context("publishing agent OCI artifact")?; + let (_, digest) = client + .pull_manifest(&reference, &auth) + .await + .context("resolving published agent manifest")?; + Ok(format!( + "oci://{}/{}@{digest}", + reference.registry(), + reference.repository() + )) +} + +fn manifest_is_missing(error: &OciDistributionError) -> bool { + matches!(error, OciDistributionError::ImageManifestNotFoundError(_)) + || matches!( + error, + OciDistributionError::RegistryError { envelope, .. } + if envelope.errors.iter().any(|error| matches!( + &error.code, + OciErrorCode::ManifestUnknown + | OciErrorCode::NameUnknown + | OciErrorCode::NotFound + )) + ) +} diff --git a/fleet/harmony-fleet-deploy/src/agent_release.rs b/fleet/harmony-fleet-deploy/src/agent_release.rs new file mode 100644 index 00000000..a5edae95 --- /dev/null +++ b/fleet/harmony-fleet-deploy/src/agent_release.rs @@ -0,0 +1,39 @@ +use std::path::PathBuf; + +use anyhow::{Context, bail}; +use clap::Parser; +use harmony_app::{AppContext, Context as DeployContext, RegistryCredentials}; + +use crate::publish_agent_artifact; + +#[derive(Parser, Debug)] +struct Args { + #[arg(long)] + binary: PathBuf, + #[arg(long)] + version: String, + #[arg(long)] + arch: String, +} + +pub async fn release_agent_cli(context: DeployContext) -> anyhow::Result<()> { + harmony_cli::cli_logger::init(); + let args = Args::parse(); + if args.arch != "x86_64" && args.arch != "aarch64" { + bail!("arch must be x86_64 or aarch64"); + } + let ctx = AppContext::resolve(&context, &args.version, None).await?; + if ctx.registry().is_none() { + bail!("agent release requires a remote context"); + } + let credentials: RegistryCredentials = ctx + .config_client() + .get() + .await + .context("loading RegistryCredentials from OpenBao")?; + let reference = format!("{}-{}", ctx.image("harmony-fleet-agent"), args.arch); + let artifact = + publish_agent_artifact(&args.binary, &reference, &args.version, &credentials).await?; + println!("agent={artifact}"); + Ok(()) +} diff --git a/fleet/harmony-fleet-deploy/src/bin/harmony-fleet-release.rs b/fleet/harmony-fleet-deploy/src/bin/harmony-fleet-release.rs index 136a8115..8272937e 100644 --- a/fleet/harmony-fleet-deploy/src/bin/harmony-fleet-release.rs +++ b/fleet/harmony-fleet-deploy/src/bin/harmony-fleet-release.rs @@ -1,15 +1,10 @@ -use std::collections::BTreeMap; use std::path::PathBuf; -use anyhow::{Context, bail}; +use anyhow::Context; use clap::{Parser, Subcommand}; use harmony_app::{PublicationTopology, publish::build_images}; -use harmony_fleet_deploy::FleetApp; -use harmony_reconciler_contracts::upgrade::{AGENT_OCI_ARTIFACT_TYPE, AGENT_OCI_LAYER_MEDIA_TYPE}; -use oci_client::annotations::ORG_OPENCONTAINERS_IMAGE_VERSION; -use oci_client::client::{Config, ImageLayer}; +use harmony_fleet_deploy::{FleetApp, publish_agent_artifact}; use oci_client::errors::{OciDistributionError, OciErrorCode}; -use oci_client::manifest::OciImageManifest; use oci_client::secrets::RegistryAuth; use oci_client::{Client, Reference}; @@ -46,7 +41,13 @@ async fn main() -> anyhow::Result<()> { binary, reference, version, - } => publish_agent(binary, &reference, &version).await, + } => { + let artifact = + publish_agent_artifact(&binary, &reference, &version, ®istry_credentials()?) + .await?; + println!("agent={artifact}"); + Ok(()) + } } } @@ -81,7 +82,9 @@ async fn publish_control(version: &str, push: bool) -> anyhow::Result<()> { return Ok(()); } if !existing.is_empty() { - bail!("control-plane release is incomplete; refusing to overwrite existing tags"); + anyhow::bail!( + "control-plane release is incomplete; refusing to overwrite existing tags" + ); } } let refs = build_images(&images, ®istry)?; @@ -97,76 +100,6 @@ async fn publish_control(version: &str, push: bool) -> anyhow::Result<()> { Ok(()) } -async fn publish_agent(binary: PathBuf, reference: &str, version: &str) -> anyhow::Result<()> { - let reference = Reference::try_from(reference).context("invalid agent OCI reference")?; - if reference.tag().is_none() || reference.digest().is_some() { - bail!("agent publication requires a tag reference"); - } - let auth = registry_auth()?; - let client = Client::default(); - let layer = ImageLayer::new( - std::fs::read(&binary) - .with_context(|| format!("reading agent binary {}", binary.display()))?, - AGENT_OCI_LAYER_MEDIA_TYPE.to_string(), - None, - ); - match client.pull_manifest(&reference, &auth).await { - Ok((oci_client::manifest::OciManifest::Image(manifest), digest)) - if manifest.artifact_type.as_deref() == Some(AGENT_OCI_ARTIFACT_TYPE) - && manifest - .annotations - .as_ref() - .and_then(|annotations| annotations.get(ORG_OPENCONTAINERS_IMAGE_VERSION)) - .map(String::as_str) - == Some(version) - && matches!(manifest.layers.as_slice(), [existing] - if existing.media_type == AGENT_OCI_LAYER_MEDIA_TYPE - && existing.digest == layer.sha256_digest()) => - { - println!( - "agent=oci://{}/{}@{digest}", - reference.registry(), - reference.repository() - ); - return Ok(()); - } - Ok(_) => bail!( - "agent artifact tag exists with different content: {}", - reference.whole() - ), - Err(error) if manifest_is_missing(&error) => {} - Err(error) => return Err(error).context("checking agent artifact tag"), - } - - let config = Config::new( - b"{}".to_vec(), - "application/vnd.oci.empty.v1+json".to_string(), - None, - ); - let mut annotations = BTreeMap::new(); - annotations.insert( - ORG_OPENCONTAINERS_IMAGE_VERSION.to_string(), - version.to_string(), - ); - let mut manifest = - OciImageManifest::build(std::slice::from_ref(&layer), &config, Some(annotations)); - manifest.artifact_type = Some(AGENT_OCI_ARTIFACT_TYPE.to_string()); - client - .push(&reference, &[layer], config, &auth, Some(manifest)) - .await - .context("publishing agent OCI artifact")?; - let (_, digest) = client - .pull_manifest(&reference, &auth) - .await - .context("resolving published agent manifest")?; - println!( - "agent=oci://{}/{}@{digest}", - reference.registry(), - reference.repository() - ); - Ok(()) -} - fn registry_auth() -> anyhow::Result { let credentials = registry_credentials()?; Ok(RegistryAuth::Basic(credentials.username, credentials.token)) diff --git a/fleet/harmony-fleet-deploy/src/lib.rs b/fleet/harmony-fleet-deploy/src/lib.rs index 7b3e3c8a..d5b6cf87 100644 --- a/fleet/harmony-fleet-deploy/src/lib.rs +++ b/fleet/harmony-fleet-deploy/src/lib.rs @@ -11,12 +11,16 @@ //! it does not own provider operations or a Fleet-wide aggregate Score. pub mod agent; +pub mod agent_artifact; +mod agent_release; mod app; mod deployment; mod device_setup; pub mod operator; pub use agent::{FleetAgentScore, PodTarget}; +pub use agent_artifact::publish_agent_artifact; +pub use agent_release::release_agent_cli; pub use app::{FleetApp, FleetCrdsApp, FleetTenantProvisionApp}; pub use deployment::FleetDeploymentScore; pub use device_setup::{ diff --git a/fleet/scripts/release.sh b/fleet/scripts/release.sh index fe1b0497..c92ec75a 100755 --- a/fleet/scripts/release.sh +++ b/fleet/scripts/release.sh @@ -32,9 +32,14 @@ Other: -h, --help Environment equivalents: - CONTROL_VERSION, AGENT_VERSION, AGENT_ARCH, FLEET_DEPLOY_MANIFEST, - FLEET_DEPLOY_BIN, FLEET_CONTEXT, FLEET_NAMESPACE, KUBECTL_CONTEXT, - REGISTRY_USER, REGISTRY_TOKEN, OPENBAO_TOKEN, RUST_LOG. + CONTROL_VERSION, AGENT_VERSION, AGENT_ARCH, FLEET_DEPLOY_MANIFEST, + FLEET_DEPLOY_BIN, FLEET_CONTEXT, FLEET_NAMESPACE, KUBECTL_CONTEXT, + HARMONY_ZITADEL_KEY_JSON, REGISTRY_USER, REGISTRY_TOKEN, OPENBAO_TOKEN, + RUST_LOG. + +Agent publication uses HARMONY_ZITADEL_KEY_JSON → OpenBao RegistryCredentials +when --deploy-manifest is set (tenant crate must provide bin release-agent). +Otherwise REGISTRY_USER / REGISTRY_TOKEN. Device patch stays kubectl. EOF } @@ -102,7 +107,11 @@ require_command sha256sum require_command stat [[ -z "$control_version" ]] || require_command docker -if [[ -n "$control_version" || -n "$agent_version" ]]; then +agent_via_openbao=0 +if [[ -n "$agent_version" && -n "${HARMONY_ZITADEL_KEY_JSON:-}" && -n "$deploy_manifest" ]]; then + agent_via_openbao=1 +fi +if [[ -n "$control_version" ]] || { [[ -n "$agent_version" ]] && ((agent_via_openbao == 0)); }; then : "${REGISTRY_USER:?REGISTRY_USER is required for publication}" : "${REGISTRY_TOKEN:?REGISTRY_TOKEN is required for publication}" fi @@ -145,11 +154,18 @@ callout_ref="" if [[ -n "$agent_version" ]]; then printf '==> Publishing %s\n' "$agent_tag_ref" agent_release_log="$tmp/agent-release.log" - REGISTRY_USER="$REGISTRY_USER" REGISTRY_TOKEN="$REGISTRY_TOKEN" \ + if ((agent_via_openbao == 1)); then RUST_LOG="${RUST_LOG:-info}" \ - cargo run --release -p harmony-fleet-deploy --bin harmony-fleet-release -- \ - agent --binary "$agent_binary" --reference "$agent_tag_ref" \ - --version "$agent_version" | tee "$agent_release_log" + cargo run --release --manifest-path "$deploy_manifest" --bin release-agent -- \ + --binary "$agent_binary" --version "$agent_version" --arch "$agent_arch" \ + | tee "$agent_release_log" + else + REGISTRY_USER="$REGISTRY_USER" REGISTRY_TOKEN="$REGISTRY_TOKEN" \ + RUST_LOG="${RUST_LOG:-info}" \ + cargo run --release -p harmony-fleet-deploy --bin harmony-fleet-release -- \ + agent --binary "$agent_binary" --reference "$agent_tag_ref" \ + --version "$agent_version" | tee "$agent_release_log" + fi agent_oci_ref="$(awk -F= '$1 == "agent" { value=$2 } END { print value }' "$agent_release_log")" [[ "$agent_oci_ref" =~ @sha256:[0-9a-f]{64}$ ]] || fail "agent digest missing from release output" fi -- 2.39.5 From c865ad2ceea8911d4c8b12e35fdeb6a6e254bea1 Mon Sep 17 00:00:00 2001 From: Jean-Gabriel Gill-Couture Date: Tue, 8 Sep 2026 22:14:28 -0400 Subject: [PATCH 4/4] refactor: grant policy does not re-validate pull secret names Aggregator already admits image_pull_secret references. Policy is the deployment subtree only. --- harmony_secret/src/deployment_grants.rs | 28 +++---------------------- 1 file changed, 3 insertions(+), 25 deletions(-) diff --git a/harmony_secret/src/deployment_grants.rs b/harmony_secret/src/deployment_grants.rs index c1f6d004..0464ea5c 100644 --- a/harmony_secret/src/deployment_grants.rs +++ b/harmony_secret/src/deployment_grants.rs @@ -7,12 +7,9 @@ //! for every member's existing tokens at request time — O(groups) //! writes per deployment change, regardless of fleet size (ADR-025). -use async_trait::async_trait; -use harmony_reconciler_contracts::{ - DeploymentName, DeploymentSecretGrants, SecretAccessError, validate_image_pull_secret_reference, -}; - use crate::OpenBaoPolicyManager; +use async_trait::async_trait; +use harmony_reconciler_contracts::{DeploymentName, DeploymentSecretGrants, SecretAccessError}; const JWT_AUTH_MOUNT: &str = "jwt"; @@ -56,15 +53,8 @@ impl OpenBaoDeploymentSecretGrants { fn policy_hcl( &self, deployment: &DeploymentName, - image_pull_secrets: &[String], + _image_pull_secrets: &[String], ) -> Result { - let mut references = image_pull_secrets.to_vec(); - references.sort(); - references.dedup(); - for reference in &references { - validate_image_pull_secret_reference(reference) - .map_err(|error| SecretAccessError::Backend(error.to_string()))?; - } Ok(format!( r#"path "{kv}/data/{prefix}/{dep}/*" {{ capabilities = ["read"] }} path "{kv}/metadata/{prefix}/{dep}/*" {{ capabilities = ["read", "list"] }}"#, @@ -125,16 +115,4 @@ mod tests { assert!(!policy.contains("a-pull")); assert!(!policy.contains("z-pull")); } - - #[test] - fn policy_rejects_unsafe_pull_secret_references() { - assert!( - grants() - .policy_hcl( - &DeploymentName::try_new("web").unwrap(), - &["../ci-push".into()], - ) - .is_err() - ); - } } -- 2.39.5