review/2026-09-08 #349
18
Cargo.lock
generated
18
Cargo.lock
generated
@@ -2532,18 +2532,6 @@ dependencies = [
|
|||||||
"syn 3.0.3",
|
"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]]
|
[[package]]
|
||||||
name = "dockerfile_builder"
|
name = "dockerfile_builder"
|
||||||
version = "0.1.6"
|
version = "0.1.6"
|
||||||
@@ -2838,10 +2826,13 @@ name = "example-compose-java-react"
|
|||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
|
"async-trait",
|
||||||
|
"harmony",
|
||||||
"harmony_app",
|
"harmony_app",
|
||||||
"harmony_cli",
|
"harmony_cli",
|
||||||
"harmony_macros",
|
"harmony_macros",
|
||||||
"harmony_types",
|
"harmony_types",
|
||||||
|
"serde_json",
|
||||||
"tokio",
|
"tokio",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -4295,15 +4286,12 @@ version = "0.1.0"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"docker-compose-types",
|
|
||||||
"fqdn",
|
|
||||||
"harmony",
|
"harmony",
|
||||||
"harmony-k8s",
|
"harmony-k8s",
|
||||||
"harmony_config",
|
"harmony_config",
|
||||||
"harmony_types",
|
"harmony_types",
|
||||||
"k8s-openapi",
|
"k8s-openapi",
|
||||||
"log",
|
"log",
|
||||||
"reqwest 0.12.28",
|
|
||||||
"schemars 0.8.22",
|
"schemars 0.8.22",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
|||||||
@@ -1,5 +1,12 @@
|
|||||||
[workspace]
|
[workspace]
|
||||||
resolver = "2"
|
resolver = "2"
|
||||||
|
exclude = [
|
||||||
|
"examples/dx_accepts_kind",
|
||||||
|
"examples/dx_bind_origin",
|
||||||
|
"examples/dx_guide_ship",
|
||||||
|
"examples/dx_slot_secret",
|
||||||
|
"examples/notes",
|
||||||
|
]
|
||||||
members = [
|
members = [
|
||||||
"examples/*",
|
"examples/*",
|
||||||
"harmony",
|
"harmony",
|
||||||
|
|||||||
@@ -71,11 +71,9 @@ APIs are not accepted designs.
|
|||||||
|
|
||||||
Verified against current code:
|
Verified against current code:
|
||||||
|
|
||||||
- The short `ComposeDeploy` example does not yet cover the real application,
|
- Apps implement `HarmonyApp` and compose Scores. `ComposeDeploy` /
|
||||||
which still assembles lower-level Scores.
|
`Application` are gone. The Java+React example does not yet create a
|
||||||
- `ComposeDeploy` supports one public endpoint. Capability environment wiring
|
Service or Ingress (`K8sDeploymentScore` is image+env only).
|
||||||
applies to every Compose service, PostgreSQL exports one fixed variable, and
|
|
||||||
the current Zitadel capability covers only a simple PKCE application.
|
|
||||||
- The real deploy still duplicates namespace-derived names and some provider
|
- The real deploy still duplicates namespace-derived names and some provider
|
||||||
conventions. Those are concrete DRY problems.
|
conventions. Those are concrete DRY problems.
|
||||||
- The application layer does not yet prove its full readiness claim; Helm
|
- 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
|
- **ADR-023** — Deploy architecture: Scores everywhere (including
|
||||||
tests), per-app `*-deploy` crates, deploy blocks on smoke-test,
|
tests), per-app `*-deploy` crates, deploy blocks on smoke-test,
|
||||||
topologies are compile-time.
|
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/`.
|
The full ADR set lives under `docs/adr/`.
|
||||||
|
|
||||||
|
|||||||
@@ -34,7 +34,7 @@
|
|||||||
- [Developer Guide](./guides/developer-guide.md)
|
- [Developer Guide](./guides/developer-guide.md)
|
||||||
- [Application CLI — Use Cases & Commands](./guides/application-cli.md)
|
- [Application CLI — Use Cases & Commands](./guides/application-cli.md)
|
||||||
- [Harmony Auth CLI](./guides/harmony-auth-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 Score](./guides/writing-a-score.md)
|
||||||
- [Writing a Topology](./guides/writing-a-topology.md)
|
- [Writing a Topology](./guides/writing-a-topology.md)
|
||||||
- [Adding Capabilities](./guides/adding-capabilities.md)
|
- [Adding Capabilities](./guides/adding-capabilities.md)
|
||||||
@@ -84,4 +84,5 @@
|
|||||||
- [025-3 · Groups as the Security Boundary](./adr/025-device-secret-access/025-3-groups-as-security-boundary.md)
|
- [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)
|
- [026 · Application Lifecycle CLI](./adr/026-application-lifecycle-cli.md)
|
||||||
- [027 · Multi-Tenant Cloud Identity](./adr/027-multi-tenant-identity.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
|
## 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
|
Extends ADR-023 (deploy architecture) — whose CLI principle (8) only
|
||||||
covers *how binaries are discovered* — with the CLI's **experience
|
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 |
|
| [026](./026-application-lifecycle-cli.md) | Application Lifecycle CLI | Accepted |
|
||||||
| [027](./027-multi-tenant-identity.md) | Multi-Tenant Cloud Identity | Accepted |
|
| [027](./027-multi-tenant-identity.md) | Multi-Tenant Cloud Identity | Accepted |
|
||||||
| [028](./028-typed-score-references.md) | Typed Score References | Proposed |
|
| [028](./028-typed-score-references.md) | Typed Score References | Proposed |
|
||||||
|
| [029](./029-application-components.md) | Application Components — One File, One Type | Proposed |
|
||||||
|
|
||||||
## Contributing
|
## Contributing
|
||||||
|
|
||||||
|
|||||||
@@ -1,14 +1,12 @@
|
|||||||
# Capabilities Catalog (Topology)
|
# Capabilities Catalog (Topology)
|
||||||
|
|
||||||
> **Note:** this page lists **Topology** capabilities — what a *cluster* can
|
> **Note:** this page lists **Topology** capabilities — what a *cluster* can
|
||||||
> do, exposed as trait bounds a `Score` requires. For **application**
|
> do, exposed as trait bounds a `Score` requires. Apps compose Scores; see
|
||||||
> capabilities — add-ons you attach to an app with `.with(...)` (databases,
|
> [Application Scores](../guides/application-capabilities.md).
|
||||||
> monitoring, …) — see
|
|
||||||
> [Application Capabilities](../guides/application-capabilities.md).
|
|
||||||
|
|
||||||
A `Capability` is a specific feature or API that a `Topology` offers. `Interpret` logic uses these capabilities to execute a `Score`.
|
A `Capability` is a specific feature or API that a `Topology` offers. `Interpret` logic uses these capabilities to execute a `Score`.
|
||||||
|
|
||||||
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-->
|
<!--toc:start-->
|
||||||
|
|
||||||
|
|||||||
@@ -1,94 +1,20 @@
|
|||||||
# Application Capabilities — `.with(...)`
|
# Application Scores
|
||||||
|
|
||||||
> **Status: in progress (feature branch).** The `harmony_app` application
|
Apps implement [`HarmonyApp`](../../harmony_app/src/app.rs) and return Scores.
|
||||||
> layer and capabilities described here are landing incrementally. The
|
There is no `.with(...)` capability menu and no `ComposeDeploy` /
|
||||||
> *decisions and rationale* live in
|
`Application` god-object.
|
||||||
> [ADR-026](../adr/026-application-lifecycle-cli.md); this is the "how".
|
|
||||||
> Companion to [Application CLI](./application-cli.md).
|
|
||||||
|
|
||||||
A **capability** is an add-on you attach to an app deployment — a database,
|
Compose the Scores that already exist: `K8sPostgreSQLScore`,
|
||||||
monitoring, auth — by declaring it:
|
`FleetDeploymentScore`, `HostProcessScore`, `K8sDeploymentScore`, …
|
||||||
|
|
||||||
```rust
|
```rust
|
||||||
ComposeDeploy::from_dir("timesheet", "./app")?
|
async fn scores(&self, ctx: &AppContext, images: &ImageRefs) -> Result<Vec<Box<dyn Score<T>>>, AppError> {
|
||||||
.expose("frontend", "timesheet.example")
|
Ok(vec![
|
||||||
.with(Postgres::managed()) // a managed database
|
Box::new(K8sPostgreSQLScore::new(ctx.namespace()).cluster_name("app-db")),
|
||||||
.with(Monitoring::new().alert(discord)); // a downtime alert
|
Box::new(K8sDeploymentScore { /* … */ }),
|
||||||
```
|
])
|
||||||
|
|
||||||
The app author *declares intent*; the capability deploys what it needs and
|
|
||||||
wires itself to the app. Everything else — `ship`/`deploy`/`status`/`logs`,
|
|
||||||
contexts, profiles — comes from the [application CLI](./application-cli.md).
|
|
||||||
|
|
||||||
> **Two different "capabilities" — don't confuse them.**
|
|
||||||
> - **Topology capabilities** ([catalog](../catalogs/capabilities.md)) are what
|
|
||||||
> a *cluster* can do — `K8sClient`, `DnsServer`, `HelmCommand` — exposed as
|
|
||||||
> trait bounds a `Score` requires. Infrastructure-facing.
|
|
||||||
> - **Application capabilities** (this page) are add-ons a *developer* attaches
|
|
||||||
> to their app with `.with(...)`. They live in `harmony_app::capabilities`.
|
|
||||||
|
|
||||||
## The menu
|
|
||||||
|
|
||||||
| Capability | Declares | Deploys | Wires into the app |
|
|
||||||
|---|---|---|---|
|
|
||||||
| `Postgres::managed()` | a managed PostgreSQL | a CNPG cluster `<app>-db` | `DATABASE_URL` ← `secretKeyRef(<app>-db-app, uri)` |
|
|
||||||
| `Monitoring::new().alert(r)` | a downtime alert | a Prometheus alert rule + routes it to `r` | nothing (selector-scoped to the app's namespace) |
|
|
||||||
|
|
||||||
(`ZitadelAuth` for OIDC login is next.)
|
|
||||||
|
|
||||||
## How wiring works — by reference, never by value
|
|
||||||
|
|
||||||
A capability never passes a *value* through Harmony. Postgres' generated
|
|
||||||
password lives only in the CNPG-created `<app>-db-app` Secret; the capability
|
|
||||||
injects an env var that **references** it (`secretKeyRef`), resolved in-cluster
|
|
||||||
at runtime. This is what keeps the rendered chart publishable (it contains a
|
|
||||||
reference, not a secret) and is why the app layer needs no typed Score outputs
|
|
||||||
today (ADR-026). The trade: wiring must be expressible as a stable k8s
|
|
||||||
reference (service DNS, Secret/ConfigMap key) — true for k8s-native add-ons.
|
|
||||||
|
|
||||||
## Writing your own capability
|
|
||||||
|
|
||||||
A capability implements one small trait
|
|
||||||
(`harmony_app::capabilities::Capability`):
|
|
||||||
|
|
||||||
```rust
|
|
||||||
pub trait Capability {
|
|
||||||
/// What this capability deploys (a database, an operator, …).
|
|
||||||
fn scores(&self, app: &AppRef) -> Vec<Box<dyn Score<K8sAnywhereTopology>>> { vec![] }
|
|
||||||
/// Env injected into the app's containers, wired by reference.
|
|
||||||
fn env(&self, app: &AppRef) -> Vec<EnvVar> { vec![] }
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
`AppRef { name, namespace, profile }` is how the capability learns who it's
|
See [Application CLI](./application-cli.md) for `ship` / `deploy` / `status` /
|
||||||
augmenting (and so derives names like `<app>-db`). Implement either method or
|
`logs`. Authoring DX (one component, one type) is ADR-029.
|
||||||
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.
|
|
||||||
|
|||||||
@@ -1,13 +1,16 @@
|
|||||||
# Harmony Application CLI — Use Cases & Commands
|
# Harmony Application CLI — Use Cases & Commands
|
||||||
|
|
||||||
> **Status: partially implemented.** The app lifecycle provides build,
|
> **Status: partially implemented.** A `HarmonyApp` composes Scores; the CLI
|
||||||
> publish, ship, deploy, status, and logs. Other verbs below remain the design
|
> parses argv, resolves `--context`, and calls `ship` / `deploy` / `status` /
|
||||||
> target. The *decisions and rationale*
|
> `logs`. Other verbs below remain the design target. See
|
||||||
> live in [ADR-026](../adr/026-application-lifecycle-cli.md) — read that
|
> [ADR-026](../adr/026-application-lifecycle-cli.md).
|
||||||
> for the "why"; this doc is the "what" and "how".
|
|
||||||
|
|
||||||
## Mental model
|
## 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:
|
Four ideas carry the whole CLI:
|
||||||
|
|
||||||
- **`harmony <scope> <verb>`.** A small fixed set of scope nouns —
|
- **`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
|
- `RegistryCredentials` in the context namespace, using a push-scoped registry
|
||||||
robot account;
|
robot account;
|
||||||
- Kubernetes cluster access for the tenant namespace;
|
- Kubernetes cluster access for the tenant namespace;
|
||||||
- application secrets;
|
- application secrets and pull-only registry credentials under
|
||||||
- pull-only registry credentials under
|
`<device-prefix>/<deployment>/<key>` (same folder; the agent reads
|
||||||
`<device-prefix>/registry/device-pull/<reference>`.
|
`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
|
Each device pull secret is JSON scoped to one registry authority. The authority
|
||||||
includes the port when the registry uses one:
|
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 |
|
| 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 |
|
| 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
|
The Rust manifest places only an `image_pull_secret` **key name** in each
|
||||||
service. The operator adds exact referenced pull-secret paths to that
|
private service (no slashes). That key lives in the deployment subtree the
|
||||||
Deployment's OpenBao policy. The agent reads the credential only when Podman
|
operator already grants (`<device-prefix>/<deployment>/*`). The agent reads
|
||||||
needs to pull a missing image and sends it through Podman's registry-auth
|
the credential only when Podman needs to pull a missing image and sends it
|
||||||
header. It does not run `podman login`, write an auth file, or place credentials
|
through Podman's registry-auth header. It does not run `podman login`, write
|
||||||
in desired state, container environment, labels, or logs.
|
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
|
The registry should provide separate repository-scoped robot accounts for push
|
||||||
and pull. For the first hosted deployments, those repositories can live under
|
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 |
|
| Example | Description | Local K3D | Existing Cluster | Hardware Needed |
|
||||||
|---------|-------------|:---------:|:----------------:|:---------------:|
|
|---------|-------------|:---------:|:----------------:|:---------------:|
|
||||||
| `postgresql` | Deploy a PostgreSQL cluster | ✅ | ✅ | — |
|
| `postgresql` | Deploy a PostgreSQL cluster | ✅ | ✅ | — |
|
||||||
|
| `notes` | ADR-029 frontend+backend+Postgres (`localdev` / `cluster`) | ✅ | — | — |
|
||||||
| `ntfy` | Deploy ntfy notification server | ✅ | ✅ | — |
|
| `ntfy` | Deploy ntfy notification server | ✅ | ✅ | — |
|
||||||
| `tenant` | Create a multi-tenant namespace | ✅ | ✅ | — |
|
| `tenant` | Create a multi-tenant namespace | ✅ | ✅ | — |
|
||||||
| `cert_manager` | Provision TLS certificates | ✅ | ✅ | — |
|
| `cert_manager` | Provision TLS certificates | ✅ | ✅ | — |
|
||||||
@@ -126,6 +127,23 @@ export HARMONY_AUTOINSTALL=false
|
|||||||
cargo run -p example-monitoring
|
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
|
## 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.
|
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
|
version.workspace = true
|
||||||
readme.workspace = true
|
readme.workspace = true
|
||||||
license.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]]
|
[[bin]]
|
||||||
name = "compose-deploy"
|
name = "compose-deploy"
|
||||||
path = "src/main.rs"
|
path = "src/main.rs"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
harmony = { path = "../../harmony" }
|
||||||
harmony_app = { path = "../../harmony_app" }
|
harmony_app = { path = "../../harmony_app" }
|
||||||
harmony_cli = { path = "../../harmony_cli" }
|
harmony_cli = { path = "../../harmony_cli" }
|
||||||
harmony_macros = { path = "../../harmony_macros" }
|
harmony_macros = { path = "../../harmony_macros" }
|
||||||
harmony_types = { path = "../../harmony_types" }
|
harmony_types = { path = "../../harmony_types" }
|
||||||
anyhow = { workspace = true }
|
anyhow = { workspace = true }
|
||||||
|
async-trait.workspace = true
|
||||||
|
serde_json.workspace = true
|
||||||
tokio = { workspace = true, features = ["full"] }
|
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
|
A thin [`HarmonyApp`](../../harmony_app) that composes existing Scores:
|
||||||
**importing its `docker-compose.yml`** — no hand-written manifests, no Argo.
|
`K8sPostgreSQLScore` and `K8sDeploymentScore` for backend and frontend.
|
||||||
The compose file stays the source of truth for the app's *base* shape;
|
Images come from `app/backend` and `app/frontend`. The `docker-compose.yml`
|
||||||
deploy-only concerns are typed Rust.
|
is the original local shape; Harmony does not import it.
|
||||||
|
|
||||||
```
|
|
||||||
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)
|
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
cd examples/compose_java_react
|
cargo run --bin compose-deploy -- ship --context local
|
||||||
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
|
|
||||||
```
|
```
|
||||||
|
|
||||||
The binary accepts only contexts compiled into `main.rs`. A context is always
|
`--context` is required. `K8sDeploymentScore` does not create a Service or
|
||||||
required, so omitting `--context` and `HARMONY_CONTEXT` is an error.
|
Ingress, so in-cluster DNS (`BACKEND_URL=http://backend:8080`) and public
|
||||||
|
expose are holes until those Scores exist.
|
||||||
**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`.
|
|
||||||
|
|||||||
@@ -1,8 +1,4 @@
|
|||||||
# The customer's existing docker-compose — the single source of truth for
|
# Original local compose. HarmonyApp does not import this file.
|
||||||
# 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.
|
|
||||||
name: timesheet
|
name: timesheet
|
||||||
services:
|
services:
|
||||||
backend:
|
backend:
|
||||||
|
|||||||
@@ -1,26 +1,81 @@
|
|||||||
//! `compose-deploy` — deploy this Java+React app to any context.
|
//! Timesheet: a thin [`HarmonyApp`] that composes existing Scores.
|
||||||
//!
|
|
||||||
//! 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.
|
|
||||||
|
|
||||||
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 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]
|
#[tokio::main]
|
||||||
async fn main() -> anyhow::Result<()> {
|
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(
|
harmony_cli::app::app_main(
|
||||||
app,
|
Timesheet,
|
||||||
ContextCatalog::new([Context {
|
ContextCatalog::new([Context {
|
||||||
name: context_name!("local"),
|
name: context_name!("local"),
|
||||||
namespace: "timesheet".parse()?,
|
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")]
|
#[arg(long, requires = "openbao_secret_prefix")]
|
||||||
openbao_url: Option<String>,
|
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")]
|
#[arg(long, requires = "openbao_url")]
|
||||||
openbao_secret_prefix: Option<String>,
|
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 anyhow::{Result, anyhow, bail};
|
||||||
use futures_util::StreamExt;
|
use futures_util::StreamExt;
|
||||||
use harmony_reconciler_contracts::{
|
use harmony_reconciler_contracts::{PodmanService, PodmanV0Score, RestartPolicy, VolumeMount};
|
||||||
DEVICE_PULL_SECRET_PATH, PodmanService, PodmanV0Score, RestartPolicy, VolumeMount,
|
|
||||||
};
|
|
||||||
use harmony_secret::SecretStore;
|
use harmony_secret::SecretStore;
|
||||||
use oci_client::Reference;
|
use oci_client::Reference;
|
||||||
use podman_api::Podman;
|
use podman_api::Podman;
|
||||||
@@ -420,13 +418,13 @@ impl PodmanRuntime {
|
|||||||
service.name
|
service.name
|
||||||
)
|
)
|
||||||
})?;
|
})?;
|
||||||
let namespace = format!("{}/{}", source.prefix, DEVICE_PULL_SECRET_PATH);
|
let namespace = format!("{}/{deployment}", source.prefix);
|
||||||
let bytes = source
|
let bytes = source
|
||||||
.store
|
.store
|
||||||
.get_raw(&namespace, reference)
|
.get_raw(&namespace, reference)
|
||||||
.await
|
.await
|
||||||
.map_err(|error| {
|
.map_err(|error| {
|
||||||
anyhow!("fetching image pull secret '{reference}': {error}")
|
anyhow!("fetching image pull secret '{namespace}/{reference}': {error}")
|
||||||
})?;
|
})?;
|
||||||
Some(
|
Some(
|
||||||
serde_json::from_slice::<RegistryCredential>(&bytes).map_err(|error| {
|
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 std::path::PathBuf;
|
||||||
|
|
||||||
use anyhow::{Context, bail};
|
use anyhow::Context;
|
||||||
use clap::{Parser, Subcommand};
|
use clap::{Parser, Subcommand};
|
||||||
use harmony_app::{PublicationTopology, publish::build_images};
|
use harmony_app::{PublicationTopology, publish::build_images};
|
||||||
use harmony_fleet_deploy::FleetApp;
|
use harmony_fleet_deploy::{FleetApp, publish_agent_artifact};
|
||||||
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::errors::{OciDistributionError, OciErrorCode};
|
||||||
use oci_client::manifest::OciImageManifest;
|
|
||||||
use oci_client::secrets::RegistryAuth;
|
use oci_client::secrets::RegistryAuth;
|
||||||
use oci_client::{Client, Reference};
|
use oci_client::{Client, Reference};
|
||||||
|
|
||||||
@@ -46,7 +41,13 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
binary,
|
binary,
|
||||||
reference,
|
reference,
|
||||||
version,
|
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(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
if !existing.is_empty() {
|
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)?;
|
let refs = build_images(&images, ®istry)?;
|
||||||
@@ -97,76 +100,6 @@ async fn publish_control(version: &str, push: bool) -> anyhow::Result<()> {
|
|||||||
Ok(())
|
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> {
|
fn registry_auth() -> anyhow::Result<RegistryAuth> {
|
||||||
let credentials = registry_credentials()?;
|
let credentials = registry_credentials()?;
|
||||||
Ok(RegistryAuth::Basic(credentials.username, credentials.token))
|
Ok(RegistryAuth::Basic(credentials.username, credentials.token))
|
||||||
|
|||||||
@@ -11,12 +11,16 @@
|
|||||||
//! it does not own provider operations or a Fleet-wide aggregate Score.
|
//! it does not own provider operations or a Fleet-wide aggregate Score.
|
||||||
|
|
||||||
pub mod agent;
|
pub mod agent;
|
||||||
|
pub mod agent_artifact;
|
||||||
|
mod agent_release;
|
||||||
mod app;
|
mod app;
|
||||||
mod deployment;
|
mod deployment;
|
||||||
mod device_setup;
|
mod device_setup;
|
||||||
pub mod operator;
|
pub mod operator;
|
||||||
|
|
||||||
pub use agent::{FleetAgentScore, PodTarget};
|
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 app::{FleetApp, FleetCrdsApp, FleetTenantProvisionApp};
|
||||||
pub use deployment::FleetDeploymentScore;
|
pub use deployment::FleetDeploymentScore;
|
||||||
pub use device_setup::{
|
pub use device_setup::{
|
||||||
|
|||||||
@@ -32,9 +32,14 @@ Other:
|
|||||||
-h, --help
|
-h, --help
|
||||||
|
|
||||||
Environment equivalents:
|
Environment equivalents:
|
||||||
CONTROL_VERSION, AGENT_VERSION, AGENT_ARCH, FLEET_DEPLOY_MANIFEST,
|
CONTROL_VERSION, AGENT_VERSION, AGENT_ARCH, FLEET_DEPLOY_MANIFEST,
|
||||||
FLEET_DEPLOY_BIN, FLEET_CONTEXT, FLEET_NAMESPACE, KUBECTL_CONTEXT,
|
FLEET_DEPLOY_BIN, FLEET_CONTEXT, FLEET_NAMESPACE, KUBECTL_CONTEXT,
|
||||||
REGISTRY_USER, REGISTRY_TOKEN, OPENBAO_TOKEN, RUST_LOG.
|
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
|
EOF
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -102,7 +107,11 @@ require_command sha256sum
|
|||||||
require_command stat
|
require_command stat
|
||||||
[[ -z "$control_version" ]] || require_command docker
|
[[ -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_USER:?REGISTRY_USER is required for publication}"
|
||||||
: "${REGISTRY_TOKEN:?REGISTRY_TOKEN is required for publication}"
|
: "${REGISTRY_TOKEN:?REGISTRY_TOKEN is required for publication}"
|
||||||
fi
|
fi
|
||||||
@@ -145,11 +154,18 @@ callout_ref=""
|
|||||||
if [[ -n "$agent_version" ]]; then
|
if [[ -n "$agent_version" ]]; then
|
||||||
printf '==> Publishing %s\n' "$agent_tag_ref"
|
printf '==> Publishing %s\n' "$agent_tag_ref"
|
||||||
agent_release_log="$tmp/agent-release.log"
|
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}" \
|
RUST_LOG="${RUST_LOG:-info}" \
|
||||||
cargo run --release -p harmony-fleet-deploy --bin harmony-fleet-release -- \
|
cargo run --release --manifest-path "$deploy_manifest" --bin release-agent -- \
|
||||||
agent --binary "$agent_binary" --reference "$agent_tag_ref" \
|
--binary "$agent_binary" --version "$agent_version" --arch "$agent_arch" \
|
||||||
--version "$agent_version" | tee "$agent_release_log"
|
| 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="$(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"
|
[[ "$agent_oci_ref" =~ @sha256:[0-9a-f]{64}$ ]] || fail "agent digest missing from release output"
|
||||||
fi
|
fi
|
||||||
|
|||||||
@@ -45,8 +45,8 @@ pub use kv::{
|
|||||||
system_upgrade_status_key,
|
system_upgrade_status_key,
|
||||||
};
|
};
|
||||||
pub use podman::{
|
pub use podman::{
|
||||||
DEVICE_PULL_SECRET_PATH, EnvVar, PodmanService, PodmanV0Score, ReconcileScore, RestartPolicy,
|
EnvVar, PodmanService, PodmanV0Score, ReconcileScore, RestartPolicy, SecretEnvVar, VolumeMount,
|
||||||
SecretEnvVar, VolumeMount, validate_image_pull_secret_reference,
|
validate_image_pull_secret_reference,
|
||||||
};
|
};
|
||||||
pub use status::{InventorySnapshot, Phase};
|
pub use status::{InventorySnapshot, Phase};
|
||||||
pub use system_upgrade::{
|
pub use system_upgrade::{
|
||||||
|
|||||||
@@ -2,8 +2,6 @@ use schemars::JsonSchema;
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use sha2::{Digest, Sha256};
|
use sha2::{Digest, Sha256};
|
||||||
|
|
||||||
pub const DEVICE_PULL_SECRET_PATH: &str = "registry/device-pull";
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
|
||||||
pub struct EnvVar {
|
pub struct EnvVar {
|
||||||
pub name: String,
|
pub name: String,
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ edition = "2024"
|
|||||||
version.workspace = true
|
version.workspace = true
|
||||||
readme.workspace = true
|
readme.workspace = true
|
||||||
license.workspace = true
|
license.workspace = true
|
||||||
|
description = "Infrastructure orchestration: Score, Topology, Interpret. Source of truth for applying desired state."
|
||||||
|
|
||||||
[features]
|
[features]
|
||||||
default = ["podman"]
|
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;
|
mod domain;
|
||||||
pub use domain::*;
|
pub use domain::*;
|
||||||
pub mod infra;
|
pub mod infra;
|
||||||
|
|||||||
@@ -6,11 +6,9 @@ use serde::Serialize;
|
|||||||
|
|
||||||
use crate::{executors::ExecutorError, topology::Topology};
|
use crate::{executors::ExecutorError, topology::Topology};
|
||||||
|
|
||||||
/// **Deprecated** (see [`ApplicationScore`](super::ApplicationScore)). Superseded
|
/// **Deprecated** (see [`ApplicationScore`](super::ApplicationScore)).
|
||||||
/// by the `harmony_app` application layer + `.with(...)` capabilities (ADR-026);
|
/// Superseded by `HarmonyApp` composing Scores (ADR-029). The trait itself
|
||||||
/// see `docs/guides/application-capabilities.md`. The trait itself is not yet
|
/// is not yet `#[deprecated]` to avoid warning every internal impl.
|
||||||
/// `#[deprecated]` to avoid warning every internal impl, but it should not be
|
|
||||||
/// used in new code.
|
|
||||||
///
|
///
|
||||||
/// An ApplicationFeature provided by harmony, such as Backups, Monitoring, MultisiteAvailability,
|
/// An ApplicationFeature provided by harmony, such as Backups, Monitoring, MultisiteAvailability,
|
||||||
/// ContinuousIntegration, ContinuousDelivery
|
/// ContinuousIntegration, ContinuousDelivery
|
||||||
|
|||||||
@@ -36,12 +36,8 @@ use schemars::JsonSchema;
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
/// **Deprecated.** Use `harmony_app::capabilities::Monitoring` via
|
/// **Deprecated.** Compose monitoring Scores from a `HarmonyApp` (ADR-029).
|
||||||
/// `.with(Monitoring::new().alert(...))` (ADR-026). See
|
#[deprecated(note = "Use harmony_app::HarmonyApp and compose Scores")]
|
||||||
/// `docs/guides/application-capabilities.md`.
|
|
||||||
#[deprecated(
|
|
||||||
note = "Use harmony_app::capabilities::Monitoring (.with(...)). See docs/guides/application-capabilities.md"
|
|
||||||
)]
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct Monitoring {
|
pub struct Monitoring {
|
||||||
pub application: Arc<dyn Application>,
|
pub application: Arc<dyn Application>,
|
||||||
|
|||||||
@@ -50,12 +50,8 @@ use crate::{
|
|||||||
/// - Harbor as artifact registru
|
/// - Harbor as artifact registru
|
||||||
/// - ArgoCD to install/upgrade/rollback/inspect k8s resources
|
/// - ArgoCD to install/upgrade/rollback/inspect k8s resources
|
||||||
/// - Kubernetes for runtime orchestration
|
/// - Kubernetes for runtime orchestration
|
||||||
/// **Deprecated.** Use the `harmony_app` application layer — `ComposeDeploy`
|
/// **Deprecated.** Use `HarmonyApp` and compose Scores (ADR-029).
|
||||||
/// publishes + deploys, capabilities attach add-ons (ADR-026). See
|
#[deprecated(note = "Use harmony_app::HarmonyApp and compose Scores")]
|
||||||
/// `docs/guides/application-capabilities.md`.
|
|
||||||
#[deprecated(
|
|
||||||
note = "Use harmony_app: ComposeDeploy + .with(...) capabilities. See docs/guides/application-capabilities.md"
|
|
||||||
)]
|
|
||||||
#[derive(Debug, Default, Clone)]
|
#[derive(Debug, Default, Clone)]
|
||||||
pub struct PackagingDeployment<A: OCICompliant + HelmPackage> {
|
pub struct PackagingDeployment<A: OCICompliant + HelmPackage> {
|
||||||
pub application: Arc<A>,
|
pub application: Arc<A>,
|
||||||
|
|||||||
@@ -20,14 +20,8 @@ use crate::{score::Score, topology::Topology};
|
|||||||
|
|
||||||
use super::{Application, ApplicationFeature, ApplicationInterpret, HelmPackage, OCICompliant};
|
use super::{Application, ApplicationFeature, ApplicationInterpret, HelmPackage, OCICompliant};
|
||||||
|
|
||||||
/// **Deprecated.** Use the `harmony_app` application layer —
|
/// **Deprecated.** Use `HarmonyApp` and compose Scores (ADR-029).
|
||||||
/// `ComposeDeploy`/`HarmonyApp` + `.with(...)` capabilities (ADR-026). See
|
#[deprecated(note = "Use harmony_app::HarmonyApp and compose Scores")]
|
||||||
/// `docs/guides/application-capabilities.md`. The feature-menu model couples
|
|
||||||
/// delivery to a fixed feature set and the cancelled ArgoCD path and does not
|
|
||||||
/// compose with plain Scores.
|
|
||||||
#[deprecated(
|
|
||||||
note = "Use harmony_app: ComposeDeploy/HarmonyApp + .with(...) capabilities (ADR-026). See docs/guides/application-capabilities.md"
|
|
||||||
)]
|
|
||||||
#[derive(Debug, Serialize, Clone)]
|
#[derive(Debug, Serialize, Clone)]
|
||||||
pub struct ApplicationScore<A: Application + Serialize, T: Topology + Clone + Serialize>
|
pub struct ApplicationScore<A: Application + Serialize, T: Topology + Clone + Serialize>
|
||||||
where
|
where
|
||||||
|
|||||||
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 fleet;
|
||||||
pub mod github_runner;
|
pub mod github_runner;
|
||||||
pub mod helm;
|
pub mod helm;
|
||||||
|
pub mod host_process;
|
||||||
pub mod http;
|
pub mod http;
|
||||||
pub mod inventory;
|
pub mod inventory;
|
||||||
pub mod k3d;
|
pub mod k3d;
|
||||||
|
|||||||
@@ -33,6 +33,9 @@ use crate::topology::{K8sclient, Topology};
|
|||||||
pub struct PostgresDebugRouteScore {
|
pub struct PostgresDebugRouteScore {
|
||||||
pub namespace: String,
|
pub namespace: String,
|
||||||
pub cluster_name: 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 {
|
impl PostgresDebugRouteScore {
|
||||||
@@ -40,9 +43,15 @@ impl PostgresDebugRouteScore {
|
|||||||
Self {
|
Self {
|
||||||
namespace: namespace.into(),
|
namespace: namespace.into(),
|
||||||
cluster_name: cluster_name.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 {
|
fn service_name(&self) -> String {
|
||||||
format!("{}-rw-debug", self.cluster_name)
|
format!("{}-rw-debug", self.cluster_name)
|
||||||
}
|
}
|
||||||
@@ -111,6 +120,7 @@ impl<T: Topology + K8sclient> Interpret<T> for PostgresDebugRouteInterpret {
|
|||||||
.and_then(|p| p.node_port);
|
.and_then(|p| p.node_port);
|
||||||
(t, np)
|
(t, np)
|
||||||
}
|
}
|
||||||
|
Ok(None) if self.score.create_as_node_port => ("NodePort".into(), None),
|
||||||
Ok(None) => ("ClusterIP".into(), None),
|
Ok(None) => ("ClusterIP".into(), None),
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
return Err(InterpretError::new(format!(
|
return Err(InterpretError::new(format!(
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
//! A reusable Score that materializes a `kubernetes.io/dockerconfigjson` Secret
|
//! A reusable Score that materializes a `kubernetes.io/dockerconfigjson` Secret
|
||||||
//! so the kubelet can pull an app's images from a private registry.
|
//! 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
|
//! The Secret is referenced by name from each pod's `imagePullSecrets`. Keep
|
||||||
//! `harmony_app`'s `DeployConfig::image_pull_secrets`). Keep the credentials
|
//! the credentials **pull-only** and load them from a vault (e.g. OpenBao
|
||||||
//! **pull-only** and load them from a vault (e.g. OpenBao `DeploySecrets`) — they
|
//! `DeploySecrets`) — they never belong in code. The Secret is namespaced, so a
|
||||||
//! never belong in code or chart values. The Secret is namespaced, so a
|
|
||||||
//! namespace-scoped deployer can apply it without any cluster RBAC.
|
//! namespace-scoped deployer can apply it without any cluster RBAC.
|
||||||
//!
|
//!
|
||||||
//! ```ignore
|
//! ```ignore
|
||||||
@@ -15,7 +14,7 @@
|
|||||||
//! username: pull_user,
|
//! username: pull_user,
|
||||||
//! token: pull_token,
|
//! 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;
|
use std::collections::BTreeMap;
|
||||||
|
|||||||
@@ -2,8 +2,9 @@
|
|||||||
name = "harmony_app"
|
name = "harmony_app"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
version.workspace = true
|
version.workspace = true
|
||||||
readme.workspace = true
|
readme = "README.md"
|
||||||
license.workspace = true
|
license.workspace = true
|
||||||
|
description = "Application delivery: HarmonyApp composes Scores; ship/deploy interpret them. Not infrastructure orchestration."
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
anyhow.workspace = true
|
anyhow.workspace = true
|
||||||
@@ -19,8 +20,5 @@ serde_yaml = { workspace = true }
|
|||||||
schemars = "0.8"
|
schemars = "0.8"
|
||||||
tempfile.workspace = true
|
tempfile.workspace = true
|
||||||
log.workspace = true
|
log.workspace = true
|
||||||
reqwest.workspace = true
|
|
||||||
k8s-openapi.workspace = true
|
k8s-openapi.workspace = true
|
||||||
thiserror.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) ----
|
// ---- structured results (rendered by the front-end, never printed here) ----
|
||||||
|
|
||||||
#[derive(Debug, Clone, serde::Serialize)]
|
#[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