review/2026-09-08 #349
18
Cargo.lock
generated
18
Cargo.lock
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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/`.
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
114
docs/adr/029-application-components.md
Normal file
114
docs/adr/029-application-components.md
Normal file
@@ -0,0 +1,114 @@
|
||||
# Architecture Decision Record: Application Components — One File, One Type
|
||||
|
||||
Initial Author: Jean-Gabriel Gill-Couture
|
||||
|
||||
Initial Date: 2026-09-03
|
||||
|
||||
Last Updated Date: 2026-09-03
|
||||
|
||||
## Status
|
||||
|
||||
Proposed.
|
||||
|
||||
Supersedes the app-facing use of a single `Application` graph
|
||||
(`harmony_app::application`) as how an application is structured.
|
||||
Does not replace ADR-023 (framework Scores) or ADR-028 (typed Refs).
|
||||
|
||||
## Context
|
||||
|
||||
An application is a map of components someone can point at. The crate
|
||||
should *be* that map. Earlier shapes hid it: one god-object `Application`,
|
||||
a type per runtime (`FrontendCommand` / `FrontendContainer`), both image
|
||||
and command on one blob checked later, a typestate builder that encodes
|
||||
bind order (a DAG — and real systems cycle, ADR-028).
|
||||
|
||||
## Decision
|
||||
|
||||
**One component, one file, one type.** Opening `frontend.rs` is opening
|
||||
the frontend.
|
||||
|
||||
Runtimes are capabilities on that type, not sibling types:
|
||||
|
||||
- `Container` — image on a cluster
|
||||
- `Command` — a process (the command is a string in the impl)
|
||||
- `Remote` — this context does not run it; it only names where it lives
|
||||
|
||||
A database that is always on the cluster implements `Container` only. A
|
||||
frontend that is a container in one context and a process in another
|
||||
implements both. The struct is still `Frontend`.
|
||||
|
||||
A **context** binds each component to one runtime that component
|
||||
implements. That bind is an ordinary struct the author fills in
|
||||
(`frontend: app.frontend.as_command()`, …) — missing a field or calling
|
||||
`as_command()` on something with no `Command` impl does not compile. Not
|
||||
a macro: macros are cryptic if Rust is not your first language. Refs whose content depends on how it runs (public origin vs
|
||||
`localhost`) live on that runtime impl. Contexts are user-declared.
|
||||
Harmony may ship default bindings as examples, not as a closed enum.
|
||||
|
||||
Components exchange typed Refs (ADR-028). A Ref is desired state, valid
|
||||
before either side has run. Cycles are two Refs, not two builder stages.
|
||||
|
||||
A new **framework** Score exists only when Harmony should orchestrate a
|
||||
reusable capability. App glue is ordinary code in the component file.
|
||||
Org-specific Scores live in that org's crate, not in `harmony`.
|
||||
|
||||
Planned framework Score: OCI/image — Dockerfile in config, build/push
|
||||
from the context, export `ContainerRef` for `K8sDeploymentScore`. Until
|
||||
then, build/push may be procedural and still yield a `ContainerRef`.
|
||||
|
||||
## Principles
|
||||
|
||||
- **Feldman:** illegal states don't compile (`Command` on Postgres,
|
||||
Mailhog in Production, `CalloutRef` where `IssuerRef` is required).
|
||||
Runtime is not a boolean on a god object.
|
||||
- **Crichton:** file = mental object; you can reason about `frontend.rs`
|
||||
without the mesh. Notation matches: `impl Command for Frontend` *is*
|
||||
"we run it with a command".
|
||||
- **Parse, don't validate:** no `.image` + `.command` on one blob
|
||||
checked later. Missing `impl Command` is the parse.
|
||||
- **SOLID:** SRP = one file; ISP = Postgres doesn't see `Command`;
|
||||
DIP = others depend on refs; OCP = a new context is new impls on
|
||||
**context-dependent** components only; LSP = `Frontend` stays
|
||||
`Frontend`, refs stay `UrlRef`.
|
||||
|
||||
**Ugly we accept:** N named `impl Production` / `LocalDev` / `Staging`
|
||||
on dual-runtime files. Better than a mesh-wide enum or a typestate DAG.
|
||||
|
||||
**Ugly we refuse:** a second type per runtime; context as `if local`
|
||||
inside scores; builder order as types.
|
||||
|
||||
**Harmony's job:** `Container`, `Command`, `Remote`, `Unit`, scores, refs.
|
||||
|
||||
**App's job:** one file per component, impl the runtimes it actually
|
||||
has, impl the contexts that bind them.
|
||||
|
||||
A process launcher (`devbox run`, `cargo watch`, …) is a string inside
|
||||
`impl Command for Frontend`, never a Harmony type.
|
||||
|
||||
## Consequences
|
||||
|
||||
- The crate layout *is* the documentation — five components or tens.
|
||||
- Always-cluster components keep a single `Container` impl.
|
||||
- The `Application` builder was deleted. Apps implement `HarmonyApp` and
|
||||
compose Scores.
|
||||
- `--context` (ADR-026) selects bindings. It does not rename types.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Single `Application` declaration.** One lowering to Scores. The mental
|
||||
model sits behind a god object.
|
||||
|
||||
**Typestate builder.** Readable as a script; types encode order. Breaks
|
||||
cycles; Harmony would have to know the app's slots.
|
||||
|
||||
**Dual types per runtime.** The author no longer has one frontend.
|
||||
|
||||
**Closed context enum in Harmony.** Defaults become the product.
|
||||
Applications cannot declare their own tying-together.
|
||||
|
||||
## Additional Notes
|
||||
|
||||
Related: ADR-023, ADR-026, ADR-028.
|
||||
|
||||
First implementation: `harmony_app::dx` (`Command` / `Container` /
|
||||
`Remote`, `as_command` / `as_container`, `Slot` / `Ref`), `HostProcessScore`.
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
<!--toc:start-->
|
||||
|
||||
|
||||
@@ -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 `<app>-db` | `DATABASE_URL` ← `secretKeyRef(<app>-db-app, uri)` |
|
||||
| `Monitoring::new().alert(r)` | a downtime alert | a Prometheus alert rule + routes it to `r` | nothing (selector-scoped to the app's namespace) |
|
||||
|
||||
(`ZitadelAuth` for OIDC login is next.)
|
||||
|
||||
## How wiring works — by reference, never by value
|
||||
|
||||
A capability never passes a *value* through Harmony. Postgres' generated
|
||||
password lives only in the CNPG-created `<app>-db-app` Secret; the capability
|
||||
injects an env var that **references** it (`secretKeyRef`), resolved in-cluster
|
||||
at runtime. This is what keeps the rendered chart publishable (it contains a
|
||||
reference, not a secret) and is why the app layer needs no typed Score outputs
|
||||
today (ADR-026). The trade: wiring must be expressible as a stable k8s
|
||||
reference (service DNS, Secret/ConfigMap key) — true for k8s-native add-ons.
|
||||
|
||||
## Writing your own capability
|
||||
|
||||
A capability implements one small trait
|
||||
(`harmony_app::capabilities::Capability`):
|
||||
|
||||
```rust
|
||||
pub trait Capability {
|
||||
/// What this capability deploys (a database, an operator, …).
|
||||
fn scores(&self, app: &AppRef) -> Vec<Box<dyn Score<K8sAnywhereTopology>>> { vec![] }
|
||||
/// Env injected into the app's containers, wired by reference.
|
||||
fn env(&self, app: &AppRef) -> Vec<EnvVar> { vec![] }
|
||||
async fn scores(&self, ctx: &AppContext, images: &ImageRefs) -> Result<Vec<Box<dyn Score<T>>>, 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 `<app>-db`). Implement either method or
|
||||
both, then `app.with(MyThing)`.
|
||||
|
||||
### Recipe (e.g. a `Redis` capability)
|
||||
|
||||
1. **Find the backing Score.** Capabilities *compose* existing Scores — they
|
||||
do not hand-roll manifests (ADR-023). Look in `harmony::modules::*` for one
|
||||
(Postgres composes `harmony::modules::postgresql::K8sPostgreSQLScore`). If
|
||||
none exists, write the Score first ([Writing a Score](./writing-a-score.md)).
|
||||
2. **Return it from `scores()`**, naming resources off `app` (e.g. cluster
|
||||
`format!("{}-redis", app.name)`).
|
||||
3. **Wire it by reference in `env()`.** Determine what stable reference the
|
||||
Score exposes — a Service DNS name, or a key in a Secret/ConfigMap it
|
||||
creates — and inject an `EnvVar` pointing at it. (Postgres references CNPG's
|
||||
`<cluster>-app` secret's `uri` key.) The convention between a Score's output
|
||||
and a capability's `env()` is currently by agreement, not type-checked —
|
||||
read the Score to confirm the names it produces.
|
||||
4. **Receivers and other inputs** (e.g. an alert `DiscordWebhook`) come from
|
||||
`harmony::modules::*` too — `Monitoring` takes a
|
||||
`harmony::modules::monitoring::alert_channel::discord_alert_channel::DiscordWebhook`.
|
||||
|
||||
`Postgres` and `Monitoring` in `harmony_app/src/capabilities.rs` are the two
|
||||
worked examples to copy.
|
||||
|
||||
## Deprecated: the old `ApplicationFeature` model
|
||||
|
||||
The previous approach — `ApplicationScore { features: vec![Monitoring, …] }`
|
||||
over `ApplicationFeature` — is **deprecated** in favor of this one. It coupled
|
||||
app delivery to a fixed feature menu and the (cancelled) ArgoCD path, and
|
||||
wasn't composable with plain Scores. Migrate `ApplicationScore` →
|
||||
`ComposeDeploy` (or your own `HarmonyApp`) + `.with(...)` capabilities.
|
||||
See [Application CLI](./application-cli.md) for `ship` / `deploy` / `status` /
|
||||
`logs`. Authoring DX (one component, one type) is ADR-029.
|
||||
|
||||
@@ -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 <scope> <verb>`.** A small fixed set of scope nouns —
|
||||
|
||||
@@ -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
|
||||
`<device-prefix>/registry/device-pull/<reference>`.
|
||||
- application secrets and pull-only registry credentials under
|
||||
`<device-prefix>/<deployment>/<key>` (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 (`<device-prefix>/<deployment>/*`). 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
|
||||
|
||||
@@ -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::<T, command>()`, origin as a Ref |
|
||||
| `dx_slot_secret` | `example-dx-slot-secret` | `Slot` / `Secret<Jdbc>`, exhaustive `bind!` |
|
||||
| `dx_accepts_kind` | `example-dx-accepts-kind` | `Accepts` matrix, `Inhabit<Dev>` / `Live` |
|
||||
| `dx_guide_ship` | `example-dx-guide-ship` | Tutorial inventory, `Runtime` enum |
|
||||
|
||||
```bash
|
||||
cargo check -p example-dx-bind-origin -p example-dx-slot-secret \
|
||||
-p example-dx-accepts-kind -p example-dx-guide-ship
|
||||
```
|
||||
|
||||
## Notes on Private Infrastructure
|
||||
|
||||
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.
|
||||
|
||||
@@ -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"] }
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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<K8sAnywhereTopology> for Timesheet {
|
||||
fn identity(&self, ctx: &AppContext) -> AppIdentity {
|
||||
AppIdentity {
|
||||
name: "timesheet".into(),
|
||||
namespace: ctx.namespace().into(),
|
||||
}
|
||||
}
|
||||
|
||||
fn images(&self, ctx: &AppContext) -> Result<Vec<ImageSpec>, AppError> {
|
||||
let root = format!("{}/app", env!("CARGO_MANIFEST_DIR"));
|
||||
Ok(["backend", "frontend"]
|
||||
.map(|name| ImageSpec {
|
||||
name: name.into(),
|
||||
image: ctx.image(name),
|
||||
context: format!("{root}/{name}").into(),
|
||||
dockerfile: format!("{root}/{name}/Dockerfile").into(),
|
||||
platform: None,
|
||||
build_args: Vec::new(),
|
||||
})
|
||||
.into())
|
||||
}
|
||||
|
||||
async fn scores(
|
||||
&self,
|
||||
ctx: &AppContext,
|
||||
images: &ImageRefs,
|
||||
) -> Result<Vec<Box<dyn Score<K8sAnywhereTopology>>>, AppError> {
|
||||
let namespace = ctx.namespace();
|
||||
let postgres = K8sPostgreSQLScore::new(namespace).cluster_name("timesheet-db");
|
||||
let backend = K8sDeploymentScore {
|
||||
name: "backend".into(),
|
||||
image: images.require("backend")?.into(),
|
||||
namespace: Some(namespace.into()),
|
||||
env_vars: json!([{
|
||||
"name": "DATABASE_URL",
|
||||
"valueFrom": {
|
||||
"secretKeyRef": { "name": "timesheet-db-app", "key": "uri" }
|
||||
}
|
||||
}]),
|
||||
};
|
||||
let frontend = K8sDeploymentScore {
|
||||
name: "frontend".into(),
|
||||
image: images.require("frontend")?.into(),
|
||||
namespace: Some(namespace.into()),
|
||||
env_vars: json!([{
|
||||
"name": "BACKEND_URL",
|
||||
"value": "http://backend:8080"
|
||||
}]),
|
||||
};
|
||||
Ok(vec![
|
||||
Box::new(postgres),
|
||||
Box::new(backend),
|
||||
Box::new(frontend),
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
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()?,
|
||||
|
||||
12
examples/dx_accepts_kind/Cargo.toml
Normal file
12
examples/dx_accepts_kind/Cargo.toml
Normal file
@@ -0,0 +1,12 @@
|
||||
[package]
|
||||
name = "example-dx-accepts-kind"
|
||||
edition = "2024"
|
||||
version.workspace = true
|
||||
readme.workspace = true
|
||||
license.workspace = true
|
||||
description = "DX experiment (ADR-029): Accepts matrix, kinded ports, Dev/Live atmosphere."
|
||||
publish = false
|
||||
|
||||
[[bin]]
|
||||
name = "dx-accepts-kind"
|
||||
path = "src/main.rs"
|
||||
9
examples/dx_accepts_kind/README.md
Normal file
9
examples/dx_accepts_kind/README.md
Normal file
@@ -0,0 +1,9 @@
|
||||
# DX: Accepts × atmosphere
|
||||
|
||||
Type-theory experiment. Empty matrix cells do not compile.
|
||||
`bind!(Postgres => Exec)` and `bind_in!(Live, Mailhog => Image)` are left
|
||||
commented. Backend ports are `Ref<Kind>`, not sibling names.
|
||||
|
||||
```bash
|
||||
cargo check -p example-dx-accepts-kind
|
||||
```
|
||||
27
examples/dx_accepts_kind/src/backend.rs
Normal file
27
examples/dx_accepts_kind/src/backend.rs
Normal file
@@ -0,0 +1,27 @@
|
||||
use crate::harmony::{
|
||||
Accepts, Bin, Component, Dev, Edge, Exec, Idp, Image, Inhabit, Live, Obj, Ref, Rel, Smtp,
|
||||
};
|
||||
|
||||
pub struct Backend;
|
||||
|
||||
impl Component for Backend {
|
||||
type Kind = Bin;
|
||||
}
|
||||
|
||||
impl Accepts<Exec> for Backend {}
|
||||
impl Accepts<Image> for Backend {}
|
||||
impl Inhabit<Dev> for Backend {}
|
||||
impl Inhabit<Live> for Backend {}
|
||||
|
||||
impl Backend {
|
||||
pub fn wire(
|
||||
edge: Ref<Edge>,
|
||||
rel: Ref<Rel>,
|
||||
idp: Ref<Idp>,
|
||||
obj: Ref<Obj>,
|
||||
smtp: Ref<Smtp>,
|
||||
) -> Self {
|
||||
let _ = (edge, rel, idp, obj, smtp);
|
||||
Self
|
||||
}
|
||||
}
|
||||
12
examples/dx_accepts_kind/src/bucket.rs
Normal file
12
examples/dx_accepts_kind/src/bucket.rs
Normal file
@@ -0,0 +1,12 @@
|
||||
use crate::harmony::{Accepts, Component, Dev, Image, Inhabit, Live, Managed, Obj};
|
||||
|
||||
pub struct Bucket;
|
||||
|
||||
impl Component for Bucket {
|
||||
type Kind = Obj;
|
||||
}
|
||||
|
||||
impl Accepts<Image> for Bucket {}
|
||||
impl Accepts<Managed> for Bucket {}
|
||||
impl Inhabit<Dev> for Bucket {}
|
||||
impl Inhabit<Live> for Bucket {}
|
||||
22
examples/dx_accepts_kind/src/frontend.rs
Normal file
22
examples/dx_accepts_kind/src/frontend.rs
Normal file
@@ -0,0 +1,22 @@
|
||||
use crate::harmony::{
|
||||
Accepts, Bin, Cdn, Component, Dev, Edge, Exec, Idp, Image, Inhabit, Live, Ref,
|
||||
};
|
||||
|
||||
pub struct Frontend;
|
||||
|
||||
impl Component for Frontend {
|
||||
type Kind = Edge;
|
||||
}
|
||||
|
||||
impl Accepts<Exec> for Frontend {}
|
||||
impl Accepts<Image> for Frontend {}
|
||||
impl Accepts<Cdn> for Frontend {}
|
||||
impl Inhabit<Dev> for Frontend {}
|
||||
impl Inhabit<Live> for Frontend {}
|
||||
|
||||
impl Frontend {
|
||||
pub fn wire(api: Ref<Bin>, idp: Ref<Idp>) -> Self {
|
||||
let _ = (api, idp);
|
||||
Self
|
||||
}
|
||||
}
|
||||
82
examples/dx_accepts_kind/src/harmony.rs
Normal file
82
examples/dx_accepts_kind/src/harmony.rs
Normal file
@@ -0,0 +1,82 @@
|
||||
//! Framework stand-in. Not `harmony_app`.
|
||||
//!
|
||||
//! Capability: `Accepts<Exec | Image | Managed | Cdn>`.
|
||||
//! Atmosphere: `Inhabit<Dev>` / `Inhabit<Live>` — no blanket.
|
||||
//! Kind on `Component`; ports are `Ref<K>`.
|
||||
|
||||
#![allow(dead_code)]
|
||||
|
||||
use std::marker::PhantomData;
|
||||
|
||||
pub trait Kind {}
|
||||
|
||||
pub struct Edge;
|
||||
impl Kind for Edge {}
|
||||
pub struct Bin;
|
||||
impl Kind for Bin {}
|
||||
pub struct Rel;
|
||||
impl Kind for Rel {}
|
||||
pub struct Idp;
|
||||
impl Kind for Idp {}
|
||||
pub struct Obj;
|
||||
impl Kind for Obj {}
|
||||
pub struct Smtp;
|
||||
impl Kind for Smtp {}
|
||||
|
||||
pub struct Ref<K: Kind>(PhantomData<K>);
|
||||
|
||||
impl<K: Kind> Copy for Ref<K> {}
|
||||
impl<K: Kind> Clone for Ref<K> {
|
||||
fn clone(&self) -> Self {
|
||||
*self
|
||||
}
|
||||
}
|
||||
impl<K: Kind> Ref<K> {
|
||||
pub const fn new() -> Self {
|
||||
Self(PhantomData)
|
||||
}
|
||||
}
|
||||
|
||||
pub trait Runtime {}
|
||||
pub struct Exec;
|
||||
impl Runtime for Exec {}
|
||||
pub struct Image;
|
||||
impl Runtime for Image {}
|
||||
pub struct Managed;
|
||||
impl Runtime for Managed {}
|
||||
pub struct Cdn;
|
||||
impl Runtime for Cdn {}
|
||||
|
||||
pub trait Accepts<R: Runtime> {}
|
||||
|
||||
pub trait Component {
|
||||
type Kind: Kind;
|
||||
|
||||
fn as_ref() -> Ref<Self::Kind> {
|
||||
Ref::new()
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Dev;
|
||||
pub struct Live;
|
||||
|
||||
/// Who may inhabit this component. No blanket: Mailhog omits `Live`.
|
||||
pub trait Inhabit<A> {}
|
||||
|
||||
#[macro_export]
|
||||
macro_rules! bind {
|
||||
($comp:ty => $rt:ty) => {{
|
||||
fn _accepts<T: $crate::harmony::Accepts<R>, R: $crate::harmony::Runtime>() {}
|
||||
_accepts::<$comp, $rt>();
|
||||
}};
|
||||
}
|
||||
|
||||
#[macro_export]
|
||||
macro_rules! bind_in {
|
||||
($atm:ty, $comp:ty => $rt:ty) => {{
|
||||
fn _accepts<T: $crate::harmony::Accepts<R>, R: $crate::harmony::Runtime>() {}
|
||||
fn _inhabit<T: $crate::harmony::Inhabit<A>, A>() {}
|
||||
_accepts::<$comp, $rt>();
|
||||
_inhabit::<$comp, $atm>();
|
||||
}};
|
||||
}
|
||||
28
examples/dx_accepts_kind/src/localdev.rs
Normal file
28
examples/dx_accepts_kind/src/localdev.rs
Normal file
@@ -0,0 +1,28 @@
|
||||
use crate::backend::Backend;
|
||||
use crate::bucket::Bucket;
|
||||
use crate::frontend::Frontend;
|
||||
use crate::harmony::{Component, Dev, Exec, Image};
|
||||
use crate::mailhog::Mailhog;
|
||||
use crate::postgres::Postgres;
|
||||
use crate::zitadel::Zitadel;
|
||||
use crate::bind_in;
|
||||
|
||||
pub fn bind() {
|
||||
bind_in!(Dev, Frontend => Exec);
|
||||
bind_in!(Dev, Backend => Exec);
|
||||
bind_in!(Dev, Postgres => Image);
|
||||
bind_in!(Dev, Zitadel => Image);
|
||||
bind_in!(Dev, Bucket => Image);
|
||||
bind_in!(Dev, Mailhog => Image);
|
||||
// crate::bind!(Postgres => Exec); // E0277: Postgres: !Accepts<Exec>
|
||||
|
||||
let _ = Frontend::wire(Backend::as_ref(), Zitadel::as_ref());
|
||||
let _ = Backend::wire(
|
||||
Frontend::as_ref(),
|
||||
Postgres::as_ref(),
|
||||
Zitadel::as_ref(),
|
||||
Bucket::as_ref(),
|
||||
Mailhog::as_ref(),
|
||||
);
|
||||
let _ = Zitadel::wire(Frontend::as_ref());
|
||||
}
|
||||
10
examples/dx_accepts_kind/src/mailhog.rs
Normal file
10
examples/dx_accepts_kind/src/mailhog.rs
Normal file
@@ -0,0 +1,10 @@
|
||||
use crate::harmony::{Accepts, Component, Dev, Image, Inhabit, Smtp};
|
||||
|
||||
pub struct Mailhog;
|
||||
|
||||
impl Component for Mailhog {
|
||||
type Kind = Smtp;
|
||||
}
|
||||
|
||||
impl Accepts<Image> for Mailhog {}
|
||||
impl Inhabit<Dev> for Mailhog {}
|
||||
15
examples/dx_accepts_kind/src/main.rs
Normal file
15
examples/dx_accepts_kind/src/main.rs
Normal file
@@ -0,0 +1,15 @@
|
||||
mod backend;
|
||||
mod bucket;
|
||||
mod frontend;
|
||||
mod harmony;
|
||||
mod localdev;
|
||||
mod mailhog;
|
||||
mod postgres;
|
||||
mod production;
|
||||
mod ses;
|
||||
mod zitadel;
|
||||
|
||||
fn main() {
|
||||
localdev::bind();
|
||||
production::bind();
|
||||
}
|
||||
12
examples/dx_accepts_kind/src/postgres.rs
Normal file
12
examples/dx_accepts_kind/src/postgres.rs
Normal file
@@ -0,0 +1,12 @@
|
||||
use crate::harmony::{Accepts, Component, Dev, Image, Inhabit, Live, Managed, Rel};
|
||||
|
||||
pub struct Postgres;
|
||||
|
||||
impl Component for Postgres {
|
||||
type Kind = Rel;
|
||||
}
|
||||
|
||||
impl Accepts<Image> for Postgres {}
|
||||
impl Accepts<Managed> for Postgres {}
|
||||
impl Inhabit<Dev> for Postgres {}
|
||||
impl Inhabit<Live> for Postgres {}
|
||||
29
examples/dx_accepts_kind/src/production.rs
Normal file
29
examples/dx_accepts_kind/src/production.rs
Normal file
@@ -0,0 +1,29 @@
|
||||
use crate::backend::Backend;
|
||||
use crate::bucket::Bucket;
|
||||
use crate::frontend::Frontend;
|
||||
use crate::harmony::{Cdn, Component, Image, Live, Managed};
|
||||
use crate::postgres::Postgres;
|
||||
use crate::ses::Ses;
|
||||
use crate::zitadel::Zitadel;
|
||||
use crate::bind_in;
|
||||
|
||||
pub fn bind() {
|
||||
bind_in!(Live, Frontend => Cdn);
|
||||
bind_in!(Live, Backend => Image);
|
||||
bind_in!(Live, Postgres => Managed);
|
||||
bind_in!(Live, Zitadel => Managed);
|
||||
bind_in!(Live, Bucket => Managed);
|
||||
bind_in!(Live, Ses => Managed);
|
||||
// bind_in!(Live, crate::mailhog::Mailhog => Image); // !Inhabit<Live>
|
||||
// crate::bind!(Postgres => crate::harmony::Exec); // !Accepts<Exec>
|
||||
|
||||
let _ = Frontend::wire(Backend::as_ref(), Zitadel::as_ref());
|
||||
let _ = Backend::wire(
|
||||
Frontend::as_ref(),
|
||||
Postgres::as_ref(),
|
||||
Zitadel::as_ref(),
|
||||
Bucket::as_ref(),
|
||||
Ses::as_ref(),
|
||||
);
|
||||
let _ = Zitadel::wire(Frontend::as_ref());
|
||||
}
|
||||
10
examples/dx_accepts_kind/src/ses.rs
Normal file
10
examples/dx_accepts_kind/src/ses.rs
Normal file
@@ -0,0 +1,10 @@
|
||||
use crate::harmony::{Accepts, Component, Inhabit, Live, Managed, Smtp};
|
||||
|
||||
pub struct Ses;
|
||||
|
||||
impl Component for Ses {
|
||||
type Kind = Smtp;
|
||||
}
|
||||
|
||||
impl Accepts<Managed> for Ses {}
|
||||
impl Inhabit<Live> for Ses {}
|
||||
19
examples/dx_accepts_kind/src/zitadel.rs
Normal file
19
examples/dx_accepts_kind/src/zitadel.rs
Normal file
@@ -0,0 +1,19 @@
|
||||
use crate::harmony::{Accepts, Component, Dev, Edge, Idp, Image, Inhabit, Live, Managed, Ref};
|
||||
|
||||
pub struct Zitadel;
|
||||
|
||||
impl Component for Zitadel {
|
||||
type Kind = Idp;
|
||||
}
|
||||
|
||||
impl Accepts<Image> for Zitadel {}
|
||||
impl Accepts<Managed> for Zitadel {}
|
||||
impl Inhabit<Dev> for Zitadel {}
|
||||
impl Inhabit<Live> for Zitadel {}
|
||||
|
||||
impl Zitadel {
|
||||
pub fn wire(edge: Ref<Edge>) -> Self {
|
||||
let _ = edge;
|
||||
Self
|
||||
}
|
||||
}
|
||||
12
examples/dx_bind_origin/Cargo.toml
Normal file
12
examples/dx_bind_origin/Cargo.toml
Normal file
@@ -0,0 +1,12 @@
|
||||
[package]
|
||||
name = "example-dx-bind-origin"
|
||||
edition = "2024"
|
||||
version.workspace = true
|
||||
readme.workspace = true
|
||||
license.workspace = true
|
||||
description = "DX experiment (ADR-029): bind::<T, command>(), origin as a Ref. Not a shippable deploy."
|
||||
publish = false
|
||||
|
||||
[[bin]]
|
||||
name = "dx-bind-origin"
|
||||
path = "src/main.rs"
|
||||
10
examples/dx_bind_origin/README.md
Normal file
10
examples/dx_bind_origin/README.md
Normal file
@@ -0,0 +1,10 @@
|
||||
# DX: bind + origin Ref
|
||||
|
||||
Frontend-shaped. Opening `frontend.rs` is the frontend. Contexts bind
|
||||
command vs container; origin is a Ref. Vite proxy is Command-only.
|
||||
|
||||
```bash
|
||||
cargo check -p example-dx-bind-origin
|
||||
```
|
||||
|
||||
`.bind::<Postgres, command>()` does not compile (uncomment in `localdev.rs`).
|
||||
43
examples/dx_bind_origin/src/backend.rs
Normal file
43
examples/dx_bind_origin/src/backend.rs
Normal file
@@ -0,0 +1,43 @@
|
||||
use crate::bucket::Bucket;
|
||||
use crate::harmony::{Command, Container, Image, Launch, Origin, Ref};
|
||||
use crate::postgres::Postgres;
|
||||
|
||||
pub struct Backend {
|
||||
origin: Ref<Origin>,
|
||||
issuer: Ref<Origin>,
|
||||
db: Ref<Postgres>,
|
||||
files: Ref<Bucket>,
|
||||
}
|
||||
|
||||
impl Backend {
|
||||
pub fn new(
|
||||
origin: Ref<Origin>,
|
||||
issuer: Ref<Origin>,
|
||||
db: Ref<Postgres>,
|
||||
files: Ref<Bucket>,
|
||||
) -> Self {
|
||||
Self {
|
||||
origin,
|
||||
issuer,
|
||||
db,
|
||||
files,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Command for Backend {
|
||||
fn launch(&self) -> Launch {
|
||||
Launch::sh("npm run start")
|
||||
.cwd("api")
|
||||
.env("FRONTEND_ORIGIN", self.origin)
|
||||
.env("OIDC_ISSUER", self.issuer)
|
||||
.env("DATABASE_URL", self.db)
|
||||
.env("S3_ENDPOINT", self.files)
|
||||
}
|
||||
}
|
||||
|
||||
impl Container for Backend {
|
||||
fn image(&self) -> Image {
|
||||
Image::build("api")
|
||||
}
|
||||
}
|
||||
19
examples/dx_bind_origin/src/bucket.rs
Normal file
19
examples/dx_bind_origin/src/bucket.rs
Normal file
@@ -0,0 +1,19 @@
|
||||
use crate::harmony::{Container, Image, Origin, Ref, Remote};
|
||||
|
||||
pub struct Bucket;
|
||||
|
||||
impl Bucket {
|
||||
pub fn endpoint(&self) -> Ref<Self> {
|
||||
Ref::new()
|
||||
}
|
||||
|
||||
pub fn cors(&self, _: Ref<Origin>) {}
|
||||
}
|
||||
|
||||
impl Container for Bucket {
|
||||
fn image(&self) -> Image {
|
||||
Image::build("s3")
|
||||
}
|
||||
}
|
||||
|
||||
impl Remote for Bucket {}
|
||||
31
examples/dx_bind_origin/src/frontend.rs
Normal file
31
examples/dx_bind_origin/src/frontend.rs
Normal file
@@ -0,0 +1,31 @@
|
||||
use crate::harmony::{Command, Container, Image, Launch, Origin, Ref};
|
||||
|
||||
pub struct Frontend {
|
||||
issuer: Ref<Origin>,
|
||||
}
|
||||
|
||||
impl Frontend {
|
||||
pub fn new(issuer: Ref<Origin>) -> Self {
|
||||
Self { issuer }
|
||||
}
|
||||
|
||||
pub fn origin(&self) -> Ref<Origin> {
|
||||
Ref::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl Command for Frontend {
|
||||
fn launch(&self) -> Launch {
|
||||
Launch::sh("npm run dev")
|
||||
.cwd("web")
|
||||
.env("VITE_OIDC_ISSUER", self.issuer)
|
||||
.env("VITE_PROXY", "/v1")
|
||||
.publish(self.origin(), 5173)
|
||||
}
|
||||
}
|
||||
|
||||
impl Container for Frontend {
|
||||
fn image(&self) -> Image {
|
||||
Image::build("web")
|
||||
}
|
||||
}
|
||||
82
examples/dx_bind_origin/src/harmony.rs
Normal file
82
examples/dx_bind_origin/src/harmony.rs
Normal file
@@ -0,0 +1,82 @@
|
||||
//! Stand-in for framework types this experiment needs. Not `harmony_app`.
|
||||
|
||||
use std::marker::PhantomData;
|
||||
|
||||
pub struct Origin;
|
||||
|
||||
pub struct Ref<T>(PhantomData<T>);
|
||||
|
||||
impl<T> Copy for Ref<T> {}
|
||||
impl<T> Clone for Ref<T> {
|
||||
fn clone(&self) -> Self {
|
||||
*self
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> Ref<T> {
|
||||
pub const fn new() -> Self {
|
||||
Self(PhantomData)
|
||||
}
|
||||
}
|
||||
|
||||
pub trait EnvVal {}
|
||||
impl<T> EnvVal for Ref<T> {}
|
||||
impl EnvVal for &'static str {}
|
||||
|
||||
pub struct Launch;
|
||||
|
||||
impl Launch {
|
||||
pub fn sh(_: &'static str) -> Self {
|
||||
Self
|
||||
}
|
||||
pub fn cwd(self, _: &'static str) -> Self {
|
||||
self
|
||||
}
|
||||
pub fn env(self, _: &'static str, _: impl EnvVal) -> Self {
|
||||
self
|
||||
}
|
||||
pub fn publish(self, _: Ref<Origin>, _: u16) -> Self {
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Image;
|
||||
|
||||
impl Image {
|
||||
pub fn build(_: &'static str) -> Self {
|
||||
Self
|
||||
}
|
||||
}
|
||||
|
||||
pub trait Command {
|
||||
fn launch(&self) -> Launch;
|
||||
}
|
||||
|
||||
pub trait Container {
|
||||
fn image(&self) -> Image;
|
||||
}
|
||||
|
||||
pub trait Remote {}
|
||||
|
||||
#[allow(non_camel_case_types)]
|
||||
pub struct command;
|
||||
#[allow(non_camel_case_types)]
|
||||
pub struct container;
|
||||
#[allow(non_camel_case_types)]
|
||||
pub struct remote;
|
||||
|
||||
pub trait Accepts<R> {}
|
||||
impl<T: Command> Accepts<command> for T {}
|
||||
impl<T: Container> Accepts<container> for T {}
|
||||
impl<T: Remote> Accepts<remote> for T {}
|
||||
|
||||
pub struct Context;
|
||||
|
||||
impl Context {
|
||||
pub fn named(_: &'static str) -> Self {
|
||||
Self
|
||||
}
|
||||
pub fn bind<C: Accepts<R>, R>(self) -> Self {
|
||||
self
|
||||
}
|
||||
}
|
||||
16
examples/dx_bind_origin/src/localdev.rs
Normal file
16
examples/dx_bind_origin/src/localdev.rs
Normal file
@@ -0,0 +1,16 @@
|
||||
use crate::backend::Backend;
|
||||
use crate::bucket::Bucket;
|
||||
use crate::frontend::Frontend;
|
||||
use crate::harmony::{command, container, Context};
|
||||
use crate::postgres::Postgres;
|
||||
use crate::zitadel::Zitadel;
|
||||
|
||||
pub fn context() -> Context {
|
||||
Context::named("localdev")
|
||||
.bind::<Frontend, command>()
|
||||
.bind::<Backend, command>()
|
||||
.bind::<Postgres, container>()
|
||||
.bind::<Zitadel, container>()
|
||||
.bind::<Bucket, container>()
|
||||
// .bind::<Postgres, command>()
|
||||
}
|
||||
34
examples/dx_bind_origin/src/main.rs
Normal file
34
examples/dx_bind_origin/src/main.rs
Normal file
@@ -0,0 +1,34 @@
|
||||
#![allow(dead_code)]
|
||||
|
||||
mod backend;
|
||||
mod bucket;
|
||||
mod frontend;
|
||||
mod harmony;
|
||||
mod localdev;
|
||||
mod postgres;
|
||||
mod production;
|
||||
mod zitadel;
|
||||
|
||||
use crate::harmony::{Command, Container};
|
||||
|
||||
fn main() {
|
||||
let db = postgres::Postgres;
|
||||
let files = bucket::Bucket;
|
||||
let id = zitadel::Zitadel;
|
||||
let web = frontend::Frontend::new(id.issuer());
|
||||
let api = backend::Backend::new(web.origin(), id.issuer(), db.url(), files.endpoint());
|
||||
|
||||
id.redirect(web.origin(), "/auth/callback");
|
||||
files.cors(web.origin());
|
||||
|
||||
let _ = web.launch();
|
||||
let _ = web.image();
|
||||
let _ = api.launch();
|
||||
let _ = api.image();
|
||||
let _ = db.image();
|
||||
let _ = id.image();
|
||||
let _ = files.image();
|
||||
|
||||
let _ = localdev::context();
|
||||
let _ = production::context();
|
||||
}
|
||||
15
examples/dx_bind_origin/src/postgres.rs
Normal file
15
examples/dx_bind_origin/src/postgres.rs
Normal file
@@ -0,0 +1,15 @@
|
||||
use crate::harmony::{Container, Image, Ref};
|
||||
|
||||
pub struct Postgres;
|
||||
|
||||
impl Postgres {
|
||||
pub fn url(&self) -> Ref<Self> {
|
||||
Ref::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl Container for Postgres {
|
||||
fn image(&self) -> Image {
|
||||
Image::build("postgres:16")
|
||||
}
|
||||
}
|
||||
15
examples/dx_bind_origin/src/production.rs
Normal file
15
examples/dx_bind_origin/src/production.rs
Normal file
@@ -0,0 +1,15 @@
|
||||
use crate::backend::Backend;
|
||||
use crate::bucket::Bucket;
|
||||
use crate::frontend::Frontend;
|
||||
use crate::harmony::{container, remote, Context};
|
||||
use crate::postgres::Postgres;
|
||||
use crate::zitadel::Zitadel;
|
||||
|
||||
pub fn context() -> Context {
|
||||
Context::named("production")
|
||||
.bind::<Frontend, container>()
|
||||
.bind::<Backend, container>()
|
||||
.bind::<Postgres, container>()
|
||||
.bind::<Zitadel, container>()
|
||||
.bind::<Bucket, remote>()
|
||||
}
|
||||
17
examples/dx_bind_origin/src/zitadel.rs
Normal file
17
examples/dx_bind_origin/src/zitadel.rs
Normal file
@@ -0,0 +1,17 @@
|
||||
use crate::harmony::{Container, Image, Origin, Ref};
|
||||
|
||||
pub struct Zitadel;
|
||||
|
||||
impl Zitadel {
|
||||
pub fn issuer(&self) -> Ref<Origin> {
|
||||
Ref::new()
|
||||
}
|
||||
|
||||
pub fn redirect(&self, _: Ref<Origin>, _: &'static str) {}
|
||||
}
|
||||
|
||||
impl Container for Zitadel {
|
||||
fn image(&self) -> Image {
|
||||
Image::build("zitadel")
|
||||
}
|
||||
}
|
||||
12
examples/dx_guide_ship/Cargo.toml
Normal file
12
examples/dx_guide_ship/Cargo.toml
Normal file
@@ -0,0 +1,12 @@
|
||||
[package]
|
||||
name = "example-dx-guide-ship"
|
||||
edition = "2024"
|
||||
version.workspace = true
|
||||
readme.workspace = true
|
||||
license.workspace = true
|
||||
description = "DX experiment (ADR-029): tutorial-shaped ship.rs inventory, Runtime enum binds."
|
||||
publish = false
|
||||
|
||||
[[bin]]
|
||||
name = "dx-guide-ship"
|
||||
path = "src/main.rs"
|
||||
39
examples/dx_guide_ship/README.md
Normal file
39
examples/dx_guide_ship/README.md
Normal file
@@ -0,0 +1,39 @@
|
||||
# Getting started: one file per component
|
||||
|
||||
Junior-shaped ship inventory (ADR-029). Opening `frontend.rs` *is* the
|
||||
frontend. There is no `FrontendCommand` type.
|
||||
|
||||
```bash
|
||||
cargo check -p example-dx-guide-ship
|
||||
```
|
||||
|
||||
## Copy this layout
|
||||
|
||||
| File | What it is |
|
||||
|------|------------|
|
||||
| `src/main.rs` | Inventory: `.component::<Frontend>()` + contexts |
|
||||
| `src/frontend.rs` | `impl Command` (`npm run dev`) and `impl Container` |
|
||||
| `src/backend.rs` | Same, plus `env()` wiring via `Ref<Postgres>` etc. |
|
||||
| `src/postgres.rs` | Container (or Remote in production). `PostgresScore` |
|
||||
| `src/zitadel.rs` | `ZitadelScore` — do not invent `AppScore` |
|
||||
| `src/bucket.rs` | `BucketScore` |
|
||||
| `src/localdev.rs` | `Context::new("localdev").bind::<Frontend>(Runtime::Command)` |
|
||||
| `src/production.rs` | Frontend/Backend as Container; managed bits as Remote |
|
||||
| `src/harmony.rs` | Stub framework types. Not `harmony_app`. |
|
||||
|
||||
## Runtime is an enum
|
||||
|
||||
```rust
|
||||
Context::new("localdev").bind::<Frontend>(Runtime::Command)
|
||||
```
|
||||
|
||||
`Runtime` is `Command | Container | Remote`. Binding `Runtime::Command` on
|
||||
Postgres **compiles**. The type system does not know Postgres has no
|
||||
`impl Command`. That is the tradeoff: obvious API, few traits, illegal
|
||||
binds are not a type error. (The other DX crates make that a type error.)
|
||||
|
||||
## Cycles
|
||||
|
||||
`Frontend::env` takes `Ref<Backend>` and `Backend::env` takes
|
||||
`Ref<Frontend>`. Those URLs are allocated before either side starts.
|
||||
A Ref is desired state, not a live connection.
|
||||
42
examples/dx_guide_ship/src/backend.rs
Normal file
42
examples/dx_guide_ship/src/backend.rs
Normal file
@@ -0,0 +1,42 @@
|
||||
use crate::bucket::Bucket;
|
||||
use crate::frontend::Frontend;
|
||||
use crate::harmony::{Command, CommandSpec, Container, Env, Image, Ref};
|
||||
use crate::postgres::Postgres;
|
||||
use crate::zitadel::Zitadel;
|
||||
|
||||
/// THIS FILE IS THE BACKEND.
|
||||
pub struct Backend;
|
||||
|
||||
impl Command for Backend {
|
||||
fn command(&self) -> CommandSpec {
|
||||
CommandSpec::new("npm").args(&["run", "start:dev"]).port(8080)
|
||||
}
|
||||
}
|
||||
|
||||
impl Container for Backend {
|
||||
fn image(&self) -> Image {
|
||||
Image::from_dockerfile("../backend/Dockerfile").port(8080)
|
||||
}
|
||||
}
|
||||
|
||||
impl Backend {
|
||||
/// Circular with Frontend::env: URLs are allocated before either side starts.
|
||||
/// A Ref is desired state, not a live connection.
|
||||
pub fn env(
|
||||
&self,
|
||||
web: Ref<Frontend>,
|
||||
db: Ref<Postgres>,
|
||||
idp: Ref<Zitadel>,
|
||||
files: Ref<Bucket>,
|
||||
) -> Env {
|
||||
Env::new()
|
||||
.set("CORS_ORIGIN", web.public_url())
|
||||
.set("DATABASE_URL", db.url())
|
||||
.set("OIDC_ISSUER", idp.issuer())
|
||||
.set("OIDC_CLIENT_ID", idp.client_id())
|
||||
.set("S3_ENDPOINT", files.endpoint())
|
||||
.set("S3_BUCKET", files.name())
|
||||
.set("S3_ACCESS_KEY", files.access_key())
|
||||
.set("S3_SECRET_KEY", files.secret_key())
|
||||
}
|
||||
}
|
||||
24
examples/dx_guide_ship/src/bucket.rs
Normal file
24
examples/dx_guide_ship/src/bucket.rs
Normal file
@@ -0,0 +1,24 @@
|
||||
use crate::harmony::scores::BucketScore;
|
||||
use crate::harmony::{Container, Image, Remote, Url};
|
||||
|
||||
/// THIS FILE IS THE FILE BUCKET.
|
||||
pub struct Bucket;
|
||||
|
||||
// Harmony already has BucketScore. Do not invent AppScore for glue.
|
||||
impl BucketScore for Bucket {
|
||||
fn name(&self) -> &str {
|
||||
"app-files"
|
||||
}
|
||||
}
|
||||
|
||||
impl Container for Bucket {
|
||||
fn image(&self) -> Image {
|
||||
Image::from_dockerfile("s3")
|
||||
}
|
||||
}
|
||||
|
||||
impl Remote for Bucket {
|
||||
fn url(&self) -> Url {
|
||||
Url::from_score(self)
|
||||
}
|
||||
}
|
||||
24
examples/dx_guide_ship/src/frontend.rs
Normal file
24
examples/dx_guide_ship/src/frontend.rs
Normal file
@@ -0,0 +1,24 @@
|
||||
use crate::backend::Backend;
|
||||
use crate::harmony::{Command, CommandSpec, Container, Env, Image, Ref};
|
||||
|
||||
/// THIS FILE IS THE FRONTEND. Not FrontendCommand / FrontendContainer.
|
||||
pub struct Frontend;
|
||||
|
||||
impl Command for Frontend {
|
||||
fn command(&self) -> CommandSpec {
|
||||
CommandSpec::new("npm").args(&["run", "dev"]).port(5173)
|
||||
}
|
||||
}
|
||||
|
||||
impl Container for Frontend {
|
||||
fn image(&self) -> Image {
|
||||
Image::from_dockerfile("../frontend/Dockerfile").port(80)
|
||||
}
|
||||
}
|
||||
|
||||
impl Frontend {
|
||||
/// Circular with Backend::env: URLs are allocated before either side starts.
|
||||
pub fn env(&self, api: Ref<Backend>) -> Env {
|
||||
Env::new().set("VITE_API_URL", api.public_url())
|
||||
}
|
||||
}
|
||||
153
examples/dx_guide_ship/src/harmony.rs
Normal file
153
examples/dx_guide_ship/src/harmony.rs
Normal file
@@ -0,0 +1,153 @@
|
||||
//! Stand-in for framework types. Not `harmony_app`.
|
||||
|
||||
use std::marker::PhantomData;
|
||||
|
||||
/// How a context runs a component. An enum, not a type parameter —
|
||||
/// `bind::<Postgres>(Runtime::Command)` is *not* a type error. See README.
|
||||
pub enum Runtime {
|
||||
Command,
|
||||
Container,
|
||||
Remote,
|
||||
}
|
||||
|
||||
pub struct CommandSpec {
|
||||
pub bin: &'static str,
|
||||
}
|
||||
|
||||
impl CommandSpec {
|
||||
pub fn new(bin: &'static str) -> Self {
|
||||
Self { bin }
|
||||
}
|
||||
pub fn args(self, _: &[&str]) -> Self {
|
||||
self
|
||||
}
|
||||
pub fn port(self, _: u16) -> Self {
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Image;
|
||||
impl Image {
|
||||
pub fn from_dockerfile(_: &'static str) -> Self {
|
||||
Self
|
||||
}
|
||||
pub fn port(self, _: u16) -> Self {
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Env;
|
||||
impl Env {
|
||||
pub fn new() -> Self {
|
||||
Self
|
||||
}
|
||||
pub fn set(self, _: &'static str, _: impl ToString) -> Self {
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Url;
|
||||
impl Url {
|
||||
pub fn from_score<T>(_: &T) -> Self {
|
||||
Self
|
||||
}
|
||||
}
|
||||
impl std::fmt::Display for Url {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "url")
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Ref<T>(PhantomData<T>);
|
||||
impl<T> Copy for Ref<T> {}
|
||||
impl<T> Clone for Ref<T> {
|
||||
fn clone(&self) -> Self {
|
||||
*self
|
||||
}
|
||||
}
|
||||
impl<T> Ref<T> {
|
||||
pub fn new() -> Self {
|
||||
Self(PhantomData)
|
||||
}
|
||||
pub fn public_url(self) -> Url {
|
||||
Url
|
||||
}
|
||||
pub fn url(self) -> Url {
|
||||
Url
|
||||
}
|
||||
pub fn issuer(self) -> Url {
|
||||
Url
|
||||
}
|
||||
pub fn client_id(self) -> &'static str {
|
||||
"id"
|
||||
}
|
||||
pub fn endpoint(self) -> Url {
|
||||
Url
|
||||
}
|
||||
pub fn name(self) -> &'static str {
|
||||
"files"
|
||||
}
|
||||
pub fn access_key(self) -> &'static str {
|
||||
"key"
|
||||
}
|
||||
pub fn secret_key(self) -> &'static str {
|
||||
"secret"
|
||||
}
|
||||
}
|
||||
|
||||
pub trait Command {
|
||||
fn command(&self) -> CommandSpec;
|
||||
}
|
||||
pub trait Container {
|
||||
fn image(&self) -> Image;
|
||||
}
|
||||
pub trait Remote {
|
||||
fn url(&self) -> Url {
|
||||
Url
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Context;
|
||||
|
||||
impl Context {
|
||||
pub fn new(_: &'static str) -> Self {
|
||||
Self
|
||||
}
|
||||
/// Not type-checked against Command/Container impls — see README.
|
||||
pub fn bind<C>(self, _: Runtime) -> Self {
|
||||
let _ = PhantomData::<C>;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Ship;
|
||||
|
||||
impl Ship {
|
||||
pub fn component<C>(self) -> Self {
|
||||
self
|
||||
}
|
||||
pub fn context(self, _: fn() -> Context) -> Self {
|
||||
self
|
||||
}
|
||||
pub fn run(self) {}
|
||||
}
|
||||
|
||||
pub fn ship() -> Ship {
|
||||
Ship
|
||||
}
|
||||
|
||||
pub mod scores {
|
||||
/// Framework scores. App glue is `env()` on the component — do not invent `AppScore`.
|
||||
pub trait PostgresScore {
|
||||
fn name(&self) -> &str;
|
||||
fn version(&self) -> &str {
|
||||
"16"
|
||||
}
|
||||
}
|
||||
pub trait ZitadelScore {
|
||||
fn name(&self) -> &str;
|
||||
}
|
||||
pub trait BucketScore {
|
||||
fn name(&self) -> &str;
|
||||
}
|
||||
}
|
||||
16
examples/dx_guide_ship/src/localdev.rs
Normal file
16
examples/dx_guide_ship/src/localdev.rs
Normal file
@@ -0,0 +1,16 @@
|
||||
use crate::backend::Backend;
|
||||
use crate::bucket::Bucket;
|
||||
use crate::frontend::Frontend;
|
||||
use crate::harmony::{Context, Runtime};
|
||||
use crate::postgres::Postgres;
|
||||
use crate::zitadel::Zitadel;
|
||||
|
||||
pub fn context() -> Context {
|
||||
Context::new("localdev")
|
||||
.bind::<Frontend>(Runtime::Command)
|
||||
.bind::<Backend>(Runtime::Command)
|
||||
.bind::<Postgres>(Runtime::Container)
|
||||
.bind::<Bucket>(Runtime::Container)
|
||||
.bind::<Zitadel>(Runtime::Container)
|
||||
// .bind::<Postgres>(Runtime::Command) // compiles! Runtime is an enum. See README.
|
||||
}
|
||||
31
examples/dx_guide_ship/src/main.rs
Normal file
31
examples/dx_guide_ship/src/main.rs
Normal file
@@ -0,0 +1,31 @@
|
||||
//! Copy-paste inventory. One line per component, one line per context.
|
||||
|
||||
#![allow(dead_code)]
|
||||
|
||||
mod backend;
|
||||
mod bucket;
|
||||
mod frontend;
|
||||
mod harmony;
|
||||
mod localdev;
|
||||
mod postgres;
|
||||
mod production;
|
||||
mod zitadel;
|
||||
|
||||
use backend::Backend;
|
||||
use bucket::Bucket;
|
||||
use frontend::Frontend;
|
||||
use harmony::ship;
|
||||
use postgres::Postgres;
|
||||
use zitadel::Zitadel;
|
||||
|
||||
fn main() {
|
||||
ship()
|
||||
.component::<Frontend>()
|
||||
.component::<Backend>()
|
||||
.component::<Postgres>()
|
||||
.component::<Zitadel>()
|
||||
.component::<Bucket>()
|
||||
.context(localdev::context)
|
||||
.context(production::context)
|
||||
.run();
|
||||
}
|
||||
24
examples/dx_guide_ship/src/postgres.rs
Normal file
24
examples/dx_guide_ship/src/postgres.rs
Normal file
@@ -0,0 +1,24 @@
|
||||
use crate::harmony::scores::PostgresScore;
|
||||
use crate::harmony::{Container, Image, Remote, Url};
|
||||
|
||||
/// THIS FILE IS POSTGRES. No `impl Command` — but `Runtime::Command` still binds (see README).
|
||||
pub struct Postgres;
|
||||
|
||||
// Harmony already has PostgresScore. Do not invent AppScore for glue.
|
||||
impl PostgresScore for Postgres {
|
||||
fn name(&self) -> &str {
|
||||
"app-db"
|
||||
}
|
||||
}
|
||||
|
||||
impl Container for Postgres {
|
||||
fn image(&self) -> Image {
|
||||
Image::from_dockerfile("postgres:16")
|
||||
}
|
||||
}
|
||||
|
||||
impl Remote for Postgres {
|
||||
fn url(&self) -> Url {
|
||||
Url::from_score(self)
|
||||
}
|
||||
}
|
||||
15
examples/dx_guide_ship/src/production.rs
Normal file
15
examples/dx_guide_ship/src/production.rs
Normal file
@@ -0,0 +1,15 @@
|
||||
use crate::backend::Backend;
|
||||
use crate::bucket::Bucket;
|
||||
use crate::frontend::Frontend;
|
||||
use crate::harmony::{Context, Runtime};
|
||||
use crate::postgres::Postgres;
|
||||
use crate::zitadel::Zitadel;
|
||||
|
||||
pub fn context() -> Context {
|
||||
Context::new("production")
|
||||
.bind::<Frontend>(Runtime::Container)
|
||||
.bind::<Backend>(Runtime::Container)
|
||||
.bind::<Postgres>(Runtime::Remote)
|
||||
.bind::<Bucket>(Runtime::Remote)
|
||||
.bind::<Zitadel>(Runtime::Remote)
|
||||
}
|
||||
24
examples/dx_guide_ship/src/zitadel.rs
Normal file
24
examples/dx_guide_ship/src/zitadel.rs
Normal file
@@ -0,0 +1,24 @@
|
||||
use crate::harmony::scores::ZitadelScore;
|
||||
use crate::harmony::{Container, Image, Remote, Url};
|
||||
|
||||
/// THIS FILE IS ZITADEL.
|
||||
pub struct Zitadel;
|
||||
|
||||
// Harmony already has ZitadelScore. Do not invent AppScore for glue.
|
||||
impl ZitadelScore for Zitadel {
|
||||
fn name(&self) -> &str {
|
||||
"idp"
|
||||
}
|
||||
}
|
||||
|
||||
impl Container for Zitadel {
|
||||
fn image(&self) -> Image {
|
||||
Image::from_dockerfile("zitadel")
|
||||
}
|
||||
}
|
||||
|
||||
impl Remote for Zitadel {
|
||||
fn url(&self) -> Url {
|
||||
Url::from_score(self)
|
||||
}
|
||||
}
|
||||
12
examples/dx_slot_secret/Cargo.toml
Normal file
12
examples/dx_slot_secret/Cargo.toml
Normal file
@@ -0,0 +1,12 @@
|
||||
[package]
|
||||
name = "example-dx-slot-secret"
|
||||
edition = "2024"
|
||||
version.workspace = true
|
||||
readme.workspace = true
|
||||
license.workspace = true
|
||||
description = "DX experiment (ADR-029): Slot/Ref, Secret<T>, dialect types, exhaustive bind."
|
||||
publish = false
|
||||
|
||||
[[bin]]
|
||||
name = "dx-slot-secret"
|
||||
path = "src/main.rs"
|
||||
9
examples/dx_slot_secret/README.md
Normal file
9
examples/dx_slot_secret/README.md
Normal file
@@ -0,0 +1,9 @@
|
||||
# DX: Slot / Secret / dialect
|
||||
|
||||
Backend-shaped. `Slot<T>` is advertised; `Ref<T>` is held.
|
||||
`Secret<Jdbc>` cannot sit on `WebEnv`. `db.jdbc()` is Java; `db.url()` is Node.
|
||||
`bind! { t.db => Command }` does not compile.
|
||||
|
||||
```bash
|
||||
cargo check -p example-dx-slot-secret
|
||||
```
|
||||
44
examples/dx_slot_secret/src/api.rs
Normal file
44
examples/dx_slot_secret/src/api.rs
Normal file
@@ -0,0 +1,44 @@
|
||||
use crate::harmony::{
|
||||
CommandRuntime, ContainerRuntime, HttpUrl, Issuer, Jdbc, MachineKey, OAuthClient, Ref, S3,
|
||||
Secret, Slot,
|
||||
};
|
||||
|
||||
pub struct Api {
|
||||
pub url: Slot<HttpUrl>,
|
||||
pub client: Slot<OAuthClient>,
|
||||
pub db: Ref<Secret<Jdbc>>,
|
||||
pub issuer: Ref<Issuer>,
|
||||
pub sa: Ref<Secret<MachineKey>>,
|
||||
pub bucket: Ref<S3>,
|
||||
}
|
||||
|
||||
pub struct ApiEnv {
|
||||
pub db: Secret<Jdbc>,
|
||||
pub issuer: Issuer,
|
||||
pub sa: Secret<MachineKey>,
|
||||
pub s3: S3,
|
||||
}
|
||||
|
||||
impl Api {
|
||||
pub fn env(&self) -> ApiEnv {
|
||||
ApiEnv {
|
||||
db: self.db.get(),
|
||||
issuer: self.issuer.get(),
|
||||
sa: self.sa.get(),
|
||||
s3: self.bucket.get(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl CommandRuntime for Api {
|
||||
fn listen(&self) -> u16 {
|
||||
let _ = self.env();
|
||||
8080
|
||||
}
|
||||
}
|
||||
|
||||
impl ContainerRuntime for Api {
|
||||
fn image(&self) -> &'static str {
|
||||
"api"
|
||||
}
|
||||
}
|
||||
180
examples/dx_slot_secret/src/harmony.rs
Normal file
180
examples/dx_slot_secret/src/harmony.rs
Normal file
@@ -0,0 +1,180 @@
|
||||
use std::marker::PhantomData;
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
pub struct Slot<T>(PhantomData<T>);
|
||||
|
||||
impl<T> Slot<T> {
|
||||
pub const fn new() -> Self {
|
||||
Self(PhantomData)
|
||||
}
|
||||
|
||||
pub fn as_ref(self) -> Ref<T> {
|
||||
Ref(PhantomData)
|
||||
}
|
||||
|
||||
pub fn public(self) -> Ref<T> {
|
||||
Ref(PhantomData)
|
||||
}
|
||||
|
||||
pub fn join(self, _: &'static str) -> Ref<T> {
|
||||
Ref(PhantomData)
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> From<Slot<T>> for Ref<T> {
|
||||
fn from(slot: Slot<T>) -> Self {
|
||||
slot.as_ref()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
pub struct Ref<T>(PhantomData<T>);
|
||||
|
||||
impl<T> Ref<T> {
|
||||
pub fn get(self) -> T
|
||||
where
|
||||
T: Default,
|
||||
{
|
||||
T::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Default)]
|
||||
pub struct Secret<T>(PhantomData<T>);
|
||||
|
||||
#[derive(Clone, Copy, Default)]
|
||||
pub struct Jdbc;
|
||||
|
||||
#[derive(Clone, Copy, Default)]
|
||||
pub struct PgUrl;
|
||||
|
||||
#[derive(Clone, Copy, Default)]
|
||||
pub struct HttpUrl;
|
||||
|
||||
#[derive(Clone, Copy, Default)]
|
||||
pub struct Issuer;
|
||||
|
||||
#[derive(Clone, Copy, Default)]
|
||||
pub struct MachineKey;
|
||||
|
||||
#[derive(Clone, Copy, Default)]
|
||||
pub struct S3;
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
pub struct OAuthClient;
|
||||
|
||||
pub struct Command;
|
||||
pub struct Container;
|
||||
pub struct Remote;
|
||||
|
||||
pub trait RunsAs<R> {}
|
||||
|
||||
pub trait CommandRuntime {
|
||||
fn listen(&self) -> u16;
|
||||
}
|
||||
|
||||
pub trait ContainerRuntime {
|
||||
fn image(&self) -> &'static str;
|
||||
}
|
||||
|
||||
pub trait RemoteRuntime {}
|
||||
|
||||
impl<T: CommandRuntime> RunsAs<Command> for T {}
|
||||
impl<T: ContainerRuntime> RunsAs<Container> for T {}
|
||||
impl<T: RemoteRuntime> RunsAs<Remote> for T {}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
pub struct Postgres;
|
||||
|
||||
impl Postgres {
|
||||
pub fn named(_: &'static str) -> Self {
|
||||
Self
|
||||
}
|
||||
|
||||
pub fn jdbc(&self) -> Ref<Secret<Jdbc>> {
|
||||
Ref(PhantomData)
|
||||
}
|
||||
|
||||
pub fn url(&self) -> Ref<Secret<PgUrl>> {
|
||||
Ref(PhantomData)
|
||||
}
|
||||
}
|
||||
|
||||
impl ContainerRuntime for Postgres {
|
||||
fn image(&self) -> &'static str {
|
||||
"postgres:16"
|
||||
}
|
||||
}
|
||||
|
||||
impl RemoteRuntime for Postgres {}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
pub struct Identity;
|
||||
|
||||
impl Identity {
|
||||
pub fn named(_: &'static str) -> Self {
|
||||
Self
|
||||
}
|
||||
|
||||
pub fn issuer(&self) -> Ref<Issuer> {
|
||||
Ref(PhantomData)
|
||||
}
|
||||
|
||||
pub fn machine_key(&self, _: Ref<OAuthClient>) -> Ref<Secret<MachineKey>> {
|
||||
Ref(PhantomData)
|
||||
}
|
||||
|
||||
pub fn redirect(&self, _: impl Into<Ref<HttpUrl>>) {}
|
||||
|
||||
pub fn invite_base(&self, _: Slot<HttpUrl>) {}
|
||||
|
||||
pub fn client(&self, _: Slot<OAuthClient>, _: ClientKind) {}
|
||||
}
|
||||
|
||||
impl ContainerRuntime for Identity {
|
||||
fn image(&self) -> &'static str {
|
||||
"identity"
|
||||
}
|
||||
}
|
||||
|
||||
impl RemoteRuntime for Identity {}
|
||||
|
||||
pub enum ClientKind {
|
||||
Machine,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
pub struct Bucket;
|
||||
|
||||
impl Bucket {
|
||||
pub fn named(_: &'static str) -> Self {
|
||||
Self
|
||||
}
|
||||
|
||||
pub fn s3(&self) -> Ref<S3> {
|
||||
Ref(PhantomData)
|
||||
}
|
||||
}
|
||||
|
||||
impl ContainerRuntime for Bucket {
|
||||
fn image(&self) -> &'static str {
|
||||
"bucket"
|
||||
}
|
||||
}
|
||||
|
||||
impl RemoteRuntime for Bucket {}
|
||||
|
||||
#[macro_export]
|
||||
macro_rules! bind {
|
||||
($($t:ident . $comp:ident => $rt:ty),+ $(,)?) => {{
|
||||
$(
|
||||
{
|
||||
fn __runs_as<T: $crate::harmony::RunsAs<$rt>>(_: &T) {}
|
||||
__runs_as(&$t.$comp);
|
||||
}
|
||||
)+
|
||||
$crate::topology::Bound {
|
||||
$($comp: &$t.$comp,)+
|
||||
}
|
||||
}};
|
||||
}
|
||||
14
examples/dx_slot_secret/src/local.rs
Normal file
14
examples/dx_slot_secret/src/local.rs
Normal file
@@ -0,0 +1,14 @@
|
||||
use crate::bind;
|
||||
use crate::harmony::{Command, Container};
|
||||
use crate::topology::Topology;
|
||||
|
||||
pub fn bind(t: &Topology) {
|
||||
bind! {
|
||||
t.api => Command,
|
||||
t.web => Command,
|
||||
t.db => Container,
|
||||
t.idp => Container,
|
||||
t.bucket => Container,
|
||||
};
|
||||
// bind! { t.db => Command } // does not compile
|
||||
}
|
||||
14
examples/dx_slot_secret/src/main.rs
Normal file
14
examples/dx_slot_secret/src/main.rs
Normal file
@@ -0,0 +1,14 @@
|
||||
#![allow(dead_code)]
|
||||
|
||||
mod api;
|
||||
mod harmony;
|
||||
mod local;
|
||||
mod production;
|
||||
mod topology;
|
||||
mod web;
|
||||
|
||||
fn main() {
|
||||
let t = topology::topology();
|
||||
local::bind(&t);
|
||||
production::bind(&t);
|
||||
}
|
||||
13
examples/dx_slot_secret/src/production.rs
Normal file
13
examples/dx_slot_secret/src/production.rs
Normal file
@@ -0,0 +1,13 @@
|
||||
use crate::bind;
|
||||
use crate::harmony::{Container, Remote};
|
||||
use crate::topology::Topology;
|
||||
|
||||
pub fn bind(t: &Topology) {
|
||||
bind! {
|
||||
t.api => Container,
|
||||
t.web => Container,
|
||||
t.db => Remote,
|
||||
t.idp => Container,
|
||||
t.bucket => Remote,
|
||||
};
|
||||
}
|
||||
51
examples/dx_slot_secret/src/topology.rs
Normal file
51
examples/dx_slot_secret/src/topology.rs
Normal file
@@ -0,0 +1,51 @@
|
||||
use crate::api::Api;
|
||||
use crate::harmony::{Bucket, ClientKind, Identity, Postgres, Slot};
|
||||
use crate::web::Web;
|
||||
|
||||
pub struct Topology {
|
||||
pub api: Api,
|
||||
pub web: Web,
|
||||
pub db: Postgres,
|
||||
pub idp: Identity,
|
||||
pub bucket: Bucket,
|
||||
}
|
||||
|
||||
pub struct Bound<'a> {
|
||||
pub api: &'a Api,
|
||||
pub web: &'a Web,
|
||||
pub db: &'a Postgres,
|
||||
pub idp: &'a Identity,
|
||||
pub bucket: &'a Bucket,
|
||||
}
|
||||
|
||||
pub fn topology() -> Topology {
|
||||
let db = Postgres::named("app");
|
||||
let bucket = Bucket::named("files");
|
||||
let idp = Identity::named("auth");
|
||||
let client = Slot::new();
|
||||
let url = Slot::new();
|
||||
idp.client(client, ClientKind::Machine);
|
||||
let api = Api {
|
||||
url,
|
||||
client,
|
||||
db: db.jdbc(),
|
||||
// db: db.url(), // Api is Java-shaped; url() is Node
|
||||
issuer: idp.issuer(),
|
||||
sa: idp.machine_key(client.as_ref()),
|
||||
bucket: bucket.s3(),
|
||||
};
|
||||
let web = Web {
|
||||
url: Slot::new(),
|
||||
api: api.url.public(),
|
||||
};
|
||||
idp.redirect(api.url);
|
||||
idp.redirect(web.url.join("/silent-renew"));
|
||||
idp.invite_base(web.url);
|
||||
Topology {
|
||||
api,
|
||||
web,
|
||||
db,
|
||||
idp,
|
||||
bucket,
|
||||
}
|
||||
}
|
||||
31
examples/dx_slot_secret/src/web.rs
Normal file
31
examples/dx_slot_secret/src/web.rs
Normal file
@@ -0,0 +1,31 @@
|
||||
use crate::harmony::{CommandRuntime, ContainerRuntime, HttpUrl, Ref, Slot};
|
||||
|
||||
pub struct Web {
|
||||
pub url: Slot<HttpUrl>,
|
||||
pub api: Ref<HttpUrl>,
|
||||
}
|
||||
|
||||
pub struct WebEnv {
|
||||
pub api: HttpUrl,
|
||||
}
|
||||
|
||||
impl Web {
|
||||
pub fn env(&self) -> WebEnv {
|
||||
WebEnv {
|
||||
api: self.api.get(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl CommandRuntime for Web {
|
||||
fn listen(&self) -> u16 {
|
||||
let _ = self.env();
|
||||
5173
|
||||
}
|
||||
}
|
||||
|
||||
impl ContainerRuntime for Web {
|
||||
fn image(&self) -> &'static str {
|
||||
"web"
|
||||
}
|
||||
}
|
||||
@@ -124,7 +124,7 @@ struct Cli {
|
||||
#[arg(long, requires = "openbao_secret_prefix")]
|
||||
openbao_url: Option<String>,
|
||||
|
||||
/// KV prefix containing this fleet's deployment and pull secrets.
|
||||
/// KV prefix containing this fleet's deployment secrets (including image pull).
|
||||
#[arg(long, requires = "openbao_url")]
|
||||
openbao_secret_prefix: Option<String>,
|
||||
|
||||
|
||||
31
examples/notes/Cargo.toml
Normal file
31
examples/notes/Cargo.toml
Normal file
@@ -0,0 +1,31 @@
|
||||
[package]
|
||||
name = "example-notes"
|
||||
edition = "2024"
|
||||
version.workspace = true
|
||||
readme.workspace = true
|
||||
license.workspace = true
|
||||
description = "ADR-029 notes app: frontend + backend + postgres, localdev vs cluster."
|
||||
publish = false
|
||||
|
||||
[[bin]]
|
||||
name = "notes"
|
||||
path = "src/main.rs"
|
||||
|
||||
[[bin]]
|
||||
name = "notes-api"
|
||||
path = "src/bin/notes_api.rs"
|
||||
|
||||
[[bin]]
|
||||
name = "notes-web"
|
||||
path = "src/bin/notes_web.rs"
|
||||
|
||||
[dependencies]
|
||||
anyhow.workspace = true
|
||||
async-trait.workspace = true
|
||||
harmony = { path = "../../harmony" }
|
||||
harmony_app = { path = "../../harmony_app" }
|
||||
harmony_cli = { path = "../../harmony_cli" }
|
||||
harmony_macros = { path = "../../harmony_macros" }
|
||||
harmony_types = { path = "../../harmony_types" }
|
||||
serde_json.workspace = true
|
||||
tokio = { workspace = true, features = ["full"] }
|
||||
17
examples/notes/README.md
Normal file
17
examples/notes/README.md
Normal file
@@ -0,0 +1,17 @@
|
||||
# notes — ADR-029 example
|
||||
|
||||
Frontend, backend, postgres. One type per file. Contexts bind runtimes.
|
||||
|
||||
```bash
|
||||
cargo build -p example-notes --bins
|
||||
cargo run -p example-notes --bin notes -- ship --context localdev
|
||||
# frontend http://127.0.0.1:15173 backend http://127.0.0.1:18080/ (200 + "ok" when DATABASE_URL is set)
|
||||
|
||||
cargo run -p example-notes --bin notes -- ship --context cluster
|
||||
```
|
||||
|
||||
`localdev`: host processes + CNPG on k3d (NodePort).
|
||||
`cluster`: all containers on the same k3d (nginx + http-echo + CNPG).
|
||||
|
||||
Context bind is a struct you fill in (`as_command` / `as_container`). Forget a
|
||||
field or `postgres.as_command()` — compile error, no macro.
|
||||
36
examples/notes/src/app.rs
Normal file
36
examples/notes/src/app.rs
Normal file
@@ -0,0 +1,36 @@
|
||||
use crate::backend::Backend;
|
||||
use crate::frontend::Frontend;
|
||||
use crate::postgres::Postgres;
|
||||
use harmony_app::dx::{BoundComp, HttpUrl, Slot};
|
||||
|
||||
pub struct Notes {
|
||||
pub frontend: Frontend,
|
||||
pub backend: Backend,
|
||||
pub postgres: Postgres,
|
||||
}
|
||||
|
||||
pub struct Bound<'a, F, B, P> {
|
||||
pub frontend: BoundComp<'a, Frontend, F>,
|
||||
pub backend: BoundComp<'a, Backend, B>,
|
||||
pub postgres: BoundComp<'a, Postgres, P>,
|
||||
}
|
||||
|
||||
pub fn notes() -> Notes {
|
||||
let web_url = Slot::<HttpUrl>::new();
|
||||
let api_url = Slot::<HttpUrl>::new();
|
||||
let postgres = Postgres::named("notes-db");
|
||||
let backend = Backend {
|
||||
url: api_url,
|
||||
origin: web_url.public(),
|
||||
db: postgres.url(),
|
||||
};
|
||||
let frontend = Frontend {
|
||||
url: web_url,
|
||||
api: api_url.public(),
|
||||
};
|
||||
Notes {
|
||||
frontend,
|
||||
backend,
|
||||
postgres,
|
||||
}
|
||||
}
|
||||
20
examples/notes/src/backend.rs
Normal file
20
examples/notes/src/backend.rs
Normal file
@@ -0,0 +1,20 @@
|
||||
use harmony_app::dx::{Command, Container, HttpUrl, Image, Launch, PgUrl, Ref, Secret, Slot};
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub struct Backend {
|
||||
pub url: Slot<HttpUrl>,
|
||||
pub origin: Ref<HttpUrl>,
|
||||
pub db: Ref<Secret<PgUrl>>,
|
||||
}
|
||||
|
||||
impl Command for Backend {
|
||||
fn launch(&self) -> Launch {
|
||||
Launch::bin("notes-api").listen(18080)
|
||||
}
|
||||
}
|
||||
|
||||
impl Container for Backend {
|
||||
fn image(&self) -> Image {
|
||||
Image::from_registry("hashicorp/http-echo:1.0.0").port(5678)
|
||||
}
|
||||
}
|
||||
21
examples/notes/src/bin/notes_api.rs
Normal file
21
examples/notes/src/bin/notes_api.rs
Normal file
@@ -0,0 +1,21 @@
|
||||
use std::io::Write;
|
||||
use std::net::TcpListener;
|
||||
|
||||
fn main() {
|
||||
let db = std::env::var("DATABASE_URL").unwrap_or_default();
|
||||
let listener = TcpListener::bind("127.0.0.1:18080").expect("bind 18080");
|
||||
eprintln!("notes-api listening 18080 DATABASE_URL_set={}", !db.is_empty());
|
||||
for mut stream in listener.incoming().flatten() {
|
||||
let body = if db.is_empty() {
|
||||
"DATABASE_URL missing"
|
||||
} else {
|
||||
"ok"
|
||||
};
|
||||
let status = if db.is_empty() { "503" } else { "200" };
|
||||
let resp = format!(
|
||||
"HTTP/1.1 {status} OK\r\nContent-Type: text/plain\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
|
||||
body.len()
|
||||
);
|
||||
let _ = stream.write_all(resp.as_bytes());
|
||||
}
|
||||
}
|
||||
20
examples/notes/src/bin/notes_web.rs
Normal file
20
examples/notes/src/bin/notes_web.rs
Normal file
@@ -0,0 +1,20 @@
|
||||
use std::io::Write;
|
||||
use std::net::TcpListener;
|
||||
|
||||
const PAGE: &str = r#"<!doctype html>
|
||||
<title>notes</title>
|
||||
<p>notes frontend</p>
|
||||
<p><a href="http://127.0.0.1:18080/">backend</a></p>
|
||||
"#;
|
||||
|
||||
fn main() {
|
||||
let listener = TcpListener::bind("127.0.0.1:15173").expect("bind 15173");
|
||||
eprintln!("notes-web listening 15173");
|
||||
for mut stream in listener.incoming().flatten() {
|
||||
let resp = format!(
|
||||
"HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{PAGE}",
|
||||
PAGE.len()
|
||||
);
|
||||
let _ = stream.write_all(resp.as_bytes());
|
||||
}
|
||||
}
|
||||
36
examples/notes/src/cluster.rs
Normal file
36
examples/notes/src/cluster.rs
Normal file
@@ -0,0 +1,36 @@
|
||||
use crate::app::{Bound, Notes};
|
||||
use harmony::modules::k8s::deployment::K8sDeploymentScore;
|
||||
use harmony::modules::postgresql::K8sPostgreSQLScore;
|
||||
use harmony::score::Score;
|
||||
use harmony::topology::K8sAnywhereTopology;
|
||||
use harmony_app::dx::{container, AsContainer, Container};
|
||||
use serde_json::json;
|
||||
|
||||
pub fn bind(app: &Notes) -> Bound<'_, container, container, container> {
|
||||
Bound {
|
||||
frontend: app.frontend.as_container(),
|
||||
backend: app.backend.as_container(),
|
||||
postgres: app.postgres.as_container(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn scores(app: &Notes, namespace: &str) -> Vec<Box<dyn Score<K8sAnywhereTopology>>> {
|
||||
let bound = bind(app);
|
||||
let cluster = bound.postgres.inner.name;
|
||||
let pg = K8sPostgreSQLScore::new(namespace).cluster_name(cluster);
|
||||
let frontend = K8sDeploymentScore {
|
||||
name: "frontend".into(),
|
||||
image: bound.frontend.inner.image().name,
|
||||
namespace: Some(namespace.into()),
|
||||
env_vars: json!([]),
|
||||
};
|
||||
let backend = K8sDeploymentScore {
|
||||
name: "backend".into(),
|
||||
image: bound.backend.inner.image().name,
|
||||
namespace: Some(namespace.into()),
|
||||
env_vars: json!([
|
||||
{ "name": "CORS_ORIGIN", "value": "http://frontend" }
|
||||
]),
|
||||
};
|
||||
vec![Box::new(pg), Box::new(frontend), Box::new(backend)]
|
||||
}
|
||||
19
examples/notes/src/frontend.rs
Normal file
19
examples/notes/src/frontend.rs
Normal file
@@ -0,0 +1,19 @@
|
||||
use harmony_app::dx::{Command, Container, HttpUrl, Image, Launch, Ref, Slot};
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub struct Frontend {
|
||||
pub url: Slot<HttpUrl>,
|
||||
pub api: Ref<HttpUrl>,
|
||||
}
|
||||
|
||||
impl Command for Frontend {
|
||||
fn launch(&self) -> Launch {
|
||||
Launch::bin("notes-web").listen(15173)
|
||||
}
|
||||
}
|
||||
|
||||
impl Container for Frontend {
|
||||
fn image(&self) -> Image {
|
||||
Image::from_registry("nginx:alpine").port(80)
|
||||
}
|
||||
}
|
||||
39
examples/notes/src/localdev.rs
Normal file
39
examples/notes/src/localdev.rs
Normal file
@@ -0,0 +1,39 @@
|
||||
use crate::app::{Bound, Notes};
|
||||
use harmony::modules::host_process::HostProcessScore;
|
||||
use harmony::modules::postgresql::{K8sPostgreSQLScore, PostgresDebugRouteScore};
|
||||
use harmony::score::Score;
|
||||
use harmony::topology::K8sAnywhereTopology;
|
||||
use harmony_app::dx::{command, container, AsCommand, AsContainer, Command, Launch};
|
||||
|
||||
pub fn bind(app: &Notes) -> Bound<'_, command, command, container> {
|
||||
Bound {
|
||||
frontend: app.frontend.as_command(),
|
||||
backend: app.backend.as_command(),
|
||||
postgres: app.postgres.as_container(),
|
||||
// postgres: app.postgres.as_command(), // does not compile: no Command
|
||||
}
|
||||
}
|
||||
|
||||
pub fn scores(app: &Notes, namespace: &str) -> Vec<Box<dyn Score<K8sAnywhereTopology>>> {
|
||||
let bound = bind(app);
|
||||
let cluster = bound.postgres.inner.name;
|
||||
let pg = K8sPostgreSQLScore::new(namespace).cluster_name(cluster);
|
||||
let debug = PostgresDebugRouteScore::new(namespace, cluster).node_port();
|
||||
let mut api = from_launch("backend", bound.backend.inner.launch());
|
||||
api = api.env_postgres_uri("DATABASE_URL", namespace, cluster);
|
||||
let web = from_launch("frontend", bound.frontend.inner.launch());
|
||||
vec![Box::new(pg), Box::new(debug), Box::new(api), Box::new(web)]
|
||||
}
|
||||
|
||||
fn from_launch(name: &str, launch: Launch) -> HostProcessScore {
|
||||
let mut score = HostProcessScore::new(name, launch.program);
|
||||
score.args = launch.args;
|
||||
score.cwd = launch.cwd;
|
||||
score.port = launch.port;
|
||||
score.env = launch
|
||||
.env
|
||||
.into_iter()
|
||||
.map(|(k, v)| (k, harmony::modules::host_process::ProcessEnv::Literal(v)))
|
||||
.collect();
|
||||
score
|
||||
}
|
||||
68
examples/notes/src/main.rs
Normal file
68
examples/notes/src/main.rs
Normal file
@@ -0,0 +1,68 @@
|
||||
mod app;
|
||||
mod backend;
|
||||
mod cluster;
|
||||
mod frontend;
|
||||
mod localdev;
|
||||
mod postgres;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use harmony::topology::K8sAnywhereTopology;
|
||||
use harmony_app::dx::container;
|
||||
use harmony_app::{
|
||||
AppContext, AppError, AppIdentity, Context, ContextCatalog, ContextSpec, HarmonyApp,
|
||||
ImageRefs, LocalContext,
|
||||
};
|
||||
use harmony_macros::context_name;
|
||||
|
||||
struct NotesApp;
|
||||
|
||||
#[async_trait]
|
||||
impl HarmonyApp<K8sAnywhereTopology> for NotesApp {
|
||||
fn identity(&self, ctx: &AppContext) -> AppIdentity {
|
||||
AppIdentity {
|
||||
name: "notes".into(),
|
||||
namespace: ctx.namespace().to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn scores(
|
||||
&self,
|
||||
ctx: &AppContext,
|
||||
_images: &ImageRefs,
|
||||
) -> Result<Vec<Box<dyn harmony::score::Score<K8sAnywhereTopology>>>, AppError> {
|
||||
let notes = app::notes();
|
||||
match ctx.name() {
|
||||
"localdev" => {
|
||||
let _ = localdev::bind(¬es);
|
||||
Ok(localdev::scores(¬es, ctx.namespace()))
|
||||
}
|
||||
"cluster" => {
|
||||
let _: app::Bound<container, container, container> = 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
|
||||
}
|
||||
21
examples/notes/src/postgres.rs
Normal file
21
examples/notes/src/postgres.rs
Normal file
@@ -0,0 +1,21 @@
|
||||
use harmony_app::dx::{Container, Image, PgUrl, Ref, Secret, Slot};
|
||||
|
||||
pub struct Postgres {
|
||||
pub name: &'static str,
|
||||
}
|
||||
|
||||
impl Postgres {
|
||||
pub fn named(name: &'static str) -> Self {
|
||||
Self { name }
|
||||
}
|
||||
|
||||
pub fn url(&self) -> Ref<Secret<PgUrl>> {
|
||||
Slot::new().as_ref()
|
||||
}
|
||||
}
|
||||
|
||||
impl Container for Postgres {
|
||||
fn image(&self) -> Image {
|
||||
Image::from_registry("ghcr.io/cloudnative-pg/postgresql:16")
|
||||
}
|
||||
}
|
||||
@@ -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::<RegistryCredential>(&bytes).map_err(|error| {
|
||||
|
||||
99
fleet/harmony-fleet-deploy/src/agent_artifact.rs
Normal file
99
fleet/harmony-fleet-deploy/src/agent_artifact.rs
Normal file
@@ -0,0 +1,99 @@
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::Path;
|
||||
|
||||
use anyhow::{Context, bail};
|
||||
use harmony_app::RegistryCredentials;
|
||||
use harmony_reconciler_contracts::upgrade::{AGENT_OCI_ARTIFACT_TYPE, AGENT_OCI_LAYER_MEDIA_TYPE};
|
||||
use oci_client::annotations::ORG_OPENCONTAINERS_IMAGE_VERSION;
|
||||
use oci_client::client::{Config, ImageLayer};
|
||||
use oci_client::errors::{OciDistributionError, OciErrorCode};
|
||||
use oci_client::manifest::OciImageManifest;
|
||||
use oci_client::secrets::RegistryAuth;
|
||||
use oci_client::{Client, Reference};
|
||||
|
||||
pub async fn publish_agent_artifact(
|
||||
binary: &Path,
|
||||
reference: &str,
|
||||
version: &str,
|
||||
credentials: &RegistryCredentials,
|
||||
) -> anyhow::Result<String> {
|
||||
let reference = Reference::try_from(reference).context("invalid agent OCI reference")?;
|
||||
if reference.tag().is_none() || reference.digest().is_some() {
|
||||
bail!("agent publication requires a tag reference");
|
||||
}
|
||||
let auth = RegistryAuth::Basic(credentials.username.clone(), credentials.token.clone());
|
||||
let client = Client::default();
|
||||
let layer = ImageLayer::new(
|
||||
std::fs::read(binary)
|
||||
.with_context(|| format!("reading agent binary {}", binary.display()))?,
|
||||
AGENT_OCI_LAYER_MEDIA_TYPE.to_string(),
|
||||
None,
|
||||
);
|
||||
match client.pull_manifest(&reference, &auth).await {
|
||||
Ok((oci_client::manifest::OciManifest::Image(manifest), digest))
|
||||
if manifest.artifact_type.as_deref() == Some(AGENT_OCI_ARTIFACT_TYPE)
|
||||
&& manifest
|
||||
.annotations
|
||||
.as_ref()
|
||||
.and_then(|annotations| annotations.get(ORG_OPENCONTAINERS_IMAGE_VERSION))
|
||||
.map(String::as_str)
|
||||
== Some(version)
|
||||
&& matches!(manifest.layers.as_slice(), [existing]
|
||||
if existing.media_type == AGENT_OCI_LAYER_MEDIA_TYPE
|
||||
&& existing.digest == layer.sha256_digest()) =>
|
||||
{
|
||||
return Ok(format!(
|
||||
"oci://{}/{}@{digest}",
|
||||
reference.registry(),
|
||||
reference.repository()
|
||||
));
|
||||
}
|
||||
Ok(_) => bail!(
|
||||
"agent artifact tag exists with different content: {}",
|
||||
reference.whole()
|
||||
),
|
||||
Err(error) if manifest_is_missing(&error) => {}
|
||||
Err(error) => return Err(error).context("checking agent artifact tag"),
|
||||
}
|
||||
|
||||
let config = Config::new(
|
||||
b"{}".to_vec(),
|
||||
"application/vnd.oci.empty.v1+json".to_string(),
|
||||
None,
|
||||
);
|
||||
let mut annotations = BTreeMap::new();
|
||||
annotations.insert(
|
||||
ORG_OPENCONTAINERS_IMAGE_VERSION.to_string(),
|
||||
version.to_string(),
|
||||
);
|
||||
let mut manifest =
|
||||
OciImageManifest::build(std::slice::from_ref(&layer), &config, Some(annotations));
|
||||
manifest.artifact_type = Some(AGENT_OCI_ARTIFACT_TYPE.to_string());
|
||||
client
|
||||
.push(&reference, &[layer], config, &auth, Some(manifest))
|
||||
.await
|
||||
.context("publishing agent OCI artifact")?;
|
||||
let (_, digest) = client
|
||||
.pull_manifest(&reference, &auth)
|
||||
.await
|
||||
.context("resolving published agent manifest")?;
|
||||
Ok(format!(
|
||||
"oci://{}/{}@{digest}",
|
||||
reference.registry(),
|
||||
reference.repository()
|
||||
))
|
||||
}
|
||||
|
||||
fn manifest_is_missing(error: &OciDistributionError) -> bool {
|
||||
matches!(error, OciDistributionError::ImageManifestNotFoundError(_))
|
||||
|| matches!(
|
||||
error,
|
||||
OciDistributionError::RegistryError { envelope, .. }
|
||||
if envelope.errors.iter().any(|error| matches!(
|
||||
&error.code,
|
||||
OciErrorCode::ManifestUnknown
|
||||
| OciErrorCode::NameUnknown
|
||||
| OciErrorCode::NotFound
|
||||
))
|
||||
)
|
||||
}
|
||||
39
fleet/harmony-fleet-deploy/src/agent_release.rs
Normal file
39
fleet/harmony-fleet-deploy/src/agent_release.rs
Normal file
@@ -0,0 +1,39 @@
|
||||
use std::path::PathBuf;
|
||||
|
||||
use anyhow::{Context, bail};
|
||||
use clap::Parser;
|
||||
use harmony_app::{AppContext, Context as DeployContext, RegistryCredentials};
|
||||
|
||||
use crate::publish_agent_artifact;
|
||||
|
||||
#[derive(Parser, Debug)]
|
||||
struct Args {
|
||||
#[arg(long)]
|
||||
binary: PathBuf,
|
||||
#[arg(long)]
|
||||
version: String,
|
||||
#[arg(long)]
|
||||
arch: String,
|
||||
}
|
||||
|
||||
pub async fn release_agent_cli(context: DeployContext) -> anyhow::Result<()> {
|
||||
harmony_cli::cli_logger::init();
|
||||
let args = Args::parse();
|
||||
if args.arch != "x86_64" && args.arch != "aarch64" {
|
||||
bail!("arch must be x86_64 or aarch64");
|
||||
}
|
||||
let ctx = AppContext::resolve(&context, &args.version, None).await?;
|
||||
if ctx.registry().is_none() {
|
||||
bail!("agent release requires a remote context");
|
||||
}
|
||||
let credentials: RegistryCredentials = ctx
|
||||
.config_client()
|
||||
.get()
|
||||
.await
|
||||
.context("loading RegistryCredentials from OpenBao")?;
|
||||
let reference = format!("{}-{}", ctx.image("harmony-fleet-agent"), args.arch);
|
||||
let artifact =
|
||||
publish_agent_artifact(&args.binary, &reference, &args.version, &credentials).await?;
|
||||
println!("agent={artifact}");
|
||||
Ok(())
|
||||
}
|
||||
@@ -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<RegistryAuth> {
|
||||
let credentials = registry_credentials()?;
|
||||
Ok(RegistryAuth::Basic(credentials.username, credentials.token))
|
||||
|
||||
@@ -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::{
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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::{
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<dyn Application>,
|
||||
|
||||
@@ -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<A: OCICompliant + HelmPackage> {
|
||||
pub application: Arc<A>,
|
||||
|
||||
@@ -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<A: Application + Serialize, T: Topology + Clone + Serialize>
|
||||
where
|
||||
|
||||
360
harmony/src/modules/host_process/mod.rs
Normal file
360
harmony/src/modules/host_process/mod.rs
Normal file
@@ -0,0 +1,360 @@
|
||||
//! Run a process on the machine that interprets the Score.
|
||||
//!
|
||||
//! Idempotent: same name + spec while the pid is alive → [`Outcome::noop`].
|
||||
|
||||
use std::fs;
|
||||
use std::net::TcpStream;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Command, Stdio};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use harmony_types::id::Id;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::data::Version;
|
||||
use crate::domain::config::HARMONY_DATA_DIR;
|
||||
use crate::interpret::{Interpret, InterpretError, InterpretName, InterpretStatus, Outcome};
|
||||
use crate::inventory::Inventory;
|
||||
use crate::score::Score;
|
||||
use crate::topology::{K8sclient, Topology};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub enum ProcessEnv {
|
||||
Literal(String),
|
||||
/// Read `{secret}` `{key}` after other Scores have created it.
|
||||
/// If `node_port_service` is set, rewrite the URI host to 127.0.0.1 and
|
||||
/// the port to that Service's nodePort (host processes talking to CNPG).
|
||||
SecretUri {
|
||||
namespace: String,
|
||||
secret: String,
|
||||
key: String,
|
||||
node_port_service: Option<(String, String)>,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct HostProcessScore {
|
||||
pub name: String,
|
||||
pub program: String,
|
||||
pub args: Vec<String>,
|
||||
pub cwd: Option<String>,
|
||||
pub env: Vec<(String, ProcessEnv)>,
|
||||
pub port: Option<u16>,
|
||||
}
|
||||
|
||||
impl HostProcessScore {
|
||||
pub fn new(name: impl Into<String>, program: impl Into<String>) -> Self {
|
||||
Self {
|
||||
name: name.into(),
|
||||
program: program.into(),
|
||||
args: Vec::new(),
|
||||
cwd: None,
|
||||
env: Vec::new(),
|
||||
port: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn arg(mut self, arg: impl Into<String>) -> Self {
|
||||
self.args.push(arg.into());
|
||||
self
|
||||
}
|
||||
|
||||
pub fn cwd(mut self, cwd: impl Into<String>) -> Self {
|
||||
self.cwd = Some(cwd.into());
|
||||
self
|
||||
}
|
||||
|
||||
pub fn env_lit(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
|
||||
self.env
|
||||
.push((key.into(), ProcessEnv::Literal(value.into())));
|
||||
self
|
||||
}
|
||||
|
||||
pub fn env_postgres_uri(
|
||||
mut self,
|
||||
key: impl Into<String>,
|
||||
namespace: impl Into<String>,
|
||||
cluster: impl Into<String>,
|
||||
) -> Self {
|
||||
let namespace = namespace.into();
|
||||
let cluster = cluster.into();
|
||||
self.env.push((
|
||||
key.into(),
|
||||
ProcessEnv::SecretUri {
|
||||
node_port_service: Some((namespace.clone(), format!("{cluster}-rw-debug"))),
|
||||
secret: format!("{cluster}-app"),
|
||||
key: "uri".into(),
|
||||
namespace,
|
||||
},
|
||||
));
|
||||
self
|
||||
}
|
||||
|
||||
pub fn port(mut self, port: u16) -> Self {
|
||||
self.port = Some(port);
|
||||
self
|
||||
}
|
||||
|
||||
fn state_dir(&self) -> PathBuf {
|
||||
HARMONY_DATA_DIR.join("host-process").join(&self.name)
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: Topology + K8sclient + 'static> Score<T> for HostProcessScore {
|
||||
fn create_interpret(&self) -> Box<dyn Interpret<T>> {
|
||||
Box::new(HostProcessInterpret {
|
||||
score: self.clone(),
|
||||
})
|
||||
}
|
||||
|
||||
fn name(&self) -> String {
|
||||
format!("HostProcessScore({})", self.name)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct HostProcessInterpret {
|
||||
score: HostProcessScore,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
struct StateFile {
|
||||
pid: u32,
|
||||
spec: String,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl<T: Topology + K8sclient> Interpret<T> for HostProcessInterpret {
|
||||
async fn execute(
|
||||
&self,
|
||||
_inventory: &Inventory,
|
||||
topology: &T,
|
||||
) -> Result<Outcome, InterpretError> {
|
||||
let dir = self.score.state_dir();
|
||||
fs::create_dir_all(&dir).map_err(|e| InterpretError::new(e.to_string()))?;
|
||||
let spec =
|
||||
serde_json::to_string(&self.score).map_err(|e| InterpretError::new(e.to_string()))?;
|
||||
let state_path = dir.join("state.json");
|
||||
|
||||
if let Some(state) = read_state(&state_path) {
|
||||
if state.spec == spec && pid_alive(state.pid) && port_ready(self.score.port) {
|
||||
return Ok(Outcome::noop(format!(
|
||||
"host process '{}' already running (pid {})",
|
||||
self.score.name, state.pid
|
||||
)));
|
||||
}
|
||||
if pid_alive(state.pid) {
|
||||
let _ = Command::new("kill").arg(state.pid.to_string()).status();
|
||||
std::thread::sleep(Duration::from_millis(100));
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(port) = self.score.port
|
||||
&& port_ready(Some(port))
|
||||
{
|
||||
return Err(InterpretError::new(format!(
|
||||
"127.0.0.1:{port} is already in use"
|
||||
)));
|
||||
}
|
||||
|
||||
let env = resolve_env(&self.score.env, topology).await?;
|
||||
let program = resolve_program(&self.score.program)?;
|
||||
let mut cmd = Command::new(&program);
|
||||
cmd.args(&self.score.args)
|
||||
.stdin(Stdio::null())
|
||||
.stdout(fs::File::create(dir.join("stdout.log")).map_err(io)?)
|
||||
.stderr(fs::File::create(dir.join("stderr.log")).map_err(io)?);
|
||||
if let Some(cwd) = &self.score.cwd {
|
||||
cmd.current_dir(cwd);
|
||||
}
|
||||
for (k, v) in &env {
|
||||
cmd.env(k, v);
|
||||
}
|
||||
let child = cmd
|
||||
.spawn()
|
||||
.map_err(|e| InterpretError::new(format!("spawn {}: {e}", program.display())))?;
|
||||
let pid = child.id();
|
||||
fs::write(
|
||||
&state_path,
|
||||
serde_json::to_vec(&StateFile {
|
||||
pid,
|
||||
spec: spec.clone(),
|
||||
})
|
||||
.map_err(|e| InterpretError::new(e.to_string()))?,
|
||||
)
|
||||
.map_err(io)?;
|
||||
|
||||
if let Some(port) = self.score.port {
|
||||
if let Err(e) = wait_port(port, Duration::from_secs(15)) {
|
||||
let stderr = fs::read_to_string(dir.join("stderr.log")).unwrap_or_default();
|
||||
return Err(InterpretError::new(format!(
|
||||
"{e}; process alive={} stderr={stderr}",
|
||||
pid_alive(pid)
|
||||
)));
|
||||
}
|
||||
} else if !pid_alive(pid) {
|
||||
let stderr = fs::read_to_string(dir.join("stderr.log")).unwrap_or_default();
|
||||
return Err(InterpretError::new(format!(
|
||||
"host process '{}' exited immediately: {stderr}",
|
||||
self.score.name
|
||||
)));
|
||||
}
|
||||
|
||||
Ok(Outcome::success(format!(
|
||||
"host process '{}' started (pid {pid})",
|
||||
self.score.name
|
||||
)))
|
||||
}
|
||||
|
||||
fn get_name(&self) -> InterpretName {
|
||||
InterpretName::Custom("HostProcess")
|
||||
}
|
||||
fn get_version(&self) -> Version {
|
||||
Version::from("0.1.0").unwrap()
|
||||
}
|
||||
fn get_status(&self) -> InterpretStatus {
|
||||
InterpretStatus::QUEUED
|
||||
}
|
||||
fn get_children(&self) -> Vec<Id> {
|
||||
vec![]
|
||||
}
|
||||
}
|
||||
|
||||
fn io(e: std::io::Error) -> InterpretError {
|
||||
InterpretError::new(e.to_string())
|
||||
}
|
||||
|
||||
fn resolve_program(program: &str) -> Result<PathBuf, InterpretError> {
|
||||
let p = PathBuf::from(program);
|
||||
if p.exists() {
|
||||
return Ok(p);
|
||||
}
|
||||
if let Ok(exe) = std::env::current_exe()
|
||||
&& let Some(dir) = exe.parent()
|
||||
{
|
||||
let sibling = dir.join(program);
|
||||
if sibling.exists() {
|
||||
return Ok(sibling);
|
||||
}
|
||||
}
|
||||
Ok(p)
|
||||
}
|
||||
|
||||
fn read_state(path: &Path) -> Option<StateFile> {
|
||||
serde_json::from_slice(&fs::read(path).ok()?).ok()
|
||||
}
|
||||
|
||||
fn pid_alive(pid: u32) -> bool {
|
||||
Command::new("kill")
|
||||
.args(["-0", &pid.to_string()])
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.status()
|
||||
.map(|s| s.success())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
fn port_ready(port: Option<u16>) -> bool {
|
||||
match port {
|
||||
None => true,
|
||||
Some(p) => TcpStream::connect(("127.0.0.1", p)).is_ok(),
|
||||
}
|
||||
}
|
||||
|
||||
fn wait_port(port: u16, timeout: Duration) -> Result<(), InterpretError> {
|
||||
let start = Instant::now();
|
||||
while start.elapsed() < timeout {
|
||||
if TcpStream::connect(("127.0.0.1", port)).is_ok() {
|
||||
return Ok(());
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(50));
|
||||
}
|
||||
Err(InterpretError::new(format!(
|
||||
"timed out waiting for 127.0.0.1:{port}"
|
||||
)))
|
||||
}
|
||||
|
||||
async fn resolve_env<T: Topology + K8sclient>(
|
||||
env: &[(String, ProcessEnv)],
|
||||
topology: &T,
|
||||
) -> Result<Vec<(String, String)>, InterpretError> {
|
||||
let mut out = Vec::new();
|
||||
for (k, v) in env {
|
||||
match v {
|
||||
ProcessEnv::Literal(s) => out.push((k.clone(), s.clone())),
|
||||
ProcessEnv::SecretUri {
|
||||
namespace,
|
||||
secret,
|
||||
key,
|
||||
node_port_service,
|
||||
} => {
|
||||
let client = topology
|
||||
.k8s_client()
|
||||
.await
|
||||
.map_err(|e| InterpretError::new(format!("k8s client: {e}")))?;
|
||||
let sec = client
|
||||
.get_resource::<k8s_openapi::api::core::v1::Secret>(secret, Some(namespace))
|
||||
.await
|
||||
.map_err(|e| InterpretError::new(format!("get secret {secret}: {e}")))?
|
||||
.ok_or_else(|| InterpretError::new(format!("secret {secret} missing")))?;
|
||||
let bytes =
|
||||
sec.data.as_ref().and_then(|d| d.get(key)).ok_or_else(|| {
|
||||
InterpretError::new(format!("secret {secret} missing {key}"))
|
||||
})?;
|
||||
let mut uri = String::from_utf8(bytes.0.clone())
|
||||
.map_err(|e| InterpretError::new(e.to_string()))?;
|
||||
if let Some((svc_ns, svc_name)) = node_port_service {
|
||||
let svc = client
|
||||
.get_resource::<k8s_openapi::api::core::v1::Service>(svc_name, Some(svc_ns))
|
||||
.await
|
||||
.map_err(|e| InterpretError::new(format!("get service {svc_name}: {e}")))?
|
||||
.ok_or_else(|| {
|
||||
InterpretError::new(format!("service {svc_name} missing"))
|
||||
})?;
|
||||
let node_port = svc
|
||||
.spec
|
||||
.as_ref()
|
||||
.and_then(|s| s.ports.as_ref())
|
||||
.and_then(|p| p.first())
|
||||
.and_then(|p| p.node_port)
|
||||
.ok_or_else(|| {
|
||||
InterpretError::new(format!("{svc_name} has no nodePort"))
|
||||
})?;
|
||||
uri = rewrite_uri_host_port(&uri, "127.0.0.1", node_port);
|
||||
}
|
||||
out.push((k.clone(), uri));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn rewrite_uri_host_port(uri: &str, host: &str, port: i32) -> String {
|
||||
// postgres://user:pass@oldhost:5432/db → postgres://user:pass@host:port/db
|
||||
if let Some(at) = uri.rfind('@') {
|
||||
let (head, rest) = uri.split_at(at + 1);
|
||||
if let Some(slash) = rest.find('/') {
|
||||
format!("{head}{host}:{port}{}", &rest[slash..])
|
||||
} else {
|
||||
format!("{head}{host}:{port}")
|
||||
}
|
||||
} else {
|
||||
uri.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::rewrite_uri_host_port;
|
||||
|
||||
#[test]
|
||||
fn rewrites_cnpg_uri() {
|
||||
let out = rewrite_uri_host_port(
|
||||
"postgresql://app:secret@notes-db-rw.notes.svc:5432/app",
|
||||
"127.0.0.1",
|
||||
32001,
|
||||
);
|
||||
assert_eq!(out, "postgresql://app:secret@127.0.0.1:32001/app");
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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<T: Topology + K8sclient> Interpret<T> 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!(
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"
|
||||
|
||||
64
harmony_app/README.md
Normal file
64
harmony_app/README.md
Normal file
@@ -0,0 +1,64 @@
|
||||
# harmony_app
|
||||
|
||||
Application delivery. A [`HarmonyApp`](src/app.rs) composes **Scores**; `ship` /
|
||||
`deploy` / `status` / `logs` are glue that interpret them. This crate does **not**
|
||||
orchestrate infrastructure — that is [`harmony`](../harmony).
|
||||
|
||||
## Architecture
|
||||
|
||||
| Crate | Job |
|
||||
|---|---|
|
||||
| **`harmony_cli`** | UI only. Parse argv, require `--context`, call verbs, render. Safe types and DX (digest-shaped flags, `--json`). No Scores, no image policy, no cluster mutation. |
|
||||
| **`harmony_app`** | Application delivery: identity, image build/publish, context catalog, `HarmonyApp` → Scores, ship/deploy as interpret glue. Authoring DX in [`dx`](src/dx.rs) (one component, one file, one type). |
|
||||
| **`harmony`** | Source of truth for infrastructure orchestration. Scores (desired state), Topologies (where), Interpret (how). Deploying Kubernetes resources, running containers, building the deployment model and applying it on a topology (k8s or otherwise). |
|
||||
| **`harmony_config`** | Load, save, discover settings and secrets. Schema is Rust; state is a store. Default store is OpenBao (CNCF). |
|
||||
|
||||
Identity is one SSO (`https://sso.nationtech.io`). That identity is what may
|
||||
read the tenant's config and environment in OpenBao, the OKD cluster, Harbor,
|
||||
and later more.
|
||||
|
||||
**SSO is still WIP for Harbor and OKD.** Access secrets for those live in
|
||||
OpenBao, which *is* SSO. Current flow:
|
||||
|
||||
1. Load one secret: `HARMONY_ZITADEL_KEY_JSON` (or `HARMONY_ZITADEL_KEY_PATH`).
|
||||
2. Authenticate to OpenBao (JWT-bearer against Zitadel).
|
||||
3. Load Harbor and kubeconfig from OpenBao.
|
||||
4. Authenticate to Harbor and Kubernetes with those secrets.
|
||||
|
||||
Interactive humans use device-code OIDC to the same OpenBao instead of a
|
||||
machine key. Same store, different first hop.
|
||||
|
||||
`--context` is required (or `HARMONY_CONTEXT`). There is no default.
|
||||
|
||||
`K8sAnywhereTopology` on `app_main` / `AppContext::topology()` is the
|
||||
**control plane** (kube apply — including Fleet CRs). It is not a claim that
|
||||
the workload runs on Kubernetes.
|
||||
|
||||
## Crate map
|
||||
|
||||
**UI:** `harmony_cli`, `harmony_tui`, `harmony_auth_cli`, `harmony_auth_ui`
|
||||
|
||||
**Application delivery:** `harmony_app`
|
||||
|
||||
**Orchestration:** `harmony`, `harmony-k8s`, `harmony-reconciler-contracts`,
|
||||
`harmony_execution`, `k3d`, `fleet/*`
|
||||
|
||||
**Config & secrets:** `harmony_config`, `harmony_config_derive`, `harmony_secret`,
|
||||
`harmony_secret_derive`
|
||||
|
||||
**Identity:** `harmony_zitadel_auth`, `harmony_zitadel_jwt`, `harmony_auth`
|
||||
|
||||
**Shared types:** `harmony_types`, `harmony_macros`
|
||||
|
||||
Infra-specific crates (OPNsense, NATS, agents, Brocade, …) live under `harmony`
|
||||
modules or their own crates; they are Scores/topologies, not app delivery.
|
||||
|
||||
## Remaining leaks (honest)
|
||||
|
||||
- `status` / `logs` list Kubernetes Deployments/Pods via the control-plane
|
||||
client. Operational verbs are allowed to read the cluster (ADR-026); they are
|
||||
not yet Score-status.
|
||||
- `harmony_cli::run` is a second frontend: a generic Score runner over Maestro.
|
||||
Still UI. Not application delivery.
|
||||
- `dx::Secret` is a stub. Secrets today are OpenBao-backed config structs, not
|
||||
a `Secret<T>` field type.
|
||||
@@ -74,6 +74,21 @@ pub trait HarmonyApp<T: Topology>: Send + Sync {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn build<T: Topology>(
|
||||
app: &dyn HarmonyApp<T>,
|
||||
ctx: &AppContext,
|
||||
) -> Result<ImageRefs, AppError> {
|
||||
app.build(ctx)
|
||||
}
|
||||
|
||||
pub async fn publish<T: Topology>(
|
||||
app: &dyn HarmonyApp<T>,
|
||||
ctx: &AppContext,
|
||||
images: &ImageRefs,
|
||||
) -> Result<ImageRefs, AppError> {
|
||||
app.publish(ctx, images).await
|
||||
}
|
||||
|
||||
// ---- structured results (rendered by the front-end, never printed here) ----
|
||||
|
||||
#[derive(Debug, Clone, serde::Serialize)]
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
@@ -1,562 +0,0 @@
|
||||
use std::path::PathBuf;
|
||||
use std::time::Duration;
|
||||
|
||||
use serde::Serialize;
|
||||
|
||||
use super::{ApplicationValidationError, BucketRef, DatabaseRef, ManagedResource, ZitadelRef};
|
||||
use harmony::modules::zitadel::{ZitadelApplicationRef, ZitadelMachineRef, ZitadelProjectRef};
|
||||
|
||||
/// A topology-neutral application declaration. K8sAnywhere is currently its first adapter.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct Application {
|
||||
pub name: String,
|
||||
pub images: Vec<Image>,
|
||||
pub endpoints: Vec<LogicalEndpoint>,
|
||||
pub resources: Vec<ManagedResource>,
|
||||
pub services: Vec<Service>,
|
||||
/// Routes are evaluated in declaration order.
|
||||
pub routes: Vec<Route>,
|
||||
pub rollout: RolloutIntent,
|
||||
}
|
||||
|
||||
impl Application {
|
||||
pub fn new(name: impl Into<String>) -> Self {
|
||||
Self {
|
||||
name: name.into(),
|
||||
images: Vec::new(),
|
||||
endpoints: Vec::new(),
|
||||
resources: Vec::new(),
|
||||
services: Vec::new(),
|
||||
routes: Vec::new(),
|
||||
rollout: RolloutIntent::default(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn image(mut self, image: Image) -> Self {
|
||||
self.images.push(image);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn service(mut self, service: Service) -> Self {
|
||||
self.services.push(service);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn endpoint(mut self, endpoint: LogicalEndpoint) -> Self {
|
||||
self.endpoints.push(endpoint);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn resource(mut self, resource: impl Into<ManagedResource>) -> Self {
|
||||
self.resources.push(resource.into());
|
||||
self
|
||||
}
|
||||
|
||||
pub fn route(mut self, route: Route) -> Self {
|
||||
self.routes.push(route);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn rollout(mut self, rollout: RolloutIntent) -> Self {
|
||||
self.rollout = rollout;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn validate(&self) -> Result<(), ApplicationValidationError> {
|
||||
super::validation::validate(self)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct Image {
|
||||
pub name: String,
|
||||
pub source: ImageSource,
|
||||
}
|
||||
|
||||
impl Image {
|
||||
pub fn new(name: impl Into<String>, reference: impl Into<String>) -> Self {
|
||||
Self {
|
||||
name: name.into(),
|
||||
source: ImageSource::Reference(reference.into()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn reference(&self) -> ImageRef {
|
||||
ImageRef(self.name.clone())
|
||||
}
|
||||
|
||||
pub fn build(name: impl Into<String>, context: impl Into<PathBuf>) -> Self {
|
||||
Self {
|
||||
name: name.into(),
|
||||
source: ImageSource::Build(ImageBuild {
|
||||
context: context.into(),
|
||||
dockerfile: PathBuf::from("Dockerfile"),
|
||||
platform: None,
|
||||
build_args: Vec::new(),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn dockerfile(mut self, dockerfile: impl Into<PathBuf>) -> Self {
|
||||
if let ImageSource::Build(build) = &mut self.source {
|
||||
build.dockerfile = dockerfile.into();
|
||||
}
|
||||
self
|
||||
}
|
||||
|
||||
pub fn platform(mut self, platform: impl Into<String>) -> Self {
|
||||
if let ImageSource::Build(build) = &mut self.source {
|
||||
build.platform = Some(platform.into());
|
||||
}
|
||||
self
|
||||
}
|
||||
|
||||
pub fn build_arg(mut self, name: impl Into<String>, value: Option<impl Into<String>>) -> Self {
|
||||
if let ImageSource::Build(build) = &mut self.source {
|
||||
build.build_args.push((name.into(), value.map(Into::into)));
|
||||
}
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub enum ImageSource {
|
||||
Reference(String),
|
||||
Build(ImageBuild),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct ImageBuild {
|
||||
pub context: PathBuf,
|
||||
pub dockerfile: PathBuf,
|
||||
pub platform: Option<String>,
|
||||
pub build_args: Vec<(String, Option<String>)>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize)]
|
||||
pub struct ImageRef(pub(crate) String);
|
||||
|
||||
impl ImageRef {
|
||||
pub fn new(name: impl Into<String>) -> Self {
|
||||
Self(name.into())
|
||||
}
|
||||
|
||||
pub fn name(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize)]
|
||||
pub struct ServiceRef(pub(crate) String);
|
||||
|
||||
impl ServiceRef {
|
||||
pub fn new(name: impl Into<String>) -> Self {
|
||||
Self(name.into())
|
||||
}
|
||||
|
||||
pub fn name(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
|
||||
pub fn port(&self, name: impl Into<String>) -> PortRef {
|
||||
PortRef {
|
||||
service: self.clone(),
|
||||
port: name.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize)]
|
||||
pub struct PortRef {
|
||||
pub(crate) service: ServiceRef,
|
||||
pub(crate) port: String,
|
||||
}
|
||||
|
||||
impl PortRef {
|
||||
pub fn new(service: ServiceRef, port: impl Into<String>) -> Self {
|
||||
Self {
|
||||
service,
|
||||
port: port.into(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn service(&self) -> &ServiceRef {
|
||||
&self.service
|
||||
}
|
||||
|
||||
pub fn name(&self) -> &str {
|
||||
&self.port
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct Service {
|
||||
pub name: String,
|
||||
pub image: ImageRef,
|
||||
pub command: Option<Command>,
|
||||
pub ports: Vec<Port>,
|
||||
pub values: Vec<(String, ValueRef)>,
|
||||
pub health: Option<HealthCheck>,
|
||||
pub resources: ResourceIntent,
|
||||
}
|
||||
|
||||
impl Service {
|
||||
pub fn new(name: impl Into<String>, image: ImageRef) -> Self {
|
||||
Self {
|
||||
name: name.into(),
|
||||
image,
|
||||
command: None,
|
||||
ports: Vec::new(),
|
||||
values: Vec::new(),
|
||||
health: None,
|
||||
resources: ResourceIntent::default(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn reference(&self) -> ServiceRef {
|
||||
ServiceRef(self.name.clone())
|
||||
}
|
||||
|
||||
pub fn command(mut self, command: Command) -> Self {
|
||||
self.command = Some(command);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn port(mut self, port: Port) -> Self {
|
||||
self.ports.push(port);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn value(mut self, name: impl Into<String>, value: ValueRef) -> Self {
|
||||
self.values.push((name.into(), value));
|
||||
self
|
||||
}
|
||||
|
||||
pub fn health(mut self, health: HealthCheck) -> Self {
|
||||
self.health = Some(health);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn resources(mut self, resources: ResourceIntent) -> Self {
|
||||
self.resources = resources;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct Command {
|
||||
pub program: String,
|
||||
pub args: Vec<String>,
|
||||
}
|
||||
|
||||
impl Command {
|
||||
pub fn new(
|
||||
program: impl Into<String>,
|
||||
args: impl IntoIterator<Item = impl Into<String>>,
|
||||
) -> Self {
|
||||
Self {
|
||||
program: program.into(),
|
||||
args: args.into_iter().map(Into::into).collect(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct Port {
|
||||
pub name: String,
|
||||
pub number: u16,
|
||||
pub protocol: Protocol,
|
||||
}
|
||||
|
||||
impl Port {
|
||||
pub fn tcp(name: impl Into<String>, number: u16) -> Self {
|
||||
Self {
|
||||
name: name.into(),
|
||||
number,
|
||||
protocol: Protocol::Tcp,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn udp(name: impl Into<String>, number: u16) -> Self {
|
||||
Self {
|
||||
name: name.into(),
|
||||
number,
|
||||
protocol: Protocol::Udp,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
|
||||
pub enum Protocol {
|
||||
Tcp,
|
||||
Udp,
|
||||
}
|
||||
|
||||
/// A value is either literal or resolved from desired-state semantics.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub enum ValueRef {
|
||||
Literal(String),
|
||||
ServiceHost(ServiceRef),
|
||||
ServicePort(PortRef),
|
||||
ServiceUrl {
|
||||
scheme: String,
|
||||
port: PortRef,
|
||||
},
|
||||
PublicEndpointOrigin(PublicEndpointRef),
|
||||
PublicEndpointUrl {
|
||||
endpoint: PublicEndpointRef,
|
||||
path: String,
|
||||
},
|
||||
DatabaseJdbcUrl(DatabaseRef),
|
||||
DatabaseUsername(DatabaseRef),
|
||||
DatabasePassword(DatabaseRef),
|
||||
BucketEndpoint(BucketRef),
|
||||
BucketName(BucketRef),
|
||||
BucketAccessKey(BucketRef),
|
||||
BucketSecretKey(BucketRef),
|
||||
BucketRegion(BucketRef),
|
||||
BucketPathStyle(BucketRef),
|
||||
ZitadelIssuer(ZitadelRef),
|
||||
ZitadelManagementUrl(ZitadelRef),
|
||||
OidcProjectId {
|
||||
zitadel: ZitadelRef,
|
||||
project: ZitadelProjectRef,
|
||||
},
|
||||
OidcClientId {
|
||||
zitadel: ZitadelRef,
|
||||
application: ZitadelApplicationRef,
|
||||
},
|
||||
MachineClientId {
|
||||
zitadel: ZitadelRef,
|
||||
machine: ZitadelMachineRef,
|
||||
},
|
||||
MachineClientSecret {
|
||||
zitadel: ZitadelRef,
|
||||
machine: ZitadelMachineRef,
|
||||
},
|
||||
/// Mount the secret key at `path` and set the value to that path.
|
||||
File(FileRef),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||
pub enum FileRef {
|
||||
MachineJsonKey {
|
||||
zitadel: ZitadelRef,
|
||||
machine: ZitadelMachineRef,
|
||||
path: String,
|
||||
},
|
||||
}
|
||||
|
||||
impl FileRef {
|
||||
pub fn path(&self) -> &str {
|
||||
match self {
|
||||
Self::MachineJsonKey { path, .. } => path,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ValueRef {
|
||||
pub fn literal(value: impl Into<String>) -> Self {
|
||||
Self::Literal(value.into())
|
||||
}
|
||||
|
||||
pub fn service_url(scheme: impl Into<String>, port: PortRef) -> Self {
|
||||
Self::ServiceUrl {
|
||||
scheme: scheme.into(),
|
||||
port,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub enum HealthCheck {
|
||||
Http {
|
||||
port: PortRef,
|
||||
path: String,
|
||||
interval: Duration,
|
||||
timeout: Duration,
|
||||
initial_delay: Duration,
|
||||
},
|
||||
Tcp {
|
||||
port: PortRef,
|
||||
interval: Duration,
|
||||
timeout: Duration,
|
||||
initial_delay: Duration,
|
||||
},
|
||||
}
|
||||
|
||||
impl HealthCheck {
|
||||
pub fn http(port: PortRef, path: impl Into<String>) -> Self {
|
||||
Self::Http {
|
||||
port,
|
||||
path: path.into(),
|
||||
interval: Duration::from_secs(10),
|
||||
timeout: Duration::from_secs(2),
|
||||
initial_delay: Duration::from_secs(0),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn tcp(port: PortRef) -> Self {
|
||||
Self::Tcp {
|
||||
port,
|
||||
interval: Duration::from_secs(10),
|
||||
timeout: Duration::from_secs(2),
|
||||
initial_delay: Duration::from_secs(0),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
|
||||
pub enum Cpu {
|
||||
Millicores(u32),
|
||||
Cores(u16),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
|
||||
pub enum Memory {
|
||||
Mebibytes(u32),
|
||||
Gibibytes(u32),
|
||||
}
|
||||
|
||||
/// Portable scheduler intent with typed CPU and memory units.
|
||||
#[derive(Debug, Clone, Default, Serialize)]
|
||||
pub struct ResourceIntent {
|
||||
pub cpu_request: Option<Cpu>,
|
||||
pub cpu_limit: Option<Cpu>,
|
||||
pub memory_request: Option<Memory>,
|
||||
pub memory_limit: Option<Memory>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct Route {
|
||||
pub endpoint: PublicEndpointRef,
|
||||
pub path: String,
|
||||
pub target: PortRef,
|
||||
pub smoke_check: bool,
|
||||
}
|
||||
|
||||
impl Route {
|
||||
pub fn new(endpoint: PublicEndpointRef, path: impl Into<String>, target: PortRef) -> Self {
|
||||
Self {
|
||||
endpoint,
|
||||
path: path.into(),
|
||||
target,
|
||||
smoke_check: true,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn smoke_check(mut self, enabled: bool) -> Self {
|
||||
self.smoke_check = enabled;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct LogicalEndpoint {
|
||||
pub name: String,
|
||||
pub tls: ManagedTls,
|
||||
}
|
||||
|
||||
impl LogicalEndpoint {
|
||||
pub fn new(name: impl Into<String>) -> Self {
|
||||
Self {
|
||||
name: name.into(),
|
||||
tls: ManagedTls::Disabled,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn managed_tls(mut self) -> Self {
|
||||
self.tls = ManagedTls::Managed;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn reference(&self) -> PublicEndpointRef {
|
||||
PublicEndpointRef(self.name.clone())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize)]
|
||||
pub struct PublicEndpointRef(pub(crate) String);
|
||||
|
||||
impl PublicEndpointRef {
|
||||
pub fn new(name: impl Into<String>) -> Self {
|
||||
Self(name.into())
|
||||
}
|
||||
|
||||
pub fn name(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
|
||||
pub fn origin(&self) -> ValueRef {
|
||||
ValueRef::PublicEndpointOrigin(self.clone())
|
||||
}
|
||||
|
||||
pub fn url(&self, path: impl Into<String>) -> ValueRef {
|
||||
ValueRef::PublicEndpointUrl {
|
||||
endpoint: self.clone(),
|
||||
path: path.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<super::ManagedPostgres> for ManagedResource {
|
||||
fn from(value: super::ManagedPostgres) -> Self {
|
||||
Self::Postgres(value)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<super::ManagedBucket> for ManagedResource {
|
||||
fn from(value: super::ManagedBucket) -> Self {
|
||||
Self::Bucket(value)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<super::ManagedZitadel> for ManagedResource {
|
||||
fn from(value: super::ManagedZitadel) -> Self {
|
||||
Self::Zitadel(value)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
|
||||
pub enum ManagedTls {
|
||||
Disabled,
|
||||
Managed,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct RolloutIntent {
|
||||
pub replicas: u32,
|
||||
pub strategy: RolloutStrategy,
|
||||
pub readiness: ReadinessIntent,
|
||||
}
|
||||
|
||||
impl Default for RolloutIntent {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
replicas: 1,
|
||||
strategy: RolloutStrategy::Rolling,
|
||||
readiness: ReadinessIntent::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
|
||||
pub enum RolloutStrategy {
|
||||
Rolling,
|
||||
Replace,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct ReadinessIntent {
|
||||
pub wait: bool,
|
||||
pub timeout: Duration,
|
||||
}
|
||||
|
||||
impl Default for ReadinessIntent {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
wait: true,
|
||||
timeout: Duration::from_secs(180),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,266 +0,0 @@
|
||||
use serde::Serialize;
|
||||
|
||||
use harmony::modules::zitadel::{
|
||||
ZitadelApplicationRef, ZitadelContract, ZitadelMachineRef, ZitadelProjectRef,
|
||||
};
|
||||
|
||||
use super::{FileRef, PublicEndpointRef, ValueRef};
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub enum ManagedResource {
|
||||
Postgres(ManagedPostgres),
|
||||
Bucket(ManagedBucket),
|
||||
Zitadel(ManagedZitadel),
|
||||
}
|
||||
|
||||
/// S3-compatible object bucket (Rook ObjectBucketClaim / ceph-bucket by default).
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct ManagedBucket {
|
||||
pub name: String,
|
||||
pub storage_class: String,
|
||||
/// Rook `additionalConfig.maxSize` (e.g. `"10G"`).
|
||||
pub max_size: String,
|
||||
/// Public/browser endpoint override (e.g. context `object_storage_endpoint`).
|
||||
/// When set, app credentials use this URL instead of cluster-internal RGW DNS.
|
||||
pub endpoint: Option<String>,
|
||||
/// Public app endpoints whose origins are allowed by bucket CORS.
|
||||
pub cors: Vec<PublicEndpointRef>,
|
||||
}
|
||||
|
||||
impl ManagedBucket {
|
||||
pub fn new(name: impl Into<String>) -> Self {
|
||||
Self {
|
||||
name: name.into(),
|
||||
storage_class: "ceph-bucket".into(),
|
||||
max_size: "10G".into(),
|
||||
endpoint: None,
|
||||
cors: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn storage_class(mut self, storage_class: impl Into<String>) -> Self {
|
||||
self.storage_class = storage_class.into();
|
||||
self
|
||||
}
|
||||
|
||||
pub fn max_size(mut self, max_size: impl Into<String>) -> Self {
|
||||
self.max_size = max_size.into();
|
||||
self
|
||||
}
|
||||
|
||||
pub fn endpoint(mut self, endpoint: impl Into<String>) -> Self {
|
||||
self.endpoint = Some(endpoint.into());
|
||||
self
|
||||
}
|
||||
|
||||
pub fn cors(mut self, endpoint: PublicEndpointRef) -> Self {
|
||||
self.cors.push(endpoint);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn reference(&self) -> BucketRef {
|
||||
BucketRef(self.name.clone())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize)]
|
||||
pub struct BucketRef(pub(crate) String);
|
||||
|
||||
impl BucketRef {
|
||||
pub fn new(name: impl Into<String>) -> Self {
|
||||
Self(name.into())
|
||||
}
|
||||
pub fn name(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
pub fn endpoint(&self) -> ValueRef {
|
||||
ValueRef::BucketEndpoint(self.clone())
|
||||
}
|
||||
pub fn bucket(&self) -> ValueRef {
|
||||
ValueRef::BucketName(self.clone())
|
||||
}
|
||||
pub fn access_key(&self) -> ValueRef {
|
||||
ValueRef::BucketAccessKey(self.clone())
|
||||
}
|
||||
pub fn secret_key(&self) -> ValueRef {
|
||||
ValueRef::BucketSecretKey(self.clone())
|
||||
}
|
||||
pub fn region(&self) -> ValueRef {
|
||||
ValueRef::BucketRegion(self.clone())
|
||||
}
|
||||
pub fn path_style(&self) -> ValueRef {
|
||||
ValueRef::BucketPathStyle(self.clone())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct ManagedPostgres {
|
||||
pub name: String,
|
||||
pub instances: u32,
|
||||
pub version: Option<String>,
|
||||
/// Companion Service `{name}-rw-debug` for VPN/debug (NodePort toggle).
|
||||
/// Default **off** (`ClusterIP`). In the OKD console set `spec.type: NodePort` to enable.
|
||||
/// Ships preserve live type/nodePort. (Not a TLS Route: PG16 lacks direct TLS/SNI for routers.)
|
||||
pub debug_route: bool,
|
||||
}
|
||||
|
||||
impl ManagedPostgres {
|
||||
pub fn new(name: impl Into<String>) -> Self {
|
||||
Self {
|
||||
name: name.into(),
|
||||
instances: 1,
|
||||
version: None,
|
||||
debug_route: false,
|
||||
}
|
||||
}
|
||||
pub fn instances(mut self, instances: u32) -> Self {
|
||||
self.instances = instances;
|
||||
self
|
||||
}
|
||||
/// PostgreSQL major/minor tag (e.g. `"16"` / `"16.4"`) or a full
|
||||
/// container image. Mapped to CNPG `spec.imageName`.
|
||||
pub fn version(mut self, version: impl Into<String>) -> Self {
|
||||
self.version = Some(version.into());
|
||||
self
|
||||
}
|
||||
/// Declare a debug Service (ClusterIP off / NodePort on in the console).
|
||||
pub fn debug_route(mut self) -> Self {
|
||||
self.debug_route = true;
|
||||
self
|
||||
}
|
||||
pub fn reference(&self) -> DatabaseRef {
|
||||
DatabaseRef(self.name.clone())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize)]
|
||||
pub struct DatabaseRef(pub(crate) String);
|
||||
|
||||
impl DatabaseRef {
|
||||
pub fn new(name: impl Into<String>) -> Self {
|
||||
Self(name.into())
|
||||
}
|
||||
pub fn name(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
pub fn jdbc_url(&self) -> ValueRef {
|
||||
ValueRef::DatabaseJdbcUrl(self.clone())
|
||||
}
|
||||
pub fn username(&self) -> ValueRef {
|
||||
ValueRef::DatabaseUsername(self.clone())
|
||||
}
|
||||
pub fn password(&self) -> ValueRef {
|
||||
ValueRef::DatabasePassword(self.clone())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct ManagedZitadel {
|
||||
pub name: String,
|
||||
pub endpoint: PublicEndpointRef,
|
||||
pub version: String,
|
||||
pub contract: ZitadelContract,
|
||||
pub redirects: Vec<OidcRedirect>,
|
||||
}
|
||||
|
||||
impl ManagedZitadel {
|
||||
pub fn new(name: impl Into<String>, endpoint: PublicEndpointRef) -> Self {
|
||||
Self {
|
||||
name: name.into(),
|
||||
endpoint,
|
||||
version: "v4.12.1".to_string(),
|
||||
contract: ZitadelContract::default(),
|
||||
redirects: Vec::new(),
|
||||
}
|
||||
}
|
||||
pub fn version(mut self, version: impl Into<String>) -> Self {
|
||||
self.version = version.into();
|
||||
self
|
||||
}
|
||||
pub fn contract(mut self, contract: ZitadelContract) -> Self {
|
||||
self.contract = contract;
|
||||
self
|
||||
}
|
||||
pub fn redirect(
|
||||
mut self,
|
||||
application: ZitadelApplicationRef,
|
||||
endpoint: PublicEndpointRef,
|
||||
path: impl Into<String>,
|
||||
) -> Self {
|
||||
self.redirects.push(OidcRedirect {
|
||||
application,
|
||||
endpoint,
|
||||
path: path.into(),
|
||||
post_logout: false,
|
||||
});
|
||||
self
|
||||
}
|
||||
pub fn post_logout(
|
||||
mut self,
|
||||
application: ZitadelApplicationRef,
|
||||
endpoint: PublicEndpointRef,
|
||||
path: impl Into<String>,
|
||||
) -> Self {
|
||||
self.redirects.push(OidcRedirect {
|
||||
application,
|
||||
endpoint,
|
||||
path: path.into(),
|
||||
post_logout: true,
|
||||
});
|
||||
self
|
||||
}
|
||||
pub fn reference(&self) -> ZitadelRef {
|
||||
ZitadelRef(self.name.clone())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize)]
|
||||
pub struct ZitadelRef(pub(crate) String);
|
||||
|
||||
impl ZitadelRef {
|
||||
pub fn new(name: impl Into<String>) -> Self {
|
||||
Self(name.into())
|
||||
}
|
||||
pub fn name(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
pub fn project_id(&self, project: ZitadelProjectRef) -> ValueRef {
|
||||
ValueRef::OidcProjectId {
|
||||
zitadel: self.clone(),
|
||||
project,
|
||||
}
|
||||
}
|
||||
pub fn oidc_client_id(&self, application: ZitadelApplicationRef) -> ValueRef {
|
||||
ValueRef::OidcClientId {
|
||||
zitadel: self.clone(),
|
||||
application,
|
||||
}
|
||||
}
|
||||
pub fn machine_client_id(&self, machine: ZitadelMachineRef) -> ValueRef {
|
||||
ValueRef::MachineClientId {
|
||||
zitadel: self.clone(),
|
||||
machine,
|
||||
}
|
||||
}
|
||||
pub fn machine_client_secret(&self, machine: ZitadelMachineRef) -> ValueRef {
|
||||
ValueRef::MachineClientSecret {
|
||||
zitadel: self.clone(),
|
||||
machine,
|
||||
}
|
||||
}
|
||||
pub fn machine_json_key(&self, machine: ZitadelMachineRef, path: impl Into<String>) -> FileRef {
|
||||
FileRef::MachineJsonKey {
|
||||
zitadel: self.clone(),
|
||||
machine,
|
||||
path: path.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct OidcRedirect {
|
||||
pub application: ZitadelApplicationRef,
|
||||
pub endpoint: PublicEndpointRef,
|
||||
pub path: String,
|
||||
pub post_logout: bool,
|
||||
}
|
||||
@@ -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(),
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,386 +0,0 @@
|
||||
//! Composable **capabilities** — the `.with(...)` menu. A capability
|
||||
//! contributes its own Scores (e.g. a database) and injects env into the
|
||||
//! app's containers, **wired by k8s reference** (service DNS, `secretKeyRef`),
|
||||
//! so generated secrets never pass through Harmony and a published chart stays
|
||||
//! value-free. This is the framework-level, discoverable home: `Postgres`
|
||||
//! today; `Monitoring` and `ZitadelAuth` next.
|
||||
//!
|
||||
//! To add one: implement [`Capability`] — `scores()` (what it deploys) and/or
|
||||
//! `env()` (how the app reaches it, by reference) — then `app.with(MyThing)`.
|
||||
|
||||
use harmony::modules::monitoring::alert_rule::prometheus_alert_rule::{
|
||||
AlertManagerRuleGroup, PrometheusAlertRule,
|
||||
};
|
||||
use harmony::modules::monitoring::kube_prometheus::helm_prometheus_alert_score::HelmPrometheusAlertingScore;
|
||||
use harmony::modules::monitoring::kube_prometheus::prometheus::KubePrometheus;
|
||||
use harmony::modules::postgresql::K8sPostgreSQLScore;
|
||||
use harmony::modules::postgresql::capability::PostgreSQLConfig;
|
||||
use harmony::modules::zitadel::{
|
||||
ZitadelAppType, ZitadelApplication, ZitadelClientIdExportScore, ZitadelSetupScore,
|
||||
};
|
||||
use harmony::score::Score;
|
||||
use harmony::topology::oberservability::monitoring::AlertReceiver;
|
||||
use harmony::topology::tenant::TenantManager;
|
||||
use harmony::topology::{HelmCommand, K8sclient, Topology};
|
||||
use k8s_openapi::api::core::v1::{ConfigMapKeySelector, EnvVar, EnvVarSource, SecretKeySelector};
|
||||
|
||||
use crate::Profile;
|
||||
|
||||
/// What a capability needs to know about the app it augments.
|
||||
pub struct AppRef<'a> {
|
||||
pub name: &'a str,
|
||||
pub namespace: &'a str,
|
||||
pub profile: Profile,
|
||||
}
|
||||
|
||||
/// An add-on a deployment declares with `.with(...)`: it deploys its own
|
||||
/// Scores and injects env wired by k8s reference, never by value. **Generic
|
||||
/// over the target `Topology`** — a concrete capability is implemented only
|
||||
/// for the topologies whose capabilities can host its Scores, so `.with(X)`
|
||||
/// is a compile error on a topology that can't run `X`.
|
||||
pub trait Capability<T: Topology>: Send + Sync {
|
||||
fn scores(&self, _app: &AppRef) -> Vec<Box<dyn Score<T>>> {
|
||||
vec![]
|
||||
}
|
||||
fn env(&self, _app: &AppRef) -> Vec<EnvVar> {
|
||||
vec![]
|
||||
}
|
||||
}
|
||||
|
||||
/// A managed PostgreSQL database (CNPG). Deploys a cluster `<app>-db` and
|
||||
/// wires `DATABASE_URL` into the app from CNPG's generated `<app>-db-app`
|
||||
/// secret — by reference, so the password never passes through Harmony.
|
||||
pub struct Postgres {
|
||||
instances: u32,
|
||||
}
|
||||
|
||||
impl Postgres {
|
||||
pub fn managed() -> Self {
|
||||
Self { instances: 1 }
|
||||
}
|
||||
pub fn instances(mut self, n: u32) -> Self {
|
||||
self.instances = n;
|
||||
self
|
||||
}
|
||||
fn cluster(app: &AppRef) -> String {
|
||||
format!("{}-db", app.name)
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: Topology + K8sclient + HelmCommand + 'static> Capability<T> for Postgres {
|
||||
fn scores(&self, app: &AppRef) -> Vec<Box<dyn Score<T>>> {
|
||||
let config = PostgreSQLConfig {
|
||||
cluster_name: Self::cluster(app),
|
||||
namespace: app.namespace.to_string(),
|
||||
instances: self.instances,
|
||||
..Default::default()
|
||||
};
|
||||
vec![Box::new(K8sPostgreSQLScore { config })]
|
||||
}
|
||||
|
||||
fn env(&self, app: &AppRef) -> Vec<EnvVar> {
|
||||
vec![EnvVar {
|
||||
name: "DATABASE_URL".to_string(),
|
||||
value_from: Some(EnvVarSource {
|
||||
secret_key_ref: Some(SecretKeySelector {
|
||||
name: format!("{}-app", Self::cluster(app)),
|
||||
key: "uri".to_string(),
|
||||
optional: Some(true),
|
||||
}),
|
||||
..Default::default()
|
||||
}),
|
||||
..Default::default()
|
||||
}]
|
||||
}
|
||||
}
|
||||
|
||||
/// Application monitoring: a downtime alert routed to the given receivers.
|
||||
/// The "is it up?" signal is kube-state-metrics deployment availability —
|
||||
/// scoped to the app's namespace, so it needs no app-side `/metrics` and no
|
||||
/// ServiceMonitor wiring. (kube-prometheus must be present on the cluster.)
|
||||
pub struct Monitoring {
|
||||
receivers: Vec<Box<dyn AlertReceiver<KubePrometheus>>>,
|
||||
}
|
||||
|
||||
impl Default for Monitoring {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl Monitoring {
|
||||
pub fn new() -> Self {
|
||||
Self { receivers: vec![] }
|
||||
}
|
||||
/// Route alerts to a receiver (e.g. `DiscordWebhook`). Chainable.
|
||||
pub fn alert(mut self, receiver: impl AlertReceiver<KubePrometheus> + 'static) -> Self {
|
||||
self.receivers.push(Box::new(receiver));
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// The downtime alert for an app: fires when its namespace has a Deployment
|
||||
/// with no available replicas for 5m. Pure + testable.
|
||||
fn downtime_alert(app: &AppRef) -> PrometheusAlertRule {
|
||||
PrometheusAlertRule::new(
|
||||
&format!("{}Down", app.name),
|
||||
&format!(
|
||||
"kube_deployment_status_replicas_available{{namespace=\"{}\"}} == 0",
|
||||
app.namespace
|
||||
),
|
||||
)
|
||||
.for_duration("5m")
|
||||
.label("severity", "critical")
|
||||
.annotation(
|
||||
"summary",
|
||||
&format!("{} has no available replicas", app.name),
|
||||
)
|
||||
}
|
||||
|
||||
impl<T: Topology + HelmCommand + TenantManager> Capability<T> for Monitoring {
|
||||
fn scores(&self, app: &AppRef) -> Vec<Box<dyn Score<T>>> {
|
||||
let group = AlertManagerRuleGroup::new(
|
||||
&format!("{}-availability", app.name),
|
||||
vec![downtime_alert(app)],
|
||||
);
|
||||
vec![Box::new(HelmPrometheusAlertingScore {
|
||||
receivers: self.receivers.iter().map(|r| r.clone_box()).collect(),
|
||||
rules: vec![Box::new(group)],
|
||||
service_monitors: vec![],
|
||||
})]
|
||||
}
|
||||
}
|
||||
|
||||
/// OIDC login via the tenant's Zitadel — a PKCE public client (no secret),
|
||||
/// for SPA/web frontends. Provisions the app in Zitadel, publishes its
|
||||
/// `client_id` to the `<app>-oidc` ConfigMap, and injects `OIDC_ISSUER`
|
||||
/// (plain) + `OIDC_CLIENT_ID` (by `configMapKeyRef`) into the app — wired by
|
||||
/// reference. For other config values, keep using `.secret(...)`.
|
||||
pub struct ZitadelAuth {
|
||||
issuer: String,
|
||||
project: Option<String>,
|
||||
redirect_uris: Vec<String>,
|
||||
post_logout_redirect_uris: Vec<String>,
|
||||
endpoint: Option<String>,
|
||||
pat_namespace: String,
|
||||
}
|
||||
|
||||
impl ZitadelAuth {
|
||||
/// `issuer` is the app-facing OIDC issuer URL (e.g. the tenant's Zitadel).
|
||||
pub fn oidc(issuer: impl Into<String>) -> Self {
|
||||
Self {
|
||||
issuer: issuer.into(),
|
||||
project: None,
|
||||
redirect_uris: vec![],
|
||||
post_logout_redirect_uris: vec![],
|
||||
endpoint: None,
|
||||
pat_namespace: "zitadel".to_string(),
|
||||
}
|
||||
}
|
||||
/// Zitadel project to create the app in (defaults to the app name).
|
||||
pub fn project(mut self, project: impl Into<String>) -> Self {
|
||||
self.project = Some(project.into());
|
||||
self
|
||||
}
|
||||
pub fn redirect(mut self, uri: impl Into<String>) -> Self {
|
||||
self.redirect_uris.push(uri.into());
|
||||
self
|
||||
}
|
||||
pub fn post_logout(mut self, uri: impl Into<String>) -> Self {
|
||||
self.post_logout_redirect_uris.push(uri.into());
|
||||
self
|
||||
}
|
||||
/// Override how the provisioner reaches Zitadel's API (e.g. a local
|
||||
/// `http://localhost:8080`); defaults to the issuer.
|
||||
pub fn endpoint(mut self, endpoint: impl Into<String>) -> Self {
|
||||
self.endpoint = Some(endpoint.into());
|
||||
self
|
||||
}
|
||||
/// Namespace holding the `iam-admin-pat` secret (default `zitadel`).
|
||||
pub fn pat_namespace(mut self, ns: impl Into<String>) -> Self {
|
||||
self.pat_namespace = ns.into();
|
||||
self
|
||||
}
|
||||
|
||||
fn configmap(app: &AppRef) -> String {
|
||||
format!("{}-oidc", app.name)
|
||||
}
|
||||
}
|
||||
|
||||
/// Hostname (no scheme/port/path) for a URL — Zitadel's `Host:` header.
|
||||
fn host_of(url: &str) -> String {
|
||||
let after_scheme = url.split_once("://").map(|(_, r)| r).unwrap_or(url);
|
||||
after_scheme
|
||||
.split('/')
|
||||
.next()
|
||||
.unwrap_or(after_scheme)
|
||||
.split(':')
|
||||
.next()
|
||||
.unwrap_or(after_scheme)
|
||||
.to_string()
|
||||
}
|
||||
|
||||
impl<T: Topology + K8sclient> Capability<T> for ZitadelAuth {
|
||||
fn scores(&self, app: &AppRef) -> Vec<Box<dyn Score<T>>> {
|
||||
let project = self.project.clone().unwrap_or_else(|| app.name.to_string());
|
||||
let setup = ZitadelSetupScore {
|
||||
host: host_of(self.endpoint.as_deref().unwrap_or(&self.issuer)),
|
||||
scheme: Default::default(),
|
||||
port: None,
|
||||
skip_tls: false,
|
||||
endpoint: self.endpoint.clone(),
|
||||
namespace: self.pat_namespace.clone(),
|
||||
admin_org_id: None,
|
||||
applications: vec![ZitadelApplication {
|
||||
project_name: project,
|
||||
app_name: app.name.to_string(),
|
||||
app_type: ZitadelAppType::WebPkce {
|
||||
redirect_uris: self.redirect_uris.clone(),
|
||||
post_logout_redirect_uris: self.post_logout_redirect_uris.clone(),
|
||||
// TODO this should be configurable
|
||||
id_token_role_assertion: false,
|
||||
},
|
||||
}],
|
||||
api_apps: vec![],
|
||||
roles: vec![],
|
||||
machine_users: vec![],
|
||||
groups_claim_action: false,
|
||||
..Default::default()
|
||||
};
|
||||
let export = ZitadelClientIdExportScore {
|
||||
app_name: app.name.to_string(),
|
||||
configmap_name: Self::configmap(app),
|
||||
namespace: app.namespace.to_string(),
|
||||
key: "OIDC_CLIENT_ID".to_string(),
|
||||
};
|
||||
vec![Box::new(setup), Box::new(export)]
|
||||
}
|
||||
|
||||
fn env(&self, app: &AppRef) -> Vec<EnvVar> {
|
||||
vec![
|
||||
EnvVar {
|
||||
name: "OIDC_ISSUER".to_string(),
|
||||
value: Some(self.issuer.clone()),
|
||||
..Default::default()
|
||||
},
|
||||
EnvVar {
|
||||
name: "OIDC_CLIENT_ID".to_string(),
|
||||
value_from: Some(EnvVarSource {
|
||||
config_map_key_ref: Some(ConfigMapKeySelector {
|
||||
name: Self::configmap(app),
|
||||
key: "OIDC_CLIENT_ID".to_string(),
|
||||
optional: Some(true),
|
||||
}),
|
||||
..Default::default()
|
||||
}),
|
||||
..Default::default()
|
||||
},
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use harmony::topology::K8sAnywhereTopology;
|
||||
|
||||
// Pin a concrete topology for the (topology-independent) assertions.
|
||||
fn scores_of(
|
||||
c: &impl Capability<K8sAnywhereTopology>,
|
||||
a: &AppRef,
|
||||
) -> Vec<Box<dyn Score<K8sAnywhereTopology>>> {
|
||||
c.scores(a)
|
||||
}
|
||||
fn env_of(c: &impl Capability<K8sAnywhereTopology>, a: &AppRef) -> Vec<EnvVar> {
|
||||
c.env(a)
|
||||
}
|
||||
|
||||
fn app() -> AppRef<'static> {
|
||||
AppRef {
|
||||
name: "ts",
|
||||
namespace: "ts",
|
||||
profile: Profile::Local,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn postgres_wires_database_url_by_reference() {
|
||||
let env = env_of(&Postgres::managed(), &app());
|
||||
let db = env.iter().find(|e| e.name == "DATABASE_URL").unwrap();
|
||||
let sel = db
|
||||
.value_from
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.secret_key_ref
|
||||
.as_ref()
|
||||
.unwrap();
|
||||
assert_eq!(sel.name, "ts-db-app", "CNPG app secret, by convention");
|
||||
assert_eq!(sel.key, "uri");
|
||||
assert!(db.value.is_none(), "wired by reference, never a value");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn postgres_contributes_a_cluster_score() {
|
||||
let scores = scores_of(&Postgres::managed(), &app());
|
||||
assert_eq!(scores.len(), 1);
|
||||
assert!(scores[0].name().contains("PostgreSQL"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn monitoring_downtime_alert_is_namespace_scoped() {
|
||||
let rule = downtime_alert(&app());
|
||||
assert!(
|
||||
rule.expr.contains("namespace=\"ts\""),
|
||||
"scoped to the app namespace: {}",
|
||||
rule.expr
|
||||
);
|
||||
assert!(rule.expr.contains("== 0"), "fires on zero replicas");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn monitoring_contributes_one_alerting_score() {
|
||||
let scores = scores_of(&Monitoring::new(), &app());
|
||||
assert_eq!(scores.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zitadel_auth_wires_issuer_plain_and_client_id_by_reference() {
|
||||
let env = env_of(
|
||||
&ZitadelAuth::oidc("https://sso.example").redirect("https://ts.example/callback"),
|
||||
&app(),
|
||||
);
|
||||
let issuer = env.iter().find(|e| e.name == "OIDC_ISSUER").unwrap();
|
||||
assert_eq!(issuer.value.as_deref(), Some("https://sso.example"));
|
||||
|
||||
let cid = env.iter().find(|e| e.name == "OIDC_CLIENT_ID").unwrap();
|
||||
let sel = cid
|
||||
.value_from
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.config_map_key_ref
|
||||
.as_ref()
|
||||
.unwrap();
|
||||
assert_eq!(sel.name, "ts-oidc");
|
||||
assert_eq!(sel.key, "OIDC_CLIENT_ID");
|
||||
assert!(cid.value.is_none(), "client_id is referenced, not inlined");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zitadel_auth_provisions_then_exports() {
|
||||
// Two scores, in order: provision the OIDC app, then publish its
|
||||
// client_id to the ConfigMap.
|
||||
let scores = scores_of(&ZitadelAuth::oidc("https://sso.example"), &app());
|
||||
assert_eq!(scores.len(), 2);
|
||||
assert!(scores[0].name().contains("ZitadelSetup"));
|
||||
assert!(scores[1].name().contains("ZitadelClientIdExport"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn host_of_strips_scheme_port_path() {
|
||||
assert_eq!(
|
||||
host_of("https://sso.nationtech.io/oauth"),
|
||||
"sso.nationtech.io"
|
||||
);
|
||||
assert_eq!(host_of("http://localhost:8080"), "localhost");
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user