feat/declarative-application-deployment #345
@@ -93,6 +93,27 @@ The opposite failure is one function or type owning unrelated reasons to
|
|||||||
change. Split by concern when the parts have different callers, lifecycles,
|
change. Split by concern when the parts have different callers, lifecycles,
|
||||||
trust boundaries, or tests. Do not split merely to shorten a function.
|
trust boundaries, or tests. Do not split merely to shorten a function.
|
||||||
|
|
||||||
|
### Misplaced knowledge
|
||||||
|
|
||||||
|
Architecture follows information ownership, not call-site convenience. Put
|
||||||
|
each fact in the layer whose reason to change it:
|
||||||
|
|
||||||
|
- domain and policy code state intent and invariants;
|
||||||
|
- adapters own provider syntax, protocol details, paths, and storage layout;
|
||||||
|
- composition roots select and wire abstractions without recreating backend
|
||||||
|
construction or deriving provider-specific coordinates;
|
||||||
|
- neutral value-type crates contain shared vocabulary, not constants belonging
|
||||||
|
to one backend.
|
||||||
|
|
||||||
|
Ask which module should change when the provider, path layout, authorization
|
||||||
|
syntax, or source chain changes. That module owns the knowledge. Warning signs
|
||||||
|
include raw HCL, SQL, manifests, or provider paths in policy code; storage
|
||||||
|
constants in generic types; the same authenticated adapter constructed twice
|
||||||
|
for different views; and application code rebuilding a source chain already
|
||||||
|
owned by infrastructure. Move the mechanism to the deepest existing owner and
|
||||||
|
expose the smallest operation or view the caller needs. Keep product choices in
|
||||||
|
the application layer; moving everything downward is also misplaced ownership.
|
||||||
|
|
||||||
### Duplicate knowledge
|
### Duplicate knowledge
|
||||||
|
|
||||||
Two copies of the same condition, default, mapping, or deployment recipe in one
|
Two copies of the same condition, default, mapping, or deployment recipe in one
|
||||||
@@ -148,6 +169,7 @@ After tests pass, inspect only the diff and ask:
|
|||||||
5. Did test setup reimplement production behavior?
|
5. Did test setup reimplement production behavior?
|
||||||
6. Can control flow become linear and local?
|
6. Can control flow become linear and local?
|
||||||
7. Does every changed file need to be in this PR?
|
7. Does every changed file need to be in this PR?
|
||||||
|
8. Does each fact live where its reason to change lives?
|
||||||
|
|
||||||
Rewrite when the answer exposes accidental complexity. Run verification again.
|
Rewrite when the answer exposes accidental complexity. Run verification again.
|
||||||
|
|
||||||
|
|||||||
1930
Cargo.lock
generated
1930
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -1,12 +1,12 @@
|
|||||||
# Harmony Auth CLI
|
# Harmony Auth CLI
|
||||||
|
|
||||||
> **Status: read-only commands implemented.** Mutation commands follow the
|
> **Status:** read-only inspection and ADR-027 tenant identity provisioning are
|
||||||
> ADR-027 group migration.
|
> implemented. Identity grant and revoke commands remain pending.
|
||||||
|
|
||||||
`harmony-auth` inspects and manages the relationship between Zitadel
|
`harmony-auth` inspects access and provisions tenants across Zitadel, OpenBao,
|
||||||
identities and OpenBao access. It presents tenants, identities, and Harmony
|
and Kubernetes. It presents tenants, identities, and Harmony permissions first.
|
||||||
permissions first. JWT roles, policy names, subject claims, and HCL remain
|
JWT roles, policy names, subject claims, and HCL remain available through
|
||||||
available through advanced output.
|
advanced output.
|
||||||
|
|
||||||
The CLI is the preferred interface while the web UI matures. Both interfaces
|
The CLI is the preferred interface while the web UI matures. Both interfaces
|
||||||
use the same `harmony_auth` operations and return the same effective access.
|
use the same `harmony_auth` operations and return the same effective access.
|
||||||
@@ -33,43 +33,61 @@ shown as imported access. The CLI does not rename, rewrite, or hide them.
|
|||||||
|
|
||||||
```text
|
```text
|
||||||
harmony-auth
|
harmony-auth
|
||||||
|
├── context configure
|
||||||
├── connection check
|
├── connection check
|
||||||
├── identity list
|
├── identity list
|
||||||
├── identity show <subject-id>
|
├── identity show <subject-id>
|
||||||
├── tenant list
|
├── tenant list
|
||||||
|
├── tenant create <tenant>
|
||||||
|
├── tenant deployer create <tenant> <account>
|
||||||
└── tenant show <tenant>
|
└── tenant show <tenant>
|
||||||
```
|
```
|
||||||
|
|
||||||
There are no flat aliases. `harmony-auth list` is not valid.
|
There are no flat aliases. `harmony-auth list` is not valid.
|
||||||
|
|
||||||
## Connection and credentials
|
## Context and credentials
|
||||||
|
|
||||||
Every command except `--help` and `--version` requires:
|
Every command selects one named environment through `--context` or
|
||||||
|
`HARMONY_CONTEXT`:
|
||||||
| Flag | Environment | Meaning |
|
|
||||||
|---|---|---|
|
|
||||||
| `--zitadel-url` | `ZITADEL_URL` | Zitadel base URL |
|
|
||||||
| `--openbao-url` | `OPENBAO_URL` | OpenBao base URL |
|
|
||||||
| none | `ZITADEL_PAT` | Zitadel service-account PAT |
|
|
||||||
| none | `OPENBAO_TOKEN` | Temporary OpenBao administrator token |
|
|
||||||
|
|
||||||
Secrets are environment-only because command-line arguments remain in shell
|
|
||||||
history and may be visible in the process list. Secret values never appear in
|
|
||||||
help output, normal output, JSON, or logs.
|
|
||||||
|
|
||||||
Example:
|
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
export ZITADEL_URL=https://sso.example.com
|
harmony-auth connection check --context prod
|
||||||
export ZITADEL_PAT=...
|
```
|
||||||
export OPENBAO_URL=https://secrets.example.com
|
|
||||||
export OPENBAO_TOKEN=...
|
|
||||||
|
|
||||||
|
Configure the context once:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
harmony-auth context configure --context prod
|
||||||
|
```
|
||||||
|
|
||||||
|
The guided setup collects the complete context in one session:
|
||||||
|
|
||||||
|
| Field | Meaning |
|
||||||
|
|---|---|
|
||||||
|
| `zitadel_url` | Zitadel issuer and Management API URL |
|
||||||
|
| `zitadel_pat` | Zitadel administrator PAT |
|
||||||
|
| `zitadel_project` | Existing Zitadel project that contains tenant roles |
|
||||||
|
| `openbao_url` | OpenBao API URL |
|
||||||
|
| `openbao_token` | OpenBao administrator token |
|
||||||
|
| `kubeconfig` | Administrator kubeconfig path |
|
||||||
|
| `kube_context` | Exact administrator kube context |
|
||||||
|
|
||||||
|
Harmony Config stores the profile at
|
||||||
|
`<config-dir>/contexts/<context>/HarmonyAuthContext.json`. The file is mode
|
||||||
|
`0600`; PAT and token prompts are masked. Each answer is saved immediately, so
|
||||||
|
rerunning `context configure` after an interruption resumes at the first missing
|
||||||
|
field. Commands either load the complete profile or report the context as
|
||||||
|
unconfigured. They do not fail through a sequence of missing provider
|
||||||
|
environment variables.
|
||||||
|
|
||||||
|
`HARMONY_CONTEXT` can set a shell's default context:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
export HARMONY_CONTEXT=prod
|
||||||
harmony-auth connection check
|
harmony-auth connection check
|
||||||
```
|
```
|
||||||
|
|
||||||
The CLI does not persist profiles or credentials. Browser profile storage and
|
Secret values never appear in help output, normal output, JSON, or logs.
|
||||||
session credential refresh remain web UI concerns.
|
|
||||||
|
|
||||||
`connection check` attempts both backends even when one fails. It reports each
|
`connection check` attempts both backends even when one fails. It reports each
|
||||||
status without printing provider response bodies:
|
status without printing provider response bodies:
|
||||||
@@ -222,6 +240,126 @@ manual migration.
|
|||||||
|
|
||||||
## Tenant commands
|
## Tenant commands
|
||||||
|
|
||||||
|
### Create a tenant
|
||||||
|
|
||||||
|
`tenant create` configures owner access, secret access, resource limits,
|
||||||
|
network isolation, Harbor push credentials, and namespace-scoped deployment
|
||||||
|
credentials. It runs `TenantScore` and `TenantCredentialScore` against an
|
||||||
|
explicit administrator kube context.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
harmony-auth tenant create acme \
|
||||||
|
--context prod \
|
||||||
|
--owner acme-admin
|
||||||
|
```
|
||||||
|
|
||||||
|
Missing resource limits are prompted with defaults. Each answer is saved
|
||||||
|
immediately in the selected context's tenant draft, so an interrupted or
|
||||||
|
plan-only run resumes without repeating completed prompts. Later plans print
|
||||||
|
the saved limits. Applied values are stored in the authoritative tenant
|
||||||
|
definition under `harmony_auth/data/tenants/<tenant>`. Use flags such as
|
||||||
|
`--cpu-limit-cores` for unattended use.
|
||||||
|
|
||||||
|
Interactive applied runs request any missing Harbor push and pull credentials.
|
||||||
|
For unattended use, set `HARBOR_USERNAME`, `HARBOR_TOKEN`,
|
||||||
|
`HARBOR_PULL_USERNAME`, and `HARBOR_PULL_TOKEN`; tokens have no command-line
|
||||||
|
flags because process arguments are not secret-safe. Supplying either half of
|
||||||
|
one credential pair replaces that pair and requires both values. Harmony stores them at
|
||||||
|
`<OPENBAO_KV_MOUNT>/data/<tenant>/RegistryCredentials` and
|
||||||
|
`<OPENBAO_KV_MOUNT>/data/<tenant>/RegistryPullCredentials`. K8sAnywhere
|
||||||
|
`Application` deploys use the pull credentials to reconcile the context's
|
||||||
|
Kubernetes image pull Secret. Harbor must enforce the robot's pull-only scope.
|
||||||
|
Harbor SSO will replace the stored push credentials when registry federation is
|
||||||
|
available.
|
||||||
|
|
||||||
|
The selected Harmony context supplies the default kubeconfig path and kube
|
||||||
|
context. Command flags override those defaults for the current invocation;
|
||||||
|
local paths are not stored in the tenant definition. Owner usernames are bound
|
||||||
|
to the namespace deployer Role until OKD group claims are available.
|
||||||
|
|
||||||
|
For an OKD OpenID provider that maps Zitadel's `preferred_username` to the
|
||||||
|
OpenShift username, values passed through `--owner` must be OpenShift usernames
|
||||||
|
such as `acme-admin`, not email addresses unless the username itself is an
|
||||||
|
email.
|
||||||
|
|
||||||
|
The command only prints a plan unless `--apply` is set. Owner additions are
|
||||||
|
additive. Omitting an existing owner does not revoke access; identity revocation
|
||||||
|
remains a separate pending command.
|
||||||
|
|
||||||
|
Tenant creation does not grant viewer access. The tenant's secret subtree also
|
||||||
|
contains namespace deployment credentials, so viewer access remains disabled
|
||||||
|
until those credentials are separated.
|
||||||
|
|
||||||
|
The shared identity and secret-login baseline must exist before tenant
|
||||||
|
creation. Operators can override its defaults through hidden environment
|
||||||
|
configuration:
|
||||||
|
|
||||||
|
| Environment | Default | Meaning |
|
||||||
|
|---|---|---|
|
||||||
|
| `ZITADEL_ORG_ID` | PAT organization | Zitadel organization containing the project |
|
||||||
|
| `HARMONY_GROUPS_ACTION` | `harmonyGroupsClaim` | Action that adds tenant roles to tokens |
|
||||||
|
| `OPENBAO_KV_MOUNT` | `secret` | Tenant secret mount |
|
||||||
|
| `OPENBAO_JWT_AUTH_MOUNT` | `jwt` | Zitadel-backed OpenBao auth mount |
|
||||||
|
| `OPENBAO_JWT_ROLE` | auto-discovered | Shared login role; set only when more than one compatible role exists |
|
||||||
|
|
||||||
|
Use `--step-by-step` with `--apply` to pause after each completed component:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
harmony-auth tenant create acme \
|
||||||
|
--context prod \
|
||||||
|
--owner acme-admin \
|
||||||
|
--apply \
|
||||||
|
--step-by-step
|
||||||
|
```
|
||||||
|
|
||||||
|
The command pauses after baseline validation, tenant permissions, owner access,
|
||||||
|
secret access, stored tenant state, each Harbor credential pair, and between
|
||||||
|
the Kubernetes tenant and credential Scores. Each prompt names the operation
|
||||||
|
it will run next, including the target Zitadel project, OpenBao path, namespace,
|
||||||
|
and kube context where applicable. All completed operations are logged at
|
||||||
|
`INFO`, including detail between checkpoints. The administrator can test from
|
||||||
|
another terminal before approving the next operation. Declining stops safely;
|
||||||
|
rerunning the same command continues through idempotent operations. Interactive
|
||||||
|
mode cannot be combined with `--json` and requires a terminal.
|
||||||
|
|
||||||
|
### Create a CI deployer
|
||||||
|
|
||||||
|
CI deployer creation is separate from tenant creation:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
harmony-auth tenant deployer create acme acme-ci \
|
||||||
|
--context prod \
|
||||||
|
--credentials ./acme-ci-key.json
|
||||||
|
```
|
||||||
|
|
||||||
|
This prints a plan and verifies that the tenant exists, the shared baseline is
|
||||||
|
valid, and the account name is unused. Add `--apply` to create the account,
|
||||||
|
grant tenant deployer access, and write its one-time credentials:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
harmony-auth tenant deployer create acme acme-ci \
|
||||||
|
--context prod \
|
||||||
|
--credentials ./acme-ci-key.json \
|
||||||
|
--apply
|
||||||
|
```
|
||||||
|
|
||||||
|
The credentials file is created with mode `0600` and is never overwritten. The
|
||||||
|
command fails without changing access if the account already exists. If writing
|
||||||
|
the credentials fails after account creation, it deletes the new account; the
|
||||||
|
error reports if that cleanup also fails.
|
||||||
|
|
||||||
|
The deployer can read tenant inputs under
|
||||||
|
`<OPENBAO_KV_MOUNT>/data/<tenant>/*`. Harmony-generated durable state is kept
|
||||||
|
separately under
|
||||||
|
`<OPENBAO_KV_MOUNT>/data/<tenant>/harmony-state/*`, where the deployer can
|
||||||
|
create, read, and update values. It cannot write input credentials or other
|
||||||
|
tenant secrets.
|
||||||
|
|
||||||
|
Before deploying an updated Harmony application into an existing tenant, rerun
|
||||||
|
`tenant create --apply` with the updated `harmony-auth` binary. Policy
|
||||||
|
reconciliation must grant the state subpath before the application migrates any
|
||||||
|
legacy generated values from the tenant root.
|
||||||
|
|
||||||
### List tenants
|
### List tenants
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
@@ -275,10 +413,13 @@ The first release uses these result shapes:
|
|||||||
|
|
||||||
| Command | `result` fields |
|
| Command | `result` fields |
|
||||||
|---|---|
|
|---|---|
|
||||||
|
| `context configure` | `context`, `path` |
|
||||||
| `connection check` | `zitadel: { connected }`, `openbao: { connected }` |
|
| `connection check` | `zitadel: { connected }`, `openbao: { connected }` |
|
||||||
| `identity list` | `identities: [{ identity, access }]` |
|
| `identity list` | `identities: [{ identity, access }]` |
|
||||||
| `identity show` | `identity`, `access` |
|
| `identity show` | `identity`, `access` |
|
||||||
| `tenant list` | `tenants: [{ scope, humans, services }]` |
|
| `tenant list` | `tenants: [{ scope, humans, services }]` |
|
||||||
|
| `tenant create` | `tenant`, `applied` |
|
||||||
|
| `tenant deployer create` | `tenant`, `account`, `credentials`, `applied` |
|
||||||
| `tenant show` | `tenant`, `project`, `identities: [{ identity, access }]` |
|
| `tenant show` | `tenant`, `project`, `identities: [{ identity, access }]` |
|
||||||
|
|
||||||
`identity` contains `subject_id`, `kind`, `display_name`, `login_name`,
|
`identity` contains `subject_id`, `kind`, `display_name`, `login_name`,
|
||||||
@@ -358,6 +499,7 @@ harmony_auth_cli ─┘
|
|||||||
`harmony_auth_cli` owns:
|
`harmony_auth_cli` owns:
|
||||||
|
|
||||||
- Clap arguments and environment mapping
|
- Clap arguments and environment mapping
|
||||||
|
- named Harmony context profile loading
|
||||||
- terminal and JSON rendering
|
- terminal and JSON rendering
|
||||||
- binary exit codes and logging setup
|
- binary exit codes and logging setup
|
||||||
|
|
||||||
@@ -369,8 +511,12 @@ tenants, reconcile JWT roles, or implement permission rules.
|
|||||||
|
|
||||||
- Authorization discovers existing per-subject JWT roles as imported access.
|
- Authorization discovers existing per-subject JWT roles as imported access.
|
||||||
- Grant and revoke wait for Zitadel role and OpenBao external-group operations.
|
- Grant and revoke wait for Zitadel role and OpenBao external-group operations.
|
||||||
- The CLI does not create Zitadel identities.
|
- Tenant deployer creation can create a Zitadel machine identity; general
|
||||||
|
identity creation is not implemented.
|
||||||
- The CLI does not provide a generic OpenBao policy editor.
|
- The CLI does not provide a generic OpenBao policy editor.
|
||||||
|
- OKD group-to-RBAC mapping, read-only viewer RBAC, short-lived Kubernetes
|
||||||
|
credentials, and WireGuard identity linking remain deferred.
|
||||||
- Tenant administrators are not yet authenticated as constrained actors; the
|
- Tenant administrators are not yet authenticated as constrained actors; the
|
||||||
supplied OpenBao token determines backend authority.
|
supplied OpenBao token determines backend authority.
|
||||||
- The CLI does not store profiles or credentials.
|
- Context profiles are local mode-`0600` files; credential refresh and remote
|
||||||
|
profile synchronization are not implemented.
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ pub fn fleet_context() -> anyhow::Result<Context> {
|
|||||||
repository: oci_repository!("customer/fleet"),
|
repository: oci_repository!("customer/fleet"),
|
||||||
domain: domain!("fleet.example.com"),
|
domain: domain!("fleet.example.com"),
|
||||||
image_pull_secret: None,
|
image_pull_secret: None,
|
||||||
|
object_storage_endpoint: None,
|
||||||
access: tenant_access("fleet-deployer")?,
|
access: tenant_access("fleet-deployer")?,
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -127,6 +127,7 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
repository: "apps".parse()?,
|
repository: "apps".parse()?,
|
||||||
domain: "example.com".parse()?,
|
domain: "example.com".parse()?,
|
||||||
image_pull_secret: None,
|
image_pull_secret: None,
|
||||||
|
object_storage_endpoint: None,
|
||||||
access: OpenBaoClusterAccess {
|
access: OpenBaoClusterAccess {
|
||||||
namespace: "platform/example-app".parse()?,
|
namespace: "platform/example-app".parse()?,
|
||||||
url: "https://secrets.example.com".parse()?,
|
url: "https://secrets.example.com".parse()?,
|
||||||
|
|||||||
@@ -278,6 +278,8 @@ impl HarmonyApp<K8sAnywhereTopology> for FleetTenantProvisionApp {
|
|||||||
let source = harmony_config::openbao_source(
|
let source = harmony_config::openbao_source(
|
||||||
store.namespace.as_ref(),
|
store.namespace.as_ref(),
|
||||||
Some(store.url.to_string()),
|
Some(store.url.to_string()),
|
||||||
|
None,
|
||||||
|
None,
|
||||||
Some(store.zitadel_url.to_string()),
|
Some(store.zitadel_url.to_string()),
|
||||||
Some(store.zitadel_audience.to_string()),
|
Some(store.zitadel_audience.to_string()),
|
||||||
Some(store.role.to_string()),
|
Some(store.role.to_string()),
|
||||||
@@ -450,6 +452,7 @@ mod tests {
|
|||||||
repository: "harmony".parse().unwrap(),
|
repository: "harmony".parse().unwrap(),
|
||||||
domain: "fleet.example.com".parse().unwrap(),
|
domain: "fleet.example.com".parse().unwrap(),
|
||||||
image_pull_secret: None,
|
image_pull_secret: None,
|
||||||
|
object_storage_endpoint: None,
|
||||||
access: OpenBaoClusterAccess {
|
access: OpenBaoClusterAccess {
|
||||||
namespace: "customer/fleet".parse().unwrap(),
|
namespace: "customer/fleet".parse().unwrap(),
|
||||||
url: "https://secrets.example.com".parse().unwrap(),
|
url: "https://secrets.example.com".parse().unwrap(),
|
||||||
|
|||||||
@@ -152,6 +152,18 @@ impl K8sClient {
|
|||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Server-side apply without serializing the resource into logs. Use for
|
||||||
|
/// generated credential objects whose `Debug`/`Serialize` output contains
|
||||||
|
/// secret material.
|
||||||
|
pub async fn apply_redacted<K>(&self, resource: &K, namespace: Option<&str>) -> Result<K, Error>
|
||||||
|
where
|
||||||
|
K: Resource + Clone + std::fmt::Debug + DeserializeOwned + Serialize,
|
||||||
|
<K as Resource>::DynamicType: Default,
|
||||||
|
{
|
||||||
|
self.apply_with_strategy_inner(resource, namespace, WriteMode::CreateOrUpdate, true)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
/// POST only — returns an error if the resource already exists.
|
/// POST only — returns an error if the resource already exists.
|
||||||
pub async fn create<K>(&self, resource: &K, namespace: Option<&str>) -> Result<K, Error>
|
pub async fn create<K>(&self, resource: &K, namespace: Option<&str>) -> Result<K, Error>
|
||||||
where
|
where
|
||||||
@@ -178,6 +190,21 @@ impl K8sClient {
|
|||||||
namespace: Option<&str>,
|
namespace: Option<&str>,
|
||||||
write_mode: WriteMode,
|
write_mode: WriteMode,
|
||||||
) -> Result<K, Error>
|
) -> Result<K, Error>
|
||||||
|
where
|
||||||
|
K: Resource + Clone + std::fmt::Debug + DeserializeOwned + Serialize,
|
||||||
|
<K as Resource>::DynamicType: Default,
|
||||||
|
{
|
||||||
|
self.apply_with_strategy_inner(resource, namespace, write_mode, false)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn apply_with_strategy_inner<K>(
|
||||||
|
&self,
|
||||||
|
resource: &K,
|
||||||
|
namespace: Option<&str>,
|
||||||
|
write_mode: WriteMode,
|
||||||
|
redact: bool,
|
||||||
|
) -> Result<K, Error>
|
||||||
where
|
where
|
||||||
K: Resource + Clone + std::fmt::Debug + DeserializeOwned + Serialize,
|
K: Resource + Clone + std::fmt::Debug + DeserializeOwned + Serialize,
|
||||||
<K as Resource>::DynamicType: Default,
|
<K as Resource>::DynamicType: Default,
|
||||||
@@ -187,7 +214,9 @@ impl K8sClient {
|
|||||||
resource.meta().name,
|
resource.meta().name,
|
||||||
namespace
|
namespace
|
||||||
);
|
);
|
||||||
trace!("{:#}", serde_json::to_value(resource).unwrap_or_default());
|
if !redact {
|
||||||
|
trace!("{:#}", serde_json::to_value(resource).unwrap_or_default());
|
||||||
|
}
|
||||||
|
|
||||||
let dyntype = K::DynamicType::default();
|
let dyntype = K::DynamicType::default();
|
||||||
let gvk = GroupVersionKind {
|
let gvk = GroupVersionKind {
|
||||||
@@ -219,6 +248,10 @@ impl K8sClient {
|
|||||||
.expect("Kubernetes resource must have a name");
|
.expect("Kubernetes resource must have a name");
|
||||||
|
|
||||||
if self.dry_run {
|
if self.dry_run {
|
||||||
|
if redact {
|
||||||
|
debug!("Dry-run payload for secret resource '{name}' is redacted");
|
||||||
|
return Ok(resource.clone());
|
||||||
|
}
|
||||||
show_dry_run(&api, name, resource).await?;
|
show_dry_run(&api, name, resource).await?;
|
||||||
return Ok(resource.clone());
|
return Ok(resource.clone());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ use std::sync::Arc;
|
|||||||
|
|
||||||
use kube::config::{KubeConfigOptions, Kubeconfig};
|
use kube::config::{KubeConfigOptions, Kubeconfig};
|
||||||
use kube::{Client, Config, Discovery, Error};
|
use kube::{Client, Config, Discovery, Error};
|
||||||
use log::error;
|
use log::{error, info};
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
use tokio::sync::{OnceCell, RwLock};
|
use tokio::sync::{OnceCell, RwLock};
|
||||||
|
|
||||||
@@ -61,6 +61,22 @@ impl ClusterConnection {
|
|||||||
tls_verified: cluster.insecure_skip_tls_verify != Some(true),
|
tls_verified: cluster.insecure_skip_tls_verify != Some(true),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn from_config(name: String, config: &Config) -> Result<Self, String> {
|
||||||
|
let server = safe_endpoint(&config.cluster_url.to_string(), "cluster server", true)?;
|
||||||
|
let proxy_url = config
|
||||||
|
.proxy_url
|
||||||
|
.as_ref()
|
||||||
|
.map(|url| safe_endpoint(&url.to_string(), "cluster proxy", false))
|
||||||
|
.transpose()?;
|
||||||
|
Ok(Self {
|
||||||
|
name,
|
||||||
|
server,
|
||||||
|
tls_server_name: config.tls_server_name.clone(),
|
||||||
|
proxy_url,
|
||||||
|
tls_verified: !config.accept_invalid_certs,
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn safe_endpoint(endpoint: &str, name: &str, require_https: bool) -> Result<String, String> {
|
fn safe_endpoint(endpoint: &str, name: &str, require_https: bool) -> Result<String, String> {
|
||||||
@@ -121,6 +137,21 @@ impl std::fmt::Debug for K8sClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl K8sClient {
|
impl K8sClient {
|
||||||
|
pub fn validate_kubeconfig_context(
|
||||||
|
path: &str,
|
||||||
|
context: String,
|
||||||
|
) -> Result<ClusterConnection, String> {
|
||||||
|
let kubeconfig = Kubeconfig::read_from(path)
|
||||||
|
.map_err(|error| format!("failed to load kubeconfig from {path}: {error}"))?;
|
||||||
|
ClusterConnection::from_kubeconfig(
|
||||||
|
&kubeconfig,
|
||||||
|
&KubeConfigOptions {
|
||||||
|
context: Some(context),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
/// Create a client, reading `DRY_RUN` from the environment.
|
/// Create a client, reading `DRY_RUN` from the environment.
|
||||||
pub fn new(client: Client) -> Self {
|
pub fn new(client: Client) -> Self {
|
||||||
Self {
|
Self {
|
||||||
@@ -174,7 +205,13 @@ impl K8sClient {
|
|||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
let connection = ClusterConnection::from_kubeconfig(&k, opts)
|
let context = opts
|
||||||
|
.context
|
||||||
|
.clone()
|
||||||
|
.or_else(|| k.current_context.clone())
|
||||||
|
.unwrap_or_default();
|
||||||
|
let cluster_name = ClusterConnection::from_kubeconfig(&k, opts)
|
||||||
|
.map(|connection| connection.name)
|
||||||
.map_err(|error| error!("Tenant credentials unavailable for {path}: {error}"))
|
.map_err(|error| error!("Tenant credentials unavailable for {path}: {error}"))
|
||||||
.ok();
|
.ok();
|
||||||
let config = match Config::from_custom_kubeconfig(k, opts).await {
|
let config = match Config::from_custom_kubeconfig(k, opts).await {
|
||||||
@@ -184,6 +221,17 @@ impl K8sClient {
|
|||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
let connection = cluster_name.and_then(|name| {
|
||||||
|
ClusterConnection::from_config(name, &config)
|
||||||
|
.map_err(|error| error!("Tenant credentials unavailable for {path}: {error}"))
|
||||||
|
.ok()
|
||||||
|
});
|
||||||
|
if let Some(connection) = &connection {
|
||||||
|
info!(
|
||||||
|
"Loaded Kubernetes context '{context}' for cluster '{}' at '{}'",
|
||||||
|
connection.name, connection.server
|
||||||
|
);
|
||||||
|
}
|
||||||
let client = match Client::try_from(config) {
|
let client = match Client::try_from(config) {
|
||||||
Ok(client) => client,
|
Ok(client) => client,
|
||||||
Err(error) => {
|
Err(error) => {
|
||||||
@@ -236,6 +284,54 @@ users:
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn resolved_connection_uses_explicit_context_in_multi_cluster_config() {
|
||||||
|
let kubeconfig: Kubeconfig = serde_yaml::from_str(
|
||||||
|
r#"
|
||||||
|
current-context: cluster-a-admin
|
||||||
|
contexts:
|
||||||
|
- name: cluster-a-admin
|
||||||
|
context: { cluster: cluster-a, user: cluster-a-admin }
|
||||||
|
- name: cluster-b-admin
|
||||||
|
context: { cluster: cluster-b, user: cluster-b-admin }
|
||||||
|
clusters:
|
||||||
|
- name: cluster-a
|
||||||
|
cluster: { server: "https://192.0.2.10:6443" }
|
||||||
|
- name: cluster-b
|
||||||
|
cluster:
|
||||||
|
server: https://api.cluster-b.example.com:6443
|
||||||
|
tls-server-name: api.cluster-b.example.com
|
||||||
|
users:
|
||||||
|
- name: cluster-a-admin
|
||||||
|
user: { token: cluster-a-secret }
|
||||||
|
- name: cluster-b-admin
|
||||||
|
user: { token: cluster-b-secret }
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let options = KubeConfigOptions {
|
||||||
|
context: Some("cluster-b-admin".into()),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let name = ClusterConnection::from_kubeconfig(&kubeconfig, &options)
|
||||||
|
.unwrap()
|
||||||
|
.name;
|
||||||
|
let config = Config::from_custom_kubeconfig(kubeconfig, &options)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
ClusterConnection::from_config(name, &config).unwrap(),
|
||||||
|
ClusterConnection {
|
||||||
|
name: "cluster-b".into(),
|
||||||
|
server: "https://api.cluster-b.example.com:6443/".into(),
|
||||||
|
tls_server_name: Some("api.cluster-b.example.com".into()),
|
||||||
|
proxy_url: None,
|
||||||
|
tls_verified: true,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn connection_records_insecure_tls() {
|
fn connection_records_insecure_tls() {
|
||||||
let kubeconfig: Kubeconfig = serde_yaml::from_str(
|
let kubeconfig: Kubeconfig = serde_yaml::from_str(
|
||||||
|
|||||||
@@ -10,8 +10,6 @@ use kube::{
|
|||||||
Error, Resource,
|
Error, Resource,
|
||||||
api::{Api, DynamicObject, GroupVersionKind, ListParams, ObjectList},
|
api::{Api, DynamicObject, GroupVersionKind, ListParams, ObjectList},
|
||||||
core::ErrorResponse,
|
core::ErrorResponse,
|
||||||
runtime::conditions,
|
|
||||||
runtime::wait::await_condition,
|
|
||||||
};
|
};
|
||||||
use log::{debug, info};
|
use log::{debug, info};
|
||||||
use serde::de::DeserializeOwned;
|
use serde::de::DeserializeOwned;
|
||||||
@@ -276,15 +274,25 @@ impl K8sClient {
|
|||||||
namespace: Option<&str>,
|
namespace: Option<&str>,
|
||||||
timeout: Option<Duration>,
|
timeout: Option<Duration>,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
let api: Api<Deployment> = match namespace {
|
|
||||||
Some(ns) => Api::namespaced(self.client.clone(), ns),
|
|
||||||
None => Api::default_namespaced(self.client.clone()),
|
|
||||||
};
|
|
||||||
let timeout = timeout.unwrap_or(Duration::from_secs(120));
|
let timeout = timeout.unwrap_or(Duration::from_secs(120));
|
||||||
let establish = await_condition(api, name, conditions::is_deployment_completed());
|
match tokio::time::timeout(timeout, async {
|
||||||
match tokio::time::timeout(timeout, establish).await {
|
loop {
|
||||||
Ok(Ok(_)) => Ok(()),
|
match self.get_resource::<Deployment>(name, namespace).await {
|
||||||
Ok(Err(error)) => Err(format!("Failed waiting for deployment {name}: {error}")),
|
Ok(Some(deployment)) if deployment_rollout_complete(&deployment) => {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
Ok(_) => {}
|
||||||
|
// Transient API blips (watch/stream drops) — keep polling.
|
||||||
|
Err(error) => {
|
||||||
|
debug!("waiting for deployment {name}: {error}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tokio::time::sleep(Duration::from_secs(2)).await;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(result) => result,
|
||||||
Err(_) => Err(format!(
|
Err(_) => Err(format!(
|
||||||
"Timed out after {}s waiting for deployment {name}",
|
"Timed out after {}s waiting for deployment {name}",
|
||||||
timeout.as_secs()
|
timeout.as_secs()
|
||||||
@@ -522,3 +530,22 @@ impl K8sClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Same criteria as `kubectl rollout status` / kube-rs `is_deployment_completed`.
|
||||||
|
fn deployment_rollout_complete(deployment: &Deployment) -> bool {
|
||||||
|
let desired = deployment
|
||||||
|
.spec
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|spec| spec.replicas)
|
||||||
|
.unwrap_or(1);
|
||||||
|
let Some(status) = deployment.status.as_ref() else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
let observed = status.observed_generation.unwrap_or(0);
|
||||||
|
let generation = deployment.metadata.generation.unwrap_or(0);
|
||||||
|
observed >= generation
|
||||||
|
&& status.updated_replicas.unwrap_or(0) >= desired
|
||||||
|
&& status.ready_replicas.unwrap_or(0) >= desired
|
||||||
|
&& status.available_replicas.unwrap_or(0) >= desired
|
||||||
|
&& status.replicas.unwrap_or(0) <= desired
|
||||||
|
}
|
||||||
|
|||||||
@@ -89,6 +89,8 @@ walkdir = "2.5.0"
|
|||||||
harmony_inventory_agent = { path = "../harmony_inventory_agent" }
|
harmony_inventory_agent = { path = "../harmony_inventory_agent" }
|
||||||
harmony_secret_derive = { path = "../harmony_secret_derive" }
|
harmony_secret_derive = { path = "../harmony_secret_derive" }
|
||||||
harmony_secret = { path = "../harmony_secret" }
|
harmony_secret = { path = "../harmony_secret" }
|
||||||
|
harmony_zitadel_auth = { path = "../harmony_zitadel_auth" }
|
||||||
|
hmac = "0.12"
|
||||||
askama.workspace = true
|
askama.workspace = true
|
||||||
sha2 = "0.10"
|
sha2 = "0.10"
|
||||||
sqlx.workspace = true
|
sqlx.workspace = true
|
||||||
|
|||||||
@@ -114,9 +114,9 @@ impl K8sTenantManager {
|
|||||||
},
|
},
|
||||||
"spec": {
|
"spec": {
|
||||||
"hard": {
|
"hard": {
|
||||||
"limits.cpu": format!("{:.0}",config.resource_limits.cpu_limit_cores),
|
"limits.cpu": config.resource_limits.cpu_limit_cores.to_string(),
|
||||||
"limits.memory": format!("{:.3}Gi", config.resource_limits.memory_limit_gb),
|
"limits.memory": format!("{:.3}Gi", config.resource_limits.memory_limit_gb),
|
||||||
"requests.cpu": format!("{:.0}",config.resource_limits.cpu_request_cores),
|
"requests.cpu": config.resource_limits.cpu_request_cores.to_string(),
|
||||||
"requests.memory": format!("{:.3}Gi", config.resource_limits.memory_request_gb),
|
"requests.memory": format!("{:.3}Gi", config.resource_limits.memory_request_gb),
|
||||||
"requests.storage": format!("{:.3}Gi", config.resource_limits.storage_total_gb),
|
"requests.storage": format!("{:.3}Gi", config.resource_limits.storage_total_gb),
|
||||||
"pods": "20",
|
"pods": "20",
|
||||||
|
|||||||
@@ -261,6 +261,10 @@ impl<T: Topology + HelmCommand> Interpret<T> for HelmChartInterpret {
|
|||||||
&self.score.chart_name,
|
&self.score.chart_name,
|
||||||
"--namespace",
|
"--namespace",
|
||||||
&ns,
|
&ns,
|
||||||
|
// Charts (e.g. Zitadel) $ref remote k8s JSON schemas; that
|
||||||
|
// fetch is not a trust boundary we own and fails offline /
|
||||||
|
// on restricted egress. Scores own values correctness.
|
||||||
|
"--skip-schema-validation",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if self.score.create_namespace {
|
if self.score.create_namespace {
|
||||||
|
|||||||
@@ -63,6 +63,7 @@ pub struct PostgreSQLConfig {
|
|||||||
pub cluster_name: String,
|
pub cluster_name: String,
|
||||||
pub instances: u32,
|
pub instances: u32,
|
||||||
pub storage_size: StorageSize,
|
pub storage_size: StorageSize,
|
||||||
|
pub version: Option<String>,
|
||||||
pub role: PostgreSQLClusterRole,
|
pub role: PostgreSQLClusterRole,
|
||||||
/// **Note :** on OpenShfit based clusters, the namespace `default` has security
|
/// **Note :** on OpenShfit based clusters, the namespace `default` has security
|
||||||
/// settings incompatible with the default CNPG behavior.
|
/// settings incompatible with the default CNPG behavior.
|
||||||
@@ -90,6 +91,7 @@ impl Default for PostgreSQLConfig {
|
|||||||
role: PostgreSQLClusterRole::Primary,
|
role: PostgreSQLClusterRole::Primary,
|
||||||
namespace: "harmony".to_string(),
|
namespace: "harmony".to_string(),
|
||||||
wait_for_ready: true,
|
wait_for_ready: true,
|
||||||
|
version: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ impl<T: PostgreSQL + TlsRouter> PostgreSQL for FailoverTopology<T> {
|
|||||||
role: PostgreSQLClusterRole::Primary,
|
role: PostgreSQLClusterRole::Primary,
|
||||||
namespace: config.namespace.clone(),
|
namespace: config.namespace.clone(),
|
||||||
wait_for_ready: config.wait_for_ready,
|
wait_for_ready: config.wait_for_ready,
|
||||||
|
version: config.version.clone(),
|
||||||
};
|
};
|
||||||
|
|
||||||
info!(
|
info!(
|
||||||
@@ -146,6 +147,7 @@ impl<T: PostgreSQL + TlsRouter> PostgreSQL for FailoverTopology<T> {
|
|||||||
role: PostgreSQLClusterRole::Replica(replica_cluster_config),
|
role: PostgreSQLClusterRole::Replica(replica_cluster_config),
|
||||||
namespace: config.namespace.clone(),
|
namespace: config.namespace.clone(),
|
||||||
wait_for_ready: config.wait_for_ready,
|
wait_for_ready: config.wait_for_ready,
|
||||||
|
version: config.version.clone(),
|
||||||
};
|
};
|
||||||
|
|
||||||
info!(
|
info!(
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ pub use score_connect::*;
|
|||||||
pub use score_k8s::*;
|
pub use score_k8s::*;
|
||||||
mod score_public;
|
mod score_public;
|
||||||
pub use score_public::*;
|
pub use score_public::*;
|
||||||
|
mod score_debug_route;
|
||||||
|
pub use score_debug_route::*;
|
||||||
|
|
||||||
pub mod failover;
|
pub mod failover;
|
||||||
mod operator;
|
mod operator;
|
||||||
|
|||||||
194
harmony/src/modules/postgresql/score_debug_route.rs
Normal file
194
harmony/src/modules/postgresql/score_debug_route.rs
Normal file
@@ -0,0 +1,194 @@
|
|||||||
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use harmony_types::id::Id;
|
||||||
|
use k8s_openapi::api::core::v1::{Service, ServicePort, ServiceSpec};
|
||||||
|
use k8s_openapi::apimachinery::pkg::apis::meta::v1::ObjectMeta;
|
||||||
|
use k8s_openapi::apimachinery::pkg::util::intstr::IntOrString;
|
||||||
|
use log::{info, warn};
|
||||||
|
use serde::Serialize;
|
||||||
|
|
||||||
|
use crate::data::Version;
|
||||||
|
use crate::interpret::{Interpret, InterpretError, InterpretName, InterpretStatus, Outcome};
|
||||||
|
use crate::inventory::Inventory;
|
||||||
|
use crate::modules::k8s::resource::K8sResourceScore;
|
||||||
|
use crate::modules::okd::crd::route::Route;
|
||||||
|
use crate::score::Score;
|
||||||
|
use crate::topology::{K8sclient, Topology};
|
||||||
|
|
||||||
|
/// VPN/debug expose for a CNPG primary via a companion Service.
|
||||||
|
///
|
||||||
|
/// **Why not an OKD TLS passthrough Route?** PostgreSQL before 17 does TLS only after the
|
||||||
|
/// PostgreSQL `SSLRequest` startup packet, not with a TLS ClientHello first. The OpenShift
|
||||||
|
/// router needs ClientHello+SNI for passthrough. PG 17 adds `sslnegotiation=direct`; we pin
|
||||||
|
/// PG 16 for Flyway/Quarkus, so typical clients (pgAdmin, stock libpq) cannot use a passthrough
|
||||||
|
/// Route. A NodePort speaks plain TCP to the pod; the client then does normal PG SSL to CNPG.
|
||||||
|
///
|
||||||
|
/// **Toggle in OKD console:** edit Service `{cluster}-rw-debug`:
|
||||||
|
/// - `spec.type: ClusterIP` — off (default on first create)
|
||||||
|
/// - `spec.type: NodePort` — on (any node/VPN IP + `nodePort`, `sslmode=require`)
|
||||||
|
///
|
||||||
|
/// Ships preserve live `type` and `nodePort`.
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub struct PostgresDebugRouteScore {
|
||||||
|
pub namespace: String,
|
||||||
|
pub cluster_name: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PostgresDebugRouteScore {
|
||||||
|
pub fn new(namespace: impl Into<String>, cluster_name: impl Into<String>) -> Self {
|
||||||
|
Self {
|
||||||
|
namespace: namespace.into(),
|
||||||
|
cluster_name: cluster_name.into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn service_name(&self) -> String {
|
||||||
|
format!("{}-rw-debug", self.cluster_name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T: Topology + K8sclient + 'static> Score<T> for PostgresDebugRouteScore {
|
||||||
|
fn create_interpret(&self) -> Box<dyn Interpret<T>> {
|
||||||
|
Box::new(PostgresDebugRouteInterpret {
|
||||||
|
score: self.clone(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn name(&self) -> String {
|
||||||
|
format!(
|
||||||
|
"PostgresDebugRouteScore({}/{})",
|
||||||
|
self.namespace, self.cluster_name
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
struct PostgresDebugRouteInterpret {
|
||||||
|
score: PostgresDebugRouteScore,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl<T: Topology + K8sclient> Interpret<T> for PostgresDebugRouteInterpret {
|
||||||
|
async fn execute(
|
||||||
|
&self,
|
||||||
|
inventory: &Inventory,
|
||||||
|
topology: &T,
|
||||||
|
) -> Result<Outcome, InterpretError> {
|
||||||
|
let name = self.score.service_name();
|
||||||
|
let client = topology
|
||||||
|
.k8s_client()
|
||||||
|
.await
|
||||||
|
.map_err(|e| InterpretError::new(format!("get k8s client: {e}")))?;
|
||||||
|
|
||||||
|
// Best-effort cleanup of the earlier passthrough Route (same name).
|
||||||
|
match client
|
||||||
|
.delete_resource::<Route>(&name, Some(&self.score.namespace))
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(()) => {}
|
||||||
|
Err(e) => warn!(
|
||||||
|
"could not delete legacy DB debug Route {}/{}: {e}",
|
||||||
|
self.score.namespace, name
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
let (svc_type, node_port) = match client
|
||||||
|
.get_resource::<Service>(&name, Some(&self.score.namespace))
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(Some(existing)) => {
|
||||||
|
let t = existing
|
||||||
|
.spec
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|s| s.type_.clone())
|
||||||
|
.unwrap_or_else(|| "ClusterIP".into());
|
||||||
|
let np = existing
|
||||||
|
.spec
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|s| s.ports.as_ref())
|
||||||
|
.and_then(|p| p.first())
|
||||||
|
.and_then(|p| p.node_port);
|
||||||
|
(t, np)
|
||||||
|
}
|
||||||
|
Ok(None) => ("ClusterIP".into(), None),
|
||||||
|
Err(e) => {
|
||||||
|
return Err(InterpretError::new(format!(
|
||||||
|
"get existing DB debug service: {e}"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
info!(
|
||||||
|
"DB debug service '{}/{}' type={} nodePort={:?} (ClusterIP=off NodePort=on)",
|
||||||
|
self.score.namespace, name, svc_type, node_port
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut annotations = BTreeMap::new();
|
||||||
|
annotations.insert(
|
||||||
|
"harmony.nationtech.io/db-expose".into(),
|
||||||
|
"Set spec.type to NodePort to enable VPN/debug (node IP + nodePort, sslmode=require). ClusterIP disables. Ships preserve type/nodePort. PG16 cannot use TLS-passthrough Routes (needs sslnegotiation=direct from PG17+).".into(),
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut port = ServicePort {
|
||||||
|
name: Some("postgres".into()),
|
||||||
|
port: 5432,
|
||||||
|
protocol: Some("TCP".into()),
|
||||||
|
target_port: Some(IntOrString::Int(5432)),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
if svc_type == "NodePort"
|
||||||
|
&& let Some(np) = node_port
|
||||||
|
{
|
||||||
|
port.node_port = Some(np);
|
||||||
|
}
|
||||||
|
|
||||||
|
let service = Service {
|
||||||
|
metadata: ObjectMeta {
|
||||||
|
name: Some(name.clone()),
|
||||||
|
namespace: Some(self.score.namespace.clone()),
|
||||||
|
annotations: Some(annotations),
|
||||||
|
labels: Some(BTreeMap::from([
|
||||||
|
("cnpg.io/cluster".into(), self.score.cluster_name.clone()),
|
||||||
|
("harmony.nationtech.io/role".into(), "db-debug".into()),
|
||||||
|
])),
|
||||||
|
..ObjectMeta::default()
|
||||||
|
},
|
||||||
|
spec: Some(ServiceSpec {
|
||||||
|
type_: Some(svc_type.clone()),
|
||||||
|
selector: Some(BTreeMap::from([
|
||||||
|
("cnpg.io/cluster".into(), self.score.cluster_name.clone()),
|
||||||
|
("cnpg.io/instanceRole".into(), "primary".into()),
|
||||||
|
])),
|
||||||
|
ports: Some(vec![port]),
|
||||||
|
..Default::default()
|
||||||
|
}),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
K8sResourceScore::single(service, Some(self.score.namespace.clone()))
|
||||||
|
.create_interpret()
|
||||||
|
.execute(inventory, topology)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(Outcome::success(format!(
|
||||||
|
"DB debug service '{name}' type={svc_type} (edit Service type ClusterIP/NodePort to toggle)"
|
||||||
|
)))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_name(&self) -> InterpretName {
|
||||||
|
InterpretName::Custom("PostgresDebugRouteInterpret")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_version(&self) -> Version {
|
||||||
|
todo!()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_status(&self) -> InterpretStatus {
|
||||||
|
todo!()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_children(&self) -> Vec<Id> {
|
||||||
|
todo!()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -66,6 +66,11 @@ impl K8sPostgreSQLScore {
|
|||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn version(mut self, version: impl Into<String>) -> Self {
|
||||||
|
self.config.version = Some(version.into());
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
pub fn root_account_ref(&self) -> PostgreSQLRootAccountRef {
|
pub fn root_account_ref(&self) -> PostgreSQLRootAccountRef {
|
||||||
PostgreSQLRootAccountRef {
|
PostgreSQLRootAccountRef {
|
||||||
host: format!(
|
host: format!(
|
||||||
@@ -298,6 +303,7 @@ impl<T: Topology + K8sclient + HelmCommand + 'static> Interpret<T> for K8sPostgr
|
|||||||
|
|
||||||
let spec = ClusterSpec {
|
let spec = ClusterSpec {
|
||||||
instances: self.config.instances,
|
instances: self.config.instances,
|
||||||
|
image_name: cnpg_image_name(self.config.version.as_deref()),
|
||||||
storage: Storage {
|
storage: Storage {
|
||||||
size: self.config.storage_size.to_string(),
|
size: self.config.storage_size.to_string(),
|
||||||
},
|
},
|
||||||
@@ -387,6 +393,7 @@ impl<T: Topology + K8sclient + HelmCommand + 'static> Interpret<T> for K8sPostgr
|
|||||||
|
|
||||||
let spec = ClusterSpec {
|
let spec = ClusterSpec {
|
||||||
instances: self.config.instances,
|
instances: self.config.instances,
|
||||||
|
image_name: cnpg_image_name(self.config.version.as_deref()),
|
||||||
storage: Storage {
|
storage: Storage {
|
||||||
size: self.config.storage_size.to_string(),
|
size: self.config.storage_size.to_string(),
|
||||||
},
|
},
|
||||||
@@ -453,6 +460,22 @@ impl<T: Topology + K8sclient + HelmCommand + 'static> Interpret<T> for K8sPostgr
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Map a declared PG version to CNPG `spec.imageName`.
|
||||||
|
/// - `None` → operator default image
|
||||||
|
/// - `"16"` / `"16.4"` → `ghcr.io/cloudnative-pg/postgresql:<tag>`
|
||||||
|
/// - value containing `/` → used as a full image reference
|
||||||
|
fn cnpg_image_name(version: Option<&str>) -> Option<String> {
|
||||||
|
let version = version?.trim();
|
||||||
|
if version.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
if version.contains('/') {
|
||||||
|
Some(version.to_string())
|
||||||
|
} else {
|
||||||
|
Some(format!("ghcr.io/cloudnative-pg/postgresql:{version}"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -474,4 +497,17 @@ mod tests {
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cnpg_image_name_maps_tags_and_full_refs() {
|
||||||
|
assert_eq!(cnpg_image_name(None), None);
|
||||||
|
assert_eq!(
|
||||||
|
cnpg_image_name(Some("16")).as_deref(),
|
||||||
|
Some("ghcr.io/cloudnative-pg/postgresql:16")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
cnpg_image_name(Some("ghcr.io/example/pg:18")).as_deref(),
|
||||||
|
Some("ghcr.io/example/pg:18")
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,31 +20,46 @@
|
|||||||
|
|
||||||
use std::collections::BTreeMap;
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
|
use async_trait::async_trait;
|
||||||
use base64::Engine;
|
use base64::Engine;
|
||||||
use k8s_openapi::ByteString;
|
use k8s_openapi::ByteString;
|
||||||
use k8s_openapi::api::core::v1::Secret as K8sSecret;
|
use k8s_openapi::api::core::v1::Secret as K8sSecret;
|
||||||
use k8s_openapi::apimachinery::pkg::apis::meta::v1::ObjectMeta;
|
use k8s_openapi::apimachinery::pkg::apis::meta::v1::ObjectMeta;
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
|
|
||||||
use crate::interpret::Interpret;
|
use crate::data::Version;
|
||||||
use crate::modules::k8s::resource::K8sResourceScore;
|
use crate::interpret::{Interpret, InterpretError, InterpretName, InterpretStatus, Outcome};
|
||||||
|
use crate::inventory::Inventory;
|
||||||
use crate::score::Score;
|
use crate::score::Score;
|
||||||
use crate::topology::{K8sclient, Topology};
|
use crate::topology::{K8sclient, Topology};
|
||||||
|
use harmony_types::id::Id;
|
||||||
|
|
||||||
/// Creates a `kubernetes.io/dockerconfigjson` Secret in `namespace` from
|
/// Creates a `kubernetes.io/dockerconfigjson` Secret in `namespace` from
|
||||||
/// pull-only registry credentials. Idempotent — a namespaced Secret apply.
|
/// pull-only registry credentials. Idempotent — a namespaced Secret apply.
|
||||||
#[derive(Debug, Clone, Serialize)]
|
#[derive(Clone, Serialize)]
|
||||||
pub struct RegistryPullSecretScore {
|
pub struct RegistryPullSecretScore {
|
||||||
pub namespace: String,
|
pub namespace: String,
|
||||||
/// Secret name — the same string goes in each pod's `imagePullSecrets`.
|
/// Secret name — the same string goes in each pod's `imagePullSecrets`.
|
||||||
pub name: String,
|
pub name: String,
|
||||||
/// Registry host the creds authenticate to, e.g. `hub.nationtech.io`.
|
/// Registry host the creds authenticate to, e.g. `hub.nationtech.io`.
|
||||||
pub registry: String,
|
pub registry: String,
|
||||||
|
#[serde(skip_serializing)]
|
||||||
pub username: String,
|
pub username: String,
|
||||||
/// Pull-only robot token. Serialized into the Secret, never logged.
|
/// Pull-only robot token. Serialized into the Secret, never logged.
|
||||||
|
#[serde(skip_serializing)]
|
||||||
pub token: String,
|
pub token: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Debug for RegistryPullSecretScore {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
f.debug_struct("RegistryPullSecretScore")
|
||||||
|
.field("namespace", &self.namespace)
|
||||||
|
.field("name", &self.name)
|
||||||
|
.field("registry", &self.registry)
|
||||||
|
.finish_non_exhaustive()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl RegistryPullSecretScore {
|
impl RegistryPullSecretScore {
|
||||||
/// Build the `.dockerconfigjson` Secret. The inner `auth` field is
|
/// Build the `.dockerconfigjson` Secret. The inner `auth` field is
|
||||||
/// `base64(user:token)` per Docker's config format; the kubelet reads it to
|
/// `base64(user:token)` per Docker's config format; the kubelet reads it to
|
||||||
@@ -85,7 +100,51 @@ impl<T: Topology + K8sclient + 'static> Score<T> for RegistryPullSecretScore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn create_interpret(&self) -> Box<dyn Interpret<T>> {
|
fn create_interpret(&self) -> Box<dyn Interpret<T>> {
|
||||||
K8sResourceScore::single(self.secret(), Some(self.namespace.clone())).create_interpret()
|
Box::new(RegistryPullSecretInterpret {
|
||||||
|
score: self.clone(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
struct RegistryPullSecretInterpret {
|
||||||
|
score: RegistryPullSecretScore,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl<T: Topology + K8sclient> Interpret<T> for RegistryPullSecretInterpret {
|
||||||
|
async fn execute(
|
||||||
|
&self,
|
||||||
|
_inventory: &Inventory,
|
||||||
|
topology: &T,
|
||||||
|
) -> Result<Outcome, InterpretError> {
|
||||||
|
topology
|
||||||
|
.k8s_client()
|
||||||
|
.await
|
||||||
|
.map_err(|error| InterpretError::new(format!("get Kubernetes client: {error}")))?
|
||||||
|
.apply_redacted(&self.score.secret(), Some(&self.score.namespace))
|
||||||
|
.await
|
||||||
|
.map_err(|error| InterpretError::new(format!("apply registry pull Secret: {error}")))?;
|
||||||
|
Ok(Outcome::success(format!(
|
||||||
|
"applied registry pull Secret {}/{}",
|
||||||
|
self.score.namespace, self.score.name
|
||||||
|
)))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_name(&self) -> InterpretName {
|
||||||
|
InterpretName::K8sResource
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_version(&self) -> Version {
|
||||||
|
todo!()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_status(&self) -> InterpretStatus {
|
||||||
|
todo!()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_children(&self) -> Vec<Id> {
|
||||||
|
vec![]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -122,4 +181,12 @@ mod tests {
|
|||||||
let expected_auth = base64::engine::general_purpose::STANDARD.encode("robot$pull:s3cr3t");
|
let expected_auth = base64::engine::general_purpose::STANDARD.encode("robot$pull:s3cr3t");
|
||||||
assert_eq!(entry["auth"], expected_auth);
|
assert_eq!(entry["auth"], expected_auth);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn debug_and_score_serialization_omit_credentials() {
|
||||||
|
let score = sample();
|
||||||
|
|
||||||
|
assert!(!format!("{score:?}").contains("s3cr3t"));
|
||||||
|
assert!(!serde_json::to_string(&score).unwrap().contains("s3cr3t"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1 +1,4 @@
|
|||||||
pub mod ceph;
|
pub mod ceph;
|
||||||
|
pub mod object_bucket;
|
||||||
|
|
||||||
|
pub use object_bucket::ObjectBucketScore;
|
||||||
|
|||||||
643
harmony/src/modules/storage/object_bucket.rs
Normal file
643
harmony/src/modules/storage/object_bucket.rs
Normal file
@@ -0,0 +1,643 @@
|
|||||||
|
use std::collections::BTreeMap;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use hmac::{Hmac, Mac};
|
||||||
|
use k8s_openapi::ByteString;
|
||||||
|
use k8s_openapi::api::core::v1::{ConfigMap, Secret};
|
||||||
|
use k8s_openapi::apimachinery::pkg::apis::meta::v1::ObjectMeta;
|
||||||
|
use kube::CustomResource;
|
||||||
|
use log::{debug, info};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
|
||||||
|
use crate::data::Version;
|
||||||
|
use crate::interpret::{Interpret, InterpretError, InterpretName, InterpretStatus, Outcome};
|
||||||
|
use crate::inventory::Inventory;
|
||||||
|
use crate::modules::k8s::resource::K8sResourceScore;
|
||||||
|
use crate::score::Score;
|
||||||
|
use crate::topology::{K8sclient, Topology};
|
||||||
|
use harmony_types::id::Id;
|
||||||
|
|
||||||
|
#[derive(CustomResource, Deserialize, Serialize, Clone, Debug, Default)]
|
||||||
|
#[kube(
|
||||||
|
group = "objectbucket.io",
|
||||||
|
version = "v1alpha1",
|
||||||
|
kind = "ObjectBucketClaim",
|
||||||
|
plural = "objectbucketclaims",
|
||||||
|
namespaced = true,
|
||||||
|
schema = "disabled"
|
||||||
|
)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct ObjectBucketClaimSpec {
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub bucket_name: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub generate_bucket_name: Option<String>,
|
||||||
|
pub storage_class_name: String,
|
||||||
|
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
|
||||||
|
pub additional_config: BTreeMap<String, String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Provisions a Rook/lib-bucket-provisioner ObjectBucketClaim and synthesizes an
|
||||||
|
/// app-facing credentials Secret (`{name}-app`) with endpoint/bucket/keys.
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub struct ObjectBucketScore {
|
||||||
|
pub name: String,
|
||||||
|
pub namespace: String,
|
||||||
|
pub storage_class: String,
|
||||||
|
pub max_size: String,
|
||||||
|
/// When set, written into the app Secret instead of the cluster-internal RGW URL.
|
||||||
|
pub endpoint_override: Option<String>,
|
||||||
|
/// Full origins (e.g. `https://app.example.com`) applied via S3 PutBucketCors.
|
||||||
|
pub cors_origins: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ObjectBucketScore {
|
||||||
|
pub fn new(namespace: impl Into<String>, name: impl Into<String>) -> Self {
|
||||||
|
Self {
|
||||||
|
name: name.into(),
|
||||||
|
namespace: namespace.into(),
|
||||||
|
storage_class: "ceph-bucket".into(),
|
||||||
|
max_size: "10G".into(),
|
||||||
|
endpoint_override: None,
|
||||||
|
cors_origins: 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_override(mut self, endpoint: impl Into<String>) -> Self {
|
||||||
|
self.endpoint_override = Some(endpoint.into());
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn cors_origins(mut self, origins: impl IntoIterator<Item = impl Into<String>>) -> Self {
|
||||||
|
self.cors_origins = origins.into_iter().map(Into::into).collect();
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn app_secret_name(&self) -> String {
|
||||||
|
format!("{}-app", self.name)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn claim(&self) -> ObjectBucketClaim {
|
||||||
|
let mut additional_config = BTreeMap::new();
|
||||||
|
additional_config.insert("maxSize".into(), self.max_size.clone());
|
||||||
|
ObjectBucketClaim {
|
||||||
|
metadata: ObjectMeta {
|
||||||
|
name: Some(self.name.clone()),
|
||||||
|
namespace: Some(self.namespace.clone()),
|
||||||
|
..ObjectMeta::default()
|
||||||
|
},
|
||||||
|
spec: ObjectBucketClaimSpec {
|
||||||
|
bucket_name: Some(self.name.clone()),
|
||||||
|
generate_bucket_name: None,
|
||||||
|
storage_class_name: self.storage_class.clone(),
|
||||||
|
additional_config,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for ObjectBucketClaim {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
metadata: ObjectMeta::default(),
|
||||||
|
spec: ObjectBucketClaimSpec::default(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T: Topology + K8sclient + 'static> Score<T> for ObjectBucketScore {
|
||||||
|
fn create_interpret(&self) -> Box<dyn Interpret<T>> {
|
||||||
|
Box::new(ObjectBucketInterpret {
|
||||||
|
score: self.clone(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn name(&self) -> String {
|
||||||
|
format!("ObjectBucketScore({}/{})", self.namespace, self.name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
struct ObjectBucketInterpret {
|
||||||
|
score: ObjectBucketScore,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl<T: Topology + K8sclient> Interpret<T> for ObjectBucketInterpret {
|
||||||
|
async fn execute(
|
||||||
|
&self,
|
||||||
|
inventory: &Inventory,
|
||||||
|
topology: &T,
|
||||||
|
) -> Result<Outcome, InterpretError> {
|
||||||
|
let client = topology
|
||||||
|
.k8s_client()
|
||||||
|
.await
|
||||||
|
.map_err(|e| InterpretError::new(format!("get k8s client: {e}")))?;
|
||||||
|
client
|
||||||
|
.ensure_namespace(&self.score.namespace)
|
||||||
|
.await
|
||||||
|
.map_err(|e| InterpretError::new(format!("ensure namespace: {e}")))?;
|
||||||
|
|
||||||
|
K8sResourceScore::single(self.score.claim(), Some(self.score.namespace.clone()))
|
||||||
|
.create_interpret()
|
||||||
|
.execute(inventory, topology)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let (config, provisioner_secret) = wait_for_claim_outputs(
|
||||||
|
client.as_ref(),
|
||||||
|
&self.score.namespace,
|
||||||
|
&self.score.name,
|
||||||
|
Duration::from_secs(180),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let credentials = bucket_credentials(
|
||||||
|
&config,
|
||||||
|
&provisioner_secret,
|
||||||
|
self.score.endpoint_override.as_deref(),
|
||||||
|
)?;
|
||||||
|
|
||||||
|
if !self.score.cors_origins.is_empty() {
|
||||||
|
apply_bucket_cors(&credentials, &self.score.cors_origins).await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let app_secret = app_secret(
|
||||||
|
&self.score.namespace,
|
||||||
|
&self.score.app_secret_name(),
|
||||||
|
&credentials,
|
||||||
|
);
|
||||||
|
client
|
||||||
|
.apply_redacted(&app_secret, Some(&self.score.namespace))
|
||||||
|
.await
|
||||||
|
.map_err(|e| InterpretError::new(format!("apply bucket credentials Secret: {e}")))?;
|
||||||
|
|
||||||
|
Ok(Outcome::success(format!(
|
||||||
|
"object bucket '{}/{}' ready",
|
||||||
|
self.score.namespace, self.score.name
|
||||||
|
)))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_name(&self) -> InterpretName {
|
||||||
|
InterpretName::Custom("ObjectBucketInterpret")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_version(&self) -> Version {
|
||||||
|
todo!()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_status(&self) -> InterpretStatus {
|
||||||
|
todo!()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_children(&self) -> Vec<Id> {
|
||||||
|
todo!()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct BucketCredentials {
|
||||||
|
endpoint: String,
|
||||||
|
bucket: String,
|
||||||
|
region: String,
|
||||||
|
access_key: String,
|
||||||
|
secret_key: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn wait_for_claim_outputs(
|
||||||
|
client: &harmony_k8s::K8sClient,
|
||||||
|
namespace: &str,
|
||||||
|
name: &str,
|
||||||
|
timeout: Duration,
|
||||||
|
) -> Result<(ConfigMap, Secret), InterpretError> {
|
||||||
|
let start = std::time::Instant::now();
|
||||||
|
info!(
|
||||||
|
"Waiting for ObjectBucketClaim '{namespace}/{name}' credentials (up to {}s)...",
|
||||||
|
timeout.as_secs()
|
||||||
|
);
|
||||||
|
loop {
|
||||||
|
let cm = client
|
||||||
|
.get_resource::<ConfigMap>(name, Some(namespace))
|
||||||
|
.await
|
||||||
|
.map_err(|e| InterpretError::new(format!("get OBC ConfigMap: {e}")))?;
|
||||||
|
let secret = client
|
||||||
|
.get_resource::<Secret>(name, Some(namespace))
|
||||||
|
.await
|
||||||
|
.map_err(|e| InterpretError::new(format!("get OBC Secret: {e}")))?;
|
||||||
|
if let (Some(cm), Some(secret)) = (cm, secret)
|
||||||
|
&& config_has_bucket(&cm)
|
||||||
|
&& secret_has_keys(&secret)
|
||||||
|
{
|
||||||
|
info!("ObjectBucketClaim '{namespace}/{name}' credentials ready");
|
||||||
|
return Ok((cm, secret));
|
||||||
|
}
|
||||||
|
debug!("ObjectBucketClaim '{namespace}/{name}' credentials not ready yet");
|
||||||
|
if start.elapsed() > timeout {
|
||||||
|
return Err(InterpretError::new(format!(
|
||||||
|
"timed out waiting for ObjectBucketClaim '{namespace}/{name}' credentials after {}s",
|
||||||
|
timeout.as_secs()
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
tokio::time::sleep(Duration::from_secs(2)).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn config_has_bucket(cm: &ConfigMap) -> bool {
|
||||||
|
cm.data
|
||||||
|
.as_ref()
|
||||||
|
.is_some_and(|data| data.contains_key("BUCKET_NAME") && data.contains_key("BUCKET_HOST"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn secret_has_keys(secret: &Secret) -> bool {
|
||||||
|
secret.data.as_ref().is_some_and(|data| {
|
||||||
|
data.contains_key("AWS_ACCESS_KEY_ID") && data.contains_key("AWS_SECRET_ACCESS_KEY")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn bucket_credentials(
|
||||||
|
config: &ConfigMap,
|
||||||
|
provisioner: &Secret,
|
||||||
|
endpoint_override: Option<&str>,
|
||||||
|
) -> Result<BucketCredentials, InterpretError> {
|
||||||
|
let data = config
|
||||||
|
.data
|
||||||
|
.as_ref()
|
||||||
|
.ok_or_else(|| InterpretError::new("OBC ConfigMap has no data".to_string()))?;
|
||||||
|
let host = data
|
||||||
|
.get("BUCKET_HOST")
|
||||||
|
.ok_or_else(|| InterpretError::new("OBC ConfigMap missing BUCKET_HOST".to_string()))?;
|
||||||
|
let port = data.get("BUCKET_PORT").map(String::as_str).unwrap_or("80");
|
||||||
|
let bucket = data
|
||||||
|
.get("BUCKET_NAME")
|
||||||
|
.ok_or_else(|| InterpretError::new("OBC ConfigMap missing BUCKET_NAME".to_string()))?
|
||||||
|
.clone();
|
||||||
|
// Rook often leaves BUCKET_REGION empty; S3 SDKs / SmallRye still need a non-empty
|
||||||
|
// value. Prefer a place-neutral token over a fake AWS region name.
|
||||||
|
let region = data
|
||||||
|
.get("BUCKET_REGION")
|
||||||
|
.map(String::as_str)
|
||||||
|
.filter(|value| !value.trim().is_empty())
|
||||||
|
.unwrap_or("default")
|
||||||
|
.to_string();
|
||||||
|
let scheme = if port == "443" { "https" } else { "http" };
|
||||||
|
let internal = if port == "80" || port == "443" {
|
||||||
|
format!("{scheme}://{host}")
|
||||||
|
} else {
|
||||||
|
format!("{scheme}://{host}:{port}")
|
||||||
|
};
|
||||||
|
let endpoint = endpoint_override
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
.map(str::to_string)
|
||||||
|
.unwrap_or(internal);
|
||||||
|
|
||||||
|
let keys = provisioner
|
||||||
|
.data
|
||||||
|
.as_ref()
|
||||||
|
.ok_or_else(|| InterpretError::new("OBC Secret has no data".to_string()))?;
|
||||||
|
let access_key = decode_secret_key(keys, "AWS_ACCESS_KEY_ID")?;
|
||||||
|
let secret_key = decode_secret_key(keys, "AWS_SECRET_ACCESS_KEY")?;
|
||||||
|
|
||||||
|
Ok(BucketCredentials {
|
||||||
|
endpoint,
|
||||||
|
bucket,
|
||||||
|
region,
|
||||||
|
access_key,
|
||||||
|
secret_key,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn decode_secret_key(
|
||||||
|
keys: &BTreeMap<String, ByteString>,
|
||||||
|
name: &str,
|
||||||
|
) -> Result<String, InterpretError> {
|
||||||
|
let raw = keys
|
||||||
|
.get(name)
|
||||||
|
.ok_or_else(|| InterpretError::new(format!("OBC Secret missing {name}")))?;
|
||||||
|
String::from_utf8(raw.0.clone())
|
||||||
|
.map_err(|e| InterpretError::new(format!("OBC Secret {name} is not utf8: {e}")))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn app_secret(namespace: &str, name: &str, credentials: &BucketCredentials) -> Secret {
|
||||||
|
Secret {
|
||||||
|
metadata: ObjectMeta {
|
||||||
|
name: Some(name.to_string()),
|
||||||
|
namespace: Some(namespace.to_string()),
|
||||||
|
..ObjectMeta::default()
|
||||||
|
},
|
||||||
|
type_: Some("Opaque".into()),
|
||||||
|
data: Some(BTreeMap::from([
|
||||||
|
(
|
||||||
|
"endpoint".into(),
|
||||||
|
ByteString(credentials.endpoint.as_bytes().to_vec()),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"bucket".into(),
|
||||||
|
ByteString(credentials.bucket.as_bytes().to_vec()),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"region".into(),
|
||||||
|
ByteString(credentials.region.as_bytes().to_vec()),
|
||||||
|
),
|
||||||
|
("path-style".into(), ByteString(b"true".to_vec())),
|
||||||
|
(
|
||||||
|
"access-key".into(),
|
||||||
|
ByteString(credentials.access_key.as_bytes().to_vec()),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"secret-key".into(),
|
||||||
|
ByteString(credentials.secret_key.as_bytes().to_vec()),
|
||||||
|
),
|
||||||
|
])),
|
||||||
|
..Default::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn apply_bucket_cors(
|
||||||
|
credentials: &BucketCredentials,
|
||||||
|
origins: &[String],
|
||||||
|
) -> Result<(), InterpretError> {
|
||||||
|
let origins: Vec<&str> = origins
|
||||||
|
.iter()
|
||||||
|
.map(|o| o.trim())
|
||||||
|
.filter(|o| !o.is_empty())
|
||||||
|
.collect();
|
||||||
|
if origins.is_empty() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
info!(
|
||||||
|
"Applying CORS on bucket '{}' at {} for origins {:?}",
|
||||||
|
credentials.bucket, credentials.endpoint, origins
|
||||||
|
);
|
||||||
|
|
||||||
|
let body = cors_configuration_xml(&origins);
|
||||||
|
let signed = sign_s3_put_cors(credentials, body.as_bytes())?;
|
||||||
|
|
||||||
|
let response = reqwest::Client::new()
|
||||||
|
.put(&signed.url)
|
||||||
|
.header("content-type", "application/xml")
|
||||||
|
.header("host", &signed.host)
|
||||||
|
.header("x-amz-content-sha256", &signed.payload_hash)
|
||||||
|
.header("x-amz-date", &signed.amz_date)
|
||||||
|
.header("authorization", &signed.authorization)
|
||||||
|
.body(body)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
InterpretError::new(format!(
|
||||||
|
"put bucket CORS on '{}' via {}: {e}",
|
||||||
|
credentials.bucket, credentials.endpoint
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
if !response.status().is_success() {
|
||||||
|
let status = response.status();
|
||||||
|
let text = response.text().await.unwrap_or_default();
|
||||||
|
return Err(InterpretError::new(format!(
|
||||||
|
"put bucket CORS on '{}' via {} failed: {status} {text}",
|
||||||
|
credentials.bucket, credentials.endpoint
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
struct SignedS3Request {
|
||||||
|
url: String,
|
||||||
|
host: String,
|
||||||
|
amz_date: String,
|
||||||
|
payload_hash: String,
|
||||||
|
authorization: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Path-style `PUT /{bucket}?cors` with AWS SigV4 (Ceph RGW compatible).
|
||||||
|
fn sign_s3_put_cors(
|
||||||
|
credentials: &BucketCredentials,
|
||||||
|
body: &[u8],
|
||||||
|
) -> Result<SignedS3Request, InterpretError> {
|
||||||
|
let endpoint = credentials
|
||||||
|
.endpoint
|
||||||
|
.trim_end_matches('/')
|
||||||
|
.parse::<url::Url>()
|
||||||
|
.map_err(|e| InterpretError::new(format!("invalid bucket endpoint: {e}")))?;
|
||||||
|
let host = endpoint
|
||||||
|
.host_str()
|
||||||
|
.ok_or_else(|| InterpretError::new("bucket endpoint missing host".to_string()))?;
|
||||||
|
let host_header = match endpoint.port() {
|
||||||
|
Some(port) => format!("{host}:{port}"),
|
||||||
|
None => host.to_string(),
|
||||||
|
};
|
||||||
|
let bucket = credentials.bucket.trim_matches('/');
|
||||||
|
// URI-encode path segments per AWS (unreserved stay literal).
|
||||||
|
let canonical_uri = format!("/{}", aws_uri_encode(bucket, false));
|
||||||
|
let url = format!(
|
||||||
|
"{}{}?cors",
|
||||||
|
credentials.endpoint.trim_end_matches('/'),
|
||||||
|
canonical_uri
|
||||||
|
);
|
||||||
|
|
||||||
|
let now = chrono::Utc::now();
|
||||||
|
let amz_date = now.format("%Y%m%dT%H%M%SZ").to_string();
|
||||||
|
let date_stamp = now.format("%Y%m%d").to_string();
|
||||||
|
let payload_hash = hex::encode(Sha256::digest(body));
|
||||||
|
// App secret may store place-neutral "default"; RGW on cb1 is configured as us-east-1.
|
||||||
|
let region = match credentials.region.as_str() {
|
||||||
|
"" | "default" => "us-east-1",
|
||||||
|
other => other,
|
||||||
|
};
|
||||||
|
let service = "s3";
|
||||||
|
let credential_scope = format!("{date_stamp}/{region}/{service}/aws4_request");
|
||||||
|
|
||||||
|
// Query params: name=value, sorted; empty value still needs '='.
|
||||||
|
let canonical_query = "cors=";
|
||||||
|
let canonical_headers = format!(
|
||||||
|
"content-type:application/xml\nhost:{host_header}\nx-amz-content-sha256:{payload_hash}\nx-amz-date:{amz_date}\n"
|
||||||
|
);
|
||||||
|
let signed_headers = "content-type;host;x-amz-content-sha256;x-amz-date";
|
||||||
|
let canonical_request = format!(
|
||||||
|
"PUT\n{canonical_uri}\n{canonical_query}\n{canonical_headers}\n{signed_headers}\n{payload_hash}"
|
||||||
|
);
|
||||||
|
let string_to_sign = format!(
|
||||||
|
"AWS4-HMAC-SHA256\n{amz_date}\n{credential_scope}\n{}",
|
||||||
|
hex::encode(Sha256::digest(canonical_request.as_bytes()))
|
||||||
|
);
|
||||||
|
let signing_key = aws4_signing_key(&credentials.secret_key, &date_stamp, region, service);
|
||||||
|
let signature = hex::encode(hmac_sha256(&signing_key, string_to_sign.as_bytes()));
|
||||||
|
let authorization = format!(
|
||||||
|
"AWS4-HMAC-SHA256 Credential={}/{credential_scope}, SignedHeaders={signed_headers}, Signature={signature}",
|
||||||
|
credentials.access_key
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(SignedS3Request {
|
||||||
|
url,
|
||||||
|
host: host_header,
|
||||||
|
amz_date,
|
||||||
|
payload_hash,
|
||||||
|
authorization,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// AWS SigV4 URI encode. `encode_slash` is false for path segments that must keep `/`.
|
||||||
|
fn aws_uri_encode(input: &str, encode_slash: bool) -> String {
|
||||||
|
let mut out = String::with_capacity(input.len());
|
||||||
|
for b in input.bytes() {
|
||||||
|
match b {
|
||||||
|
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
|
||||||
|
out.push(b as char);
|
||||||
|
}
|
||||||
|
b'/' if !encode_slash => out.push('/'),
|
||||||
|
_ => out.push_str(&format!("%{b:02X}")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cors_configuration_xml(origins: &[&str]) -> String {
|
||||||
|
let mut rules = String::new();
|
||||||
|
for origin in origins {
|
||||||
|
let origin = xml_escape(origin);
|
||||||
|
rules.push_str(&format!(
|
||||||
|
r#"
|
||||||
|
<CORSRule>
|
||||||
|
<AllowedOrigin>{origin}</AllowedOrigin>
|
||||||
|
<AllowedMethod>GET</AllowedMethod>
|
||||||
|
<AllowedMethod>PUT</AllowedMethod>
|
||||||
|
<AllowedMethod>POST</AllowedMethod>
|
||||||
|
<AllowedMethod>DELETE</AllowedMethod>
|
||||||
|
<AllowedMethod>HEAD</AllowedMethod>
|
||||||
|
<AllowedHeader>*</AllowedHeader>
|
||||||
|
<ExposeHeader>ETag</ExposeHeader>
|
||||||
|
<ExposeHeader>x-amz-request-id</ExposeHeader>
|
||||||
|
<ExposeHeader>x-amz-id-2</ExposeHeader>
|
||||||
|
<MaxAgeSeconds>3600</MaxAgeSeconds>
|
||||||
|
</CORSRule>"#
|
||||||
|
));
|
||||||
|
}
|
||||||
|
format!(
|
||||||
|
"<?xml version=\"1.0\" encoding=\"UTF-8\"?><CORSConfiguration>{rules}\n</CORSConfiguration>"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn xml_escape(value: &str) -> String {
|
||||||
|
value
|
||||||
|
.replace('&', "&")
|
||||||
|
.replace('<', "<")
|
||||||
|
.replace('>', ">")
|
||||||
|
.replace('"', """)
|
||||||
|
.replace('\'', "'")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn hmac_sha256(key: &[u8], data: &[u8]) -> Vec<u8> {
|
||||||
|
let mut mac = Hmac::<Sha256>::new_from_slice(key).expect("HMAC accepts any key length");
|
||||||
|
mac.update(data);
|
||||||
|
mac.finalize().into_bytes().to_vec()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn aws4_signing_key(secret: &str, date: &str, region: &str, service: &str) -> Vec<u8> {
|
||||||
|
let k_date = hmac_sha256(format!("AWS4{secret}").as_bytes(), date.as_bytes());
|
||||||
|
let k_region = hmac_sha256(&k_date, region.as_bytes());
|
||||||
|
let k_service = hmac_sha256(&k_region, service.as_bytes());
|
||||||
|
hmac_sha256(&k_service, b"aws4_request")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn claim_pins_bucket_name_and_max_size() {
|
||||||
|
let score = ObjectBucketScore::new("ns", "recipe1-files").max_size("10G");
|
||||||
|
let claim = score.claim();
|
||||||
|
assert_eq!(claim.spec.bucket_name.as_deref(), Some("recipe1-files"));
|
||||||
|
assert_eq!(claim.spec.storage_class_name, "ceph-bucket");
|
||||||
|
assert_eq!(
|
||||||
|
claim
|
||||||
|
.spec
|
||||||
|
.additional_config
|
||||||
|
.get("maxSize")
|
||||||
|
.map(String::as_str),
|
||||||
|
Some("10G")
|
||||||
|
);
|
||||||
|
assert_eq!(score.app_secret_name(), "recipe1-files-app");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn empty_bucket_region_defaults_to_default() {
|
||||||
|
let cm = ConfigMap {
|
||||||
|
data: Some(BTreeMap::from([
|
||||||
|
("BUCKET_HOST".into(), "rgw.svc".into()),
|
||||||
|
("BUCKET_PORT".into(), "80".into()),
|
||||||
|
("BUCKET_NAME".into(), "files".into()),
|
||||||
|
("BUCKET_REGION".into(), "".into()),
|
||||||
|
])),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let provisioner = Secret {
|
||||||
|
data: Some(BTreeMap::from([
|
||||||
|
("AWS_ACCESS_KEY_ID".into(), ByteString(b"ak".to_vec())),
|
||||||
|
("AWS_SECRET_ACCESS_KEY".into(), ByteString(b"sk".to_vec())),
|
||||||
|
])),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let credentials = bucket_credentials(&cm, &provisioner, None).unwrap();
|
||||||
|
assert_eq!(credentials.region, "default");
|
||||||
|
assert_eq!(credentials.endpoint, "http://rgw.svc");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cors_xml_lists_origins_and_methods() {
|
||||||
|
let xml = cors_configuration_xml(&["https://app.example.com"]);
|
||||||
|
assert!(xml.contains("<AllowedOrigin>https://app.example.com</AllowedOrigin>"));
|
||||||
|
assert!(xml.contains("<AllowedMethod>PUT</AllowedMethod>"));
|
||||||
|
assert!(xml.contains("<AllowedHeader>*</AllowedHeader>"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sign_s3_put_cors_uses_cors_eq_query_and_us_east_1_for_default_region() {
|
||||||
|
let credentials = BucketCredentials {
|
||||||
|
endpoint: "https://s3.example.com".into(),
|
||||||
|
bucket: "my-bucket".into(),
|
||||||
|
region: "default".into(),
|
||||||
|
access_key: "AKIA".into(),
|
||||||
|
secret_key: "secret".into(),
|
||||||
|
};
|
||||||
|
let signed = sign_s3_put_cors(&credentials, b"<CORSConfiguration/>").unwrap();
|
||||||
|
assert_eq!(signed.url, "https://s3.example.com/my-bucket?cors");
|
||||||
|
assert!(signed.authorization.contains("/us-east-1/s3/aws4_request"));
|
||||||
|
// Signature is deterministic for fixed clock only; at least shape is present.
|
||||||
|
assert!(signed.authorization.contains("Signature="));
|
||||||
|
assert_eq!(signed.host, "s3.example.com");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn endpoint_override_replaces_internal_rgw_url() {
|
||||||
|
let cm = ConfigMap {
|
||||||
|
data: Some(BTreeMap::from([
|
||||||
|
("BUCKET_HOST".into(), "rgw.svc".into()),
|
||||||
|
("BUCKET_PORT".into(), "25080".into()),
|
||||||
|
("BUCKET_NAME".into(), "files".into()),
|
||||||
|
])),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let provisioner = Secret {
|
||||||
|
data: Some(BTreeMap::from([
|
||||||
|
("AWS_ACCESS_KEY_ID".into(), ByteString(b"ak".to_vec())),
|
||||||
|
("AWS_SECRET_ACCESS_KEY".into(), ByteString(b"sk".to_vec())),
|
||||||
|
])),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let credentials =
|
||||||
|
bucket_credentials(&cm, &provisioner, Some("https://s3.cb1.nationtech.io")).unwrap();
|
||||||
|
assert_eq!(credentials.endpoint, "https://s3.cb1.nationtech.io");
|
||||||
|
assert_eq!(credentials.bucket, "files");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,14 +4,15 @@ use std::time::Duration;
|
|||||||
|
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use harmony_config::{Config, ConfigClient};
|
use harmony_config::{Config, ConfigClient};
|
||||||
use harmony_k8s::ClusterConnection;
|
use harmony_k8s::{ClusterConnection, K8sClient};
|
||||||
use harmony_types::id::Id;
|
use harmony_types::id::Id;
|
||||||
use harmony_types::k8s_name::K8sName;
|
use harmony_types::k8s_name::K8sName;
|
||||||
use k8s_openapi::api::core::v1::{Secret, ServiceAccount};
|
use k8s_openapi::api::core::v1::{Namespace, Secret, ServiceAccount};
|
||||||
use k8s_openapi::api::rbac::v1::{
|
use k8s_openapi::api::rbac::v1::{
|
||||||
ClusterRole, ClusterRoleBinding, PolicyRule, Role, RoleBinding, RoleRef, Subject,
|
ClusterRole, ClusterRoleBinding, PolicyRule, Role, RoleBinding, RoleRef, Subject,
|
||||||
};
|
};
|
||||||
use kube::api::ObjectMeta;
|
use kube::api::ObjectMeta;
|
||||||
|
use kube::config::{KubeConfigOptions, Kubeconfig};
|
||||||
use schemars::JsonSchema;
|
use schemars::JsonSchema;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
@@ -32,6 +33,7 @@ pub struct TenantCredentialScore {
|
|||||||
namespace: K8sName,
|
namespace: K8sName,
|
||||||
name: K8sName,
|
name: K8sName,
|
||||||
rules: Vec<PolicyRule>,
|
rules: Vec<PolicyRule>,
|
||||||
|
role_subjects: Vec<Subject>,
|
||||||
#[serde(skip)]
|
#[serde(skip)]
|
||||||
store: Arc<ConfigClient>,
|
store: Arc<ConfigClient>,
|
||||||
allow_insecure_source: bool,
|
allow_insecure_source: bool,
|
||||||
@@ -59,11 +61,17 @@ impl TenantCredentialScore {
|
|||||||
namespace,
|
namespace,
|
||||||
name,
|
name,
|
||||||
rules,
|
rules,
|
||||||
|
role_subjects: Vec::new(),
|
||||||
store,
|
store,
|
||||||
allow_insecure_source,
|
allow_insecure_source,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn with_role_subjects(mut self, subjects: Vec<Subject>) -> Self {
|
||||||
|
self.role_subjects = subjects;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
fn service_account(&self) -> ServiceAccount {
|
fn service_account(&self) -> ServiceAccount {
|
||||||
ServiceAccount {
|
ServiceAccount {
|
||||||
metadata: ObjectMeta {
|
metadata: ObjectMeta {
|
||||||
@@ -88,6 +96,8 @@ impl TenantCredentialScore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn role_binding(&self) -> RoleBinding {
|
fn role_binding(&self) -> RoleBinding {
|
||||||
|
let mut subjects = vec![self.subject()];
|
||||||
|
subjects.extend(self.role_subjects.clone());
|
||||||
RoleBinding {
|
RoleBinding {
|
||||||
metadata: ObjectMeta {
|
metadata: ObjectMeta {
|
||||||
name: Some(self.name.to_string()),
|
name: Some(self.name.to_string()),
|
||||||
@@ -99,7 +109,7 @@ impl TenantCredentialScore {
|
|||||||
kind: "Role".to_string(),
|
kind: "Role".to_string(),
|
||||||
name: self.name.to_string(),
|
name: self.name.to_string(),
|
||||||
},
|
},
|
||||||
subjects: Some(vec![self.subject()]),
|
subjects: Some(subjects),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -280,15 +290,50 @@ impl<T: Topology + K8sclient> Interpret<T> for TenantCredentialInterpret {
|
|||||||
&token,
|
&token,
|
||||||
&certificate_authority_data,
|
&certificate_authority_data,
|
||||||
)?;
|
)?;
|
||||||
|
let generated_config = kube::Config::from_custom_kubeconfig(
|
||||||
|
serde_yaml::from_str::<Kubeconfig>(&kubeconfig).map_err(|error| {
|
||||||
|
InterpretError::new(format!("parse generated tenant kubeconfig: {error}"))
|
||||||
|
})?,
|
||||||
|
&KubeConfigOptions::default(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|error| {
|
||||||
|
InterpretError::new(format!("load generated tenant kubeconfig: {error}"))
|
||||||
|
})?;
|
||||||
|
let generated_client =
|
||||||
|
K8sClient::new(kube::Client::try_from(generated_config).map_err(|error| {
|
||||||
|
InterpretError::new(format!("create generated tenant client: {error}"))
|
||||||
|
})?);
|
||||||
|
if generated_client
|
||||||
|
.get_resource::<Namespace>(namespace, None)
|
||||||
|
.await
|
||||||
|
.map_err(|error| {
|
||||||
|
InterpretError::new(format!("verify generated tenant credentials: {error}"))
|
||||||
|
})?
|
||||||
|
.is_none()
|
||||||
|
{
|
||||||
|
return Err(InterpretError::new(format!(
|
||||||
|
"generated tenant credentials cannot read namespace '{namespace}'"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
let access = ClusterAccess { kubeconfig };
|
||||||
self.score
|
self.score
|
||||||
.store
|
.store
|
||||||
.set(&ClusterAccess { kubeconfig })
|
.set(&access)
|
||||||
.await
|
.await
|
||||||
.map_err(|error| InterpretError::new(format!("store tenant ClusterAccess: {error}")))?;
|
.map_err(|error| InterpretError::new(format!("store tenant ClusterAccess: {error}")))?;
|
||||||
|
let stored: ClusterAccess = self.score.store.get().await.map_err(|error| {
|
||||||
|
InterpretError::new(format!("verify stored ClusterAccess: {error}"))
|
||||||
|
})?;
|
||||||
|
if stored.kubeconfig != access.kubeconfig {
|
||||||
|
return Err(InterpretError::new(
|
||||||
|
"stored ClusterAccess does not match the verified tenant credentials".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
Ok(Outcome::success(format!(
|
Ok(Outcome::success(format!(
|
||||||
"tenant deployer access stored for namespace '{}'",
|
"tenant deployer access stored for namespace '{}' on cluster '{}' at '{}'",
|
||||||
self.score.namespace
|
self.score.namespace, connection.name, connection.server
|
||||||
)))
|
)))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -317,13 +362,16 @@ fn tenant_kubeconfig(
|
|||||||
certificate_authority_data: &str,
|
certificate_authority_data: &str,
|
||||||
) -> Result<String, InterpretError> {
|
) -> Result<String, InterpretError> {
|
||||||
let context = format!("{user}@{}", connection.name);
|
let context = format!("{user}@{}", connection.name);
|
||||||
let mut cluster = serde_json::Map::from_iter([
|
let mut cluster =
|
||||||
("server".to_string(), connection.server.clone().into()),
|
serde_json::Map::from_iter([("server".to_string(), connection.server.clone().into())]);
|
||||||
(
|
if connection.tls_verified {
|
||||||
|
cluster.insert(
|
||||||
"certificate-authority-data".to_string(),
|
"certificate-authority-data".to_string(),
|
||||||
certificate_authority_data.into(),
|
certificate_authority_data.into(),
|
||||||
),
|
);
|
||||||
]);
|
} else {
|
||||||
|
cluster.insert("insecure-skip-tls-verify".to_string(), true.into());
|
||||||
|
}
|
||||||
if let Some(name) = &connection.tls_server_name {
|
if let Some(name) = &connection.tls_server_name {
|
||||||
cluster.insert("tls-server-name".to_string(), name.clone().into());
|
cluster.insert("tls-server-name".to_string(), name.clone().into());
|
||||||
}
|
}
|
||||||
@@ -374,6 +422,27 @@ mod tests {
|
|||||||
assert!(!kubeconfig.contains("cluster-admin"));
|
assert!(!kubeconfig.contains("cluster-admin"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn kubeconfig_preserves_insecure_source_tls_mode() {
|
||||||
|
let kubeconfig = tenant_kubeconfig(
|
||||||
|
&ClusterConnection {
|
||||||
|
name: "lab".to_string(),
|
||||||
|
server: "https://192.0.2.10:6443".to_string(),
|
||||||
|
tls_server_name: None,
|
||||||
|
proxy_url: None,
|
||||||
|
tls_verified: false,
|
||||||
|
},
|
||||||
|
"customer-fleet",
|
||||||
|
"fleet-deployer",
|
||||||
|
"tenant-token",
|
||||||
|
"unused-ca",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert!(kubeconfig.contains("insecure-skip-tls-verify: true"));
|
||||||
|
assert!(!kubeconfig.contains("certificate-authority-data"));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn score_serialization_excludes_config_destination() {
|
fn score_serialization_excludes_config_destination() {
|
||||||
let score = TenantCredentialScore::new(
|
let score = TenantCredentialScore::new(
|
||||||
@@ -387,4 +456,25 @@ mod tests {
|
|||||||
let serialized = serde_json::to_string(&score).unwrap();
|
let serialized = serde_json::to_string(&score).unwrap();
|
||||||
assert!(!serialized.contains("store"));
|
assert!(!serialized.contains("store"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn additional_role_subjects_share_the_deployer_role() {
|
||||||
|
let score = TenantCredentialScore::new(
|
||||||
|
"customer-fleet".parse().unwrap(),
|
||||||
|
"fleet-deployer".parse().unwrap(),
|
||||||
|
Vec::new(),
|
||||||
|
Arc::new(ConfigClient::new(Vec::new())),
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
.with_role_subjects(vec![Subject {
|
||||||
|
api_group: Some("rbac.authorization.k8s.io".into()),
|
||||||
|
kind: "User".into(),
|
||||||
|
name: "alice@example.com".into(),
|
||||||
|
namespace: None,
|
||||||
|
}]);
|
||||||
|
|
||||||
|
let subjects = score.role_binding().subjects.unwrap();
|
||||||
|
assert_eq!(subjects.len(), 2);
|
||||||
|
assert_eq!(subjects[1].name, "alice@example.com");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
932
harmony/src/modules/zitadel/contract.rs
Normal file
932
harmony/src/modules/zitadel/contract.rs
Normal file
@@ -0,0 +1,932 @@
|
|||||||
|
//! Typed, additive Zitadel provisioning for [`super::ZitadelSetupScore`].
|
||||||
|
//!
|
||||||
|
//! Declared resources are created and selected mutable settings are updated.
|
||||||
|
//! Resources removed from a contract are not deleted, and role metadata drift
|
||||||
|
//! is not yet converged.
|
||||||
|
|
||||||
|
use std::{
|
||||||
|
collections::{HashMap, HashSet},
|
||||||
|
fmt,
|
||||||
|
};
|
||||||
|
|
||||||
|
use harmony_config::Config;
|
||||||
|
use schemars::JsonSchema;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
macro_rules! named_ref {
|
||||||
|
($name:ident) => {
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||||
|
#[serde(transparent)]
|
||||||
|
pub struct $name(String);
|
||||||
|
|
||||||
|
impl $name {
|
||||||
|
pub fn new(name: impl Into<String>) -> Self {
|
||||||
|
Self(name.into())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn name(&self) -> &str {
|
||||||
|
&self.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<&str> for $name {
|
||||||
|
fn from(name: &str) -> Self {
|
||||||
|
Self::new(name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<String> for $name {
|
||||||
|
fn from(name: String) -> Self {
|
||||||
|
Self::new(name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
named_ref!(ZitadelProjectRef);
|
||||||
|
named_ref!(ZitadelHumanRef);
|
||||||
|
named_ref!(ZitadelMachineRef);
|
||||||
|
named_ref!(ZitadelBootstrapSecretRef);
|
||||||
|
|
||||||
|
/// An application identity is project-scoped because Zitadel permits the same
|
||||||
|
/// application name in different projects.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||||
|
pub struct ZitadelApplicationRef {
|
||||||
|
project: ZitadelProjectRef,
|
||||||
|
name: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ZitadelApplicationRef {
|
||||||
|
pub fn new(project: ZitadelProjectRef, name: impl Into<String>) -> Self {
|
||||||
|
Self {
|
||||||
|
project,
|
||||||
|
name: name.into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn project(&self) -> &ZitadelProjectRef {
|
||||||
|
&self.project
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn name(&self) -> &str {
|
||||||
|
&self.name
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn cache_key(&self) -> String {
|
||||||
|
format!("{}::{}", self.project.name(), self.name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||||
|
pub struct ZitadelRoleRef {
|
||||||
|
pub project: ZitadelProjectRef,
|
||||||
|
pub key: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ZitadelRoleRef {
|
||||||
|
pub fn new(project: ZitadelProjectRef, key: impl Into<String>) -> Self {
|
||||||
|
Self {
|
||||||
|
project,
|
||||||
|
key: key.into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "snake_case", tag = "kind", content = "subject")]
|
||||||
|
pub enum ZitadelPrincipalRef {
|
||||||
|
Human(ZitadelHumanRef),
|
||||||
|
Machine(ZitadelMachineRef),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<ZitadelHumanRef> for ZitadelPrincipalRef {
|
||||||
|
fn from(value: ZitadelHumanRef) -> Self {
|
||||||
|
Self::Human(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<ZitadelMachineRef> for ZitadelPrincipalRef {
|
||||||
|
fn from(value: ZitadelMachineRef) -> Self {
|
||||||
|
Self::Machine(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ZitadelPrincipalRef {
|
||||||
|
pub fn username(&self) -> &str {
|
||||||
|
match self {
|
||||||
|
Self::Human(value) => value.name(),
|
||||||
|
Self::Machine(value) => value.name(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Named bootstrap values stored under deployer-writable `harmony-state/`.
|
||||||
|
/// Missing keys are generated on first human create. Contract declarations
|
||||||
|
/// serialize only a [`ZitadelBootstrapSecretRef`], never the password itself.
|
||||||
|
#[derive(Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema, Config)]
|
||||||
|
#[config(secret)]
|
||||||
|
pub struct ZitadelBootstrapSecrets {
|
||||||
|
#[config(secret)]
|
||||||
|
values: HashMap<String, String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ZitadelBootstrapSecrets {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self::default()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn insert(
|
||||||
|
mut self,
|
||||||
|
reference: ZitadelBootstrapSecretRef,
|
||||||
|
value: impl Into<String>,
|
||||||
|
) -> Self {
|
||||||
|
self.values
|
||||||
|
.insert(reference.name().to_string(), value.into());
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn resolve(&self, reference: &ZitadelBootstrapSecretRef) -> Option<&str> {
|
||||||
|
self.values.get(reference.name()).map(String::as_str)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Debug for ZitadelBootstrapSecrets {
|
||||||
|
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
formatter
|
||||||
|
.debug_struct("ZitadelBootstrapSecrets")
|
||||||
|
.field("values", &format_args!("[REDACTED; {}]", self.values.len()))
|
||||||
|
.finish()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum ZitadelAccessTokenType {
|
||||||
|
#[default]
|
||||||
|
Bearer,
|
||||||
|
Jwt,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ZitadelAccessTokenType {
|
||||||
|
pub(crate) fn api_value(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::Bearer => "OIDC_TOKEN_TYPE_BEARER",
|
||||||
|
Self::Jwt => "OIDC_TOKEN_TYPE_JWT",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum ZitadelOidcResponseType {
|
||||||
|
Code,
|
||||||
|
IdToken,
|
||||||
|
IdTokenToken,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ZitadelOidcResponseType {
|
||||||
|
pub(crate) fn api_value(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::Code => "OIDC_RESPONSE_TYPE_CODE",
|
||||||
|
Self::IdToken => "OIDC_RESPONSE_TYPE_ID_TOKEN",
|
||||||
|
Self::IdTokenToken => "OIDC_RESPONSE_TYPE_ID_TOKEN_TOKEN",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum ZitadelOidcGrantType {
|
||||||
|
AuthorizationCode,
|
||||||
|
Implicit,
|
||||||
|
RefreshToken,
|
||||||
|
DeviceCode,
|
||||||
|
TokenExchange,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ZitadelOidcGrantType {
|
||||||
|
pub(crate) fn api_value(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::AuthorizationCode => "OIDC_GRANT_TYPE_AUTHORIZATION_CODE",
|
||||||
|
Self::Implicit => "OIDC_GRANT_TYPE_IMPLICIT",
|
||||||
|
Self::RefreshToken => "OIDC_GRANT_TYPE_REFRESH_TOKEN",
|
||||||
|
Self::DeviceCode => "OIDC_GRANT_TYPE_DEVICE_CODE",
|
||||||
|
Self::TokenExchange => "OIDC_GRANT_TYPE_TOKEN_EXCHANGE",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum ZitadelOidcAppType {
|
||||||
|
Web,
|
||||||
|
UserAgent,
|
||||||
|
Native,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ZitadelOidcAppType {
|
||||||
|
pub(crate) fn api_value(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::Web => "OIDC_APP_TYPE_WEB",
|
||||||
|
Self::UserAgent => "OIDC_APP_TYPE_USER_AGENT",
|
||||||
|
Self::Native => "OIDC_APP_TYPE_NATIVE",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum ZitadelOidcAuthMethod {
|
||||||
|
None,
|
||||||
|
Basic,
|
||||||
|
Post,
|
||||||
|
PrivateKeyJwt,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum ZitadelLoginVersion {
|
||||||
|
V1,
|
||||||
|
V2 {
|
||||||
|
#[serde(default)]
|
||||||
|
base_uri: Option<String>,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ZitadelLoginVersion {
|
||||||
|
pub fn v2() -> Self {
|
||||||
|
Self::V2 { base_uri: None }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn v2_at(base_uri: impl Into<String>) -> Self {
|
||||||
|
Self::V2 {
|
||||||
|
base_uri: Some(base_uri.into()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn api_value(&self) -> serde_json::Value {
|
||||||
|
match self {
|
||||||
|
Self::V1 => serde_json::json!({ "loginV1": {} }),
|
||||||
|
Self::V2 { base_uri: None } => serde_json::json!({ "loginV2": {} }),
|
||||||
|
Self::V2 {
|
||||||
|
base_uri: Some(base_uri),
|
||||||
|
} => serde_json::json!({ "loginV2": { "baseUri": base_uri } }),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ZitadelOidcAuthMethod {
|
||||||
|
pub(crate) fn api_value(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::None => "OIDC_AUTH_METHOD_TYPE_NONE",
|
||||||
|
Self::Basic => "OIDC_AUTH_METHOD_TYPE_BASIC",
|
||||||
|
Self::Post => "OIDC_AUTH_METHOD_TYPE_POST",
|
||||||
|
Self::PrivateKeyJwt => "OIDC_AUTH_METHOD_TYPE_PRIVATE_KEY_JWT",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct ZitadelOidcTokenSettings {
|
||||||
|
#[serde(default)]
|
||||||
|
pub access_token_type: ZitadelAccessTokenType,
|
||||||
|
#[serde(default)]
|
||||||
|
pub id_token_role_assertion: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
pub id_token_userinfo_assertion: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
pub access_token_role_assertion: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
pub clock_skew: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub dev_mode: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for ZitadelOidcTokenSettings {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
access_token_type: ZitadelAccessTokenType::Bearer,
|
||||||
|
id_token_role_assertion: false,
|
||||||
|
id_token_userinfo_assertion: true,
|
||||||
|
access_token_role_assertion: false,
|
||||||
|
clock_skew: None,
|
||||||
|
dev_mode: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct ZitadelProjectDeclaration {
|
||||||
|
pub project: ZitadelProjectRef,
|
||||||
|
#[serde(default = "default_true")]
|
||||||
|
pub project_role_assertion: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
pub project_role_check: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
pub has_project_check: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_true() -> bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ZitadelProjectDeclaration {
|
||||||
|
pub fn new(project: ZitadelProjectRef) -> Self {
|
||||||
|
Self {
|
||||||
|
project,
|
||||||
|
project_role_assertion: true,
|
||||||
|
project_role_check: false,
|
||||||
|
has_project_check: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct ZitadelRoleDeclaration {
|
||||||
|
pub role: ZitadelRoleRef,
|
||||||
|
pub display_name: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub group: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ZitadelRoleDeclaration {
|
||||||
|
pub fn new(role: ZitadelRoleRef, display_name: impl Into<String>) -> Self {
|
||||||
|
Self {
|
||||||
|
role,
|
||||||
|
display_name: display_name.into(),
|
||||||
|
group: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct ZitadelOidcApplicationDeclaration {
|
||||||
|
pub application: ZitadelApplicationRef,
|
||||||
|
#[serde(default)]
|
||||||
|
pub redirect_uris: Vec<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub post_logout_redirect_uris: Vec<String>,
|
||||||
|
pub response_types: Vec<ZitadelOidcResponseType>,
|
||||||
|
pub grant_types: Vec<ZitadelOidcGrantType>,
|
||||||
|
pub app_type: ZitadelOidcAppType,
|
||||||
|
pub auth_method: ZitadelOidcAuthMethod,
|
||||||
|
#[serde(default)]
|
||||||
|
pub login_version: Option<ZitadelLoginVersion>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub token_settings: ZitadelOidcTokenSettings,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ZitadelOidcApplicationDeclaration {
|
||||||
|
pub fn web_pkce(application: ZitadelApplicationRef, redirect_uris: Vec<String>) -> Self {
|
||||||
|
Self {
|
||||||
|
application,
|
||||||
|
redirect_uris,
|
||||||
|
post_logout_redirect_uris: Vec::new(),
|
||||||
|
response_types: vec![ZitadelOidcResponseType::Code],
|
||||||
|
grant_types: vec![
|
||||||
|
ZitadelOidcGrantType::AuthorizationCode,
|
||||||
|
ZitadelOidcGrantType::RefreshToken,
|
||||||
|
],
|
||||||
|
app_type: ZitadelOidcAppType::UserAgent,
|
||||||
|
auth_method: ZitadelOidcAuthMethod::None,
|
||||||
|
login_version: None,
|
||||||
|
token_settings: ZitadelOidcTokenSettings::default(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A Zitadel API application (resource server), scoped to its owning project
|
||||||
|
/// through [`ZitadelApplicationRef`].
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct ZitadelApiApplicationDeclaration {
|
||||||
|
pub application: ZitadelApplicationRef,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ZitadelApiApplicationDeclaration {
|
||||||
|
pub fn new(application: ZitadelApplicationRef) -> Self {
|
||||||
|
Self { application }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct ZitadelHumanDeclaration {
|
||||||
|
pub human: ZitadelHumanRef,
|
||||||
|
pub first_name: String,
|
||||||
|
pub last_name: String,
|
||||||
|
pub bootstrap_password: ZitadelBootstrapSecretRef,
|
||||||
|
#[serde(default)]
|
||||||
|
pub password_change_required: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum ZitadelMachineKeyDeclaration {
|
||||||
|
Json,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct ZitadelMachineDeclaration {
|
||||||
|
pub machine: ZitadelMachineRef,
|
||||||
|
pub name: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub key: Option<ZitadelMachineKeyDeclaration>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub client_secret: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct ZitadelProjectRoleAssignment {
|
||||||
|
pub principal: ZitadelPrincipalRef,
|
||||||
|
pub project: ZitadelProjectRef,
|
||||||
|
pub roles: Vec<ZitadelRoleRef>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct ZitadelOrgRoleAssignment {
|
||||||
|
pub principal: ZitadelPrincipalRef,
|
||||||
|
pub roles: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct ZitadelInstanceRoleAssignment {
|
||||||
|
pub principal: ZitadelPrincipalRef,
|
||||||
|
pub roles: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct ZitadelContract {
|
||||||
|
#[serde(default)]
|
||||||
|
pub projects: Vec<ZitadelProjectDeclaration>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub roles: Vec<ZitadelRoleDeclaration>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub applications: Vec<ZitadelOidcApplicationDeclaration>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub api_applications: Vec<ZitadelApiApplicationDeclaration>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub humans: Vec<ZitadelHumanDeclaration>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub machines: Vec<ZitadelMachineDeclaration>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub project_role_assignments: Vec<ZitadelProjectRoleAssignment>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub org_role_assignments: Vec<ZitadelOrgRoleAssignment>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub instance_role_assignments: Vec<ZitadelInstanceRoleAssignment>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ZitadelContract {
|
||||||
|
pub fn project(mut self, project: ZitadelProjectDeclaration) -> Self {
|
||||||
|
self.projects.push(project);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn role(mut self, role: ZitadelRoleDeclaration) -> Self {
|
||||||
|
self.roles.push(role);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn application(mut self, application: ZitadelOidcApplicationDeclaration) -> Self {
|
||||||
|
self.applications.push(application);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn api_application(mut self, application: ZitadelApiApplicationDeclaration) -> Self {
|
||||||
|
self.api_applications.push(application);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn human(mut self, human: ZitadelHumanDeclaration) -> Self {
|
||||||
|
self.humans.push(human);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn machine(mut self, machine: ZitadelMachineDeclaration) -> Self {
|
||||||
|
self.machines.push(machine);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn project_roles(mut self, assignment: ZitadelProjectRoleAssignment) -> Self {
|
||||||
|
self.project_role_assignments.push(assignment);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn org_roles(mut self, assignment: ZitadelOrgRoleAssignment) -> Self {
|
||||||
|
self.org_role_assignments.push(assignment);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn instance_roles(mut self, assignment: ZitadelInstanceRoleAssignment) -> Self {
|
||||||
|
self.instance_role_assignments.push(assignment);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn validate(&self) -> Result<(), String> {
|
||||||
|
fn reject_duplicates<'a>(
|
||||||
|
kind: &str,
|
||||||
|
values: impl IntoIterator<Item = &'a str>,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let mut seen = HashSet::new();
|
||||||
|
for value in values {
|
||||||
|
if !seen.insert(value) {
|
||||||
|
return Err(format!("duplicate {kind} declaration '{value}'"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
reject_duplicates(
|
||||||
|
"project",
|
||||||
|
self.projects.iter().map(|item| item.project.name()),
|
||||||
|
)?;
|
||||||
|
let mut roles = HashSet::new();
|
||||||
|
for role in &self.roles {
|
||||||
|
if !roles.insert(role.role.clone()) {
|
||||||
|
return Err(format!(
|
||||||
|
"duplicate role declaration '{}::{}'",
|
||||||
|
role.role.project.name(),
|
||||||
|
role.role.key
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let mut applications = HashSet::new();
|
||||||
|
for application in self
|
||||||
|
.applications
|
||||||
|
.iter()
|
||||||
|
.map(|item| &item.application)
|
||||||
|
.chain(self.api_applications.iter().map(|item| &item.application))
|
||||||
|
{
|
||||||
|
if !applications.insert(application.clone()) {
|
||||||
|
return Err(format!(
|
||||||
|
"duplicate application declaration '{}::{}'",
|
||||||
|
application.project().name(),
|
||||||
|
application.name()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
reject_duplicates("human", self.humans.iter().map(|item| item.human.name()))?;
|
||||||
|
reject_duplicates(
|
||||||
|
"machine",
|
||||||
|
self.machines.iter().map(|item| item.machine.name()),
|
||||||
|
)?;
|
||||||
|
let humans: HashSet<&str> = self.humans.iter().map(|item| item.human.name()).collect();
|
||||||
|
if let Some(machine) = self
|
||||||
|
.machines
|
||||||
|
.iter()
|
||||||
|
.find(|item| humans.contains(item.machine.name()))
|
||||||
|
{
|
||||||
|
return Err(format!(
|
||||||
|
"principal '{}' is declared as both human and machine",
|
||||||
|
machine.machine.name()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let project_exists =
|
||||||
|
|project: &ZitadelProjectRef| self.projects.iter().any(|item| &item.project == project);
|
||||||
|
let principal_exists = |principal: &ZitadelPrincipalRef| match principal {
|
||||||
|
ZitadelPrincipalRef::Human(human) => {
|
||||||
|
self.humans.iter().any(|item| &item.human == human)
|
||||||
|
}
|
||||||
|
ZitadelPrincipalRef::Machine(machine) => {
|
||||||
|
self.machines.iter().any(|item| &item.machine == machine)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
for role in &self.roles {
|
||||||
|
if !project_exists(&role.role.project) {
|
||||||
|
return Err(format!(
|
||||||
|
"role '{}' references undeclared project '{}'",
|
||||||
|
role.role.key,
|
||||||
|
role.role.project.name()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for app in &self.applications {
|
||||||
|
if !project_exists(app.application.project()) {
|
||||||
|
return Err(format!(
|
||||||
|
"application '{}' references undeclared project '{}'",
|
||||||
|
app.application.name(),
|
||||||
|
app.application.project().name()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if app.response_types.is_empty() || app.grant_types.is_empty() {
|
||||||
|
return Err(format!(
|
||||||
|
"application '{}' requires response_types and grant_types",
|
||||||
|
app.application.name()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for app in &self.api_applications {
|
||||||
|
if !project_exists(app.application.project()) {
|
||||||
|
return Err(format!(
|
||||||
|
"API application '{}' references undeclared project '{}'",
|
||||||
|
app.application.name(),
|
||||||
|
app.application.project().name()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for assignment in &self.project_role_assignments {
|
||||||
|
if !principal_exists(&assignment.principal) {
|
||||||
|
return Err(format!(
|
||||||
|
"project assignment references undeclared principal '{}'",
|
||||||
|
assignment.principal.username()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if !project_exists(&assignment.project) {
|
||||||
|
return Err(format!(
|
||||||
|
"project assignment references undeclared project '{}'",
|
||||||
|
assignment.project.name()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
for role in &assignment.roles {
|
||||||
|
if role.project != assignment.project {
|
||||||
|
return Err(format!(
|
||||||
|
"role '{}' belongs to project '{}', not assignment project '{}'",
|
||||||
|
role.key,
|
||||||
|
role.project.name(),
|
||||||
|
assignment.project.name()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if !self.roles.iter().any(|item| &item.role == role) {
|
||||||
|
return Err(format!(
|
||||||
|
"assignment references undeclared role '{}'",
|
||||||
|
role.key
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for assignment in self
|
||||||
|
.org_role_assignments
|
||||||
|
.iter()
|
||||||
|
.map(|item| &item.principal)
|
||||||
|
.chain(
|
||||||
|
self.instance_role_assignments
|
||||||
|
.iter()
|
||||||
|
.map(|item| &item.principal),
|
||||||
|
)
|
||||||
|
{
|
||||||
|
if !principal_exists(assignment) {
|
||||||
|
return Err(format!(
|
||||||
|
"membership references undeclared principal '{}'",
|
||||||
|
assignment.username()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||||
|
pub struct ZitadelProjectOutputRef {
|
||||||
|
project: ZitadelProjectRef,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ZitadelProjectOutputRef {
|
||||||
|
pub(crate) fn new(project: &ZitadelProjectRef) -> Self {
|
||||||
|
Self {
|
||||||
|
project: project.clone(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn project(&self) -> &ZitadelProjectRef {
|
||||||
|
&self.project
|
||||||
|
}
|
||||||
|
#[doc(hidden)]
|
||||||
|
pub fn config_map_name(&self) -> String {
|
||||||
|
format!("zitadel-{}-project", self.project.name())
|
||||||
|
}
|
||||||
|
#[doc(hidden)]
|
||||||
|
pub fn project_id_key(&self) -> &'static str {
|
||||||
|
"project_id"
|
||||||
|
}
|
||||||
|
#[doc(hidden)]
|
||||||
|
pub fn roles_claim_key(&self) -> &'static str {
|
||||||
|
"roles_claim"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||||
|
pub struct ZitadelApplicationOutputRef {
|
||||||
|
application: ZitadelApplicationRef,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ZitadelApplicationOutputRef {
|
||||||
|
pub(crate) fn new(application: &ZitadelApplicationRef) -> Self {
|
||||||
|
Self {
|
||||||
|
application: application.clone(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn application(&self) -> &ZitadelApplicationRef {
|
||||||
|
&self.application
|
||||||
|
}
|
||||||
|
#[doc(hidden)]
|
||||||
|
pub fn config_map_name(&self) -> String {
|
||||||
|
format!(
|
||||||
|
"zitadel-{}-{}-oidc",
|
||||||
|
self.application.project().name(),
|
||||||
|
self.application.name()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
#[doc(hidden)]
|
||||||
|
pub fn client_id_key(&self) -> &'static str {
|
||||||
|
"client_id"
|
||||||
|
}
|
||||||
|
#[doc(hidden)]
|
||||||
|
pub fn project_id_key(&self) -> &'static str {
|
||||||
|
"project_id"
|
||||||
|
}
|
||||||
|
#[doc(hidden)]
|
||||||
|
pub fn roles_claim_key(&self) -> &'static str {
|
||||||
|
"roles_claim"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||||
|
pub struct ZitadelMachineOutputRef {
|
||||||
|
machine: ZitadelMachineRef,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ZitadelMachineOutputRef {
|
||||||
|
pub(crate) fn new(machine: &ZitadelMachineRef) -> Self {
|
||||||
|
Self {
|
||||||
|
machine: machine.clone(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn machine(&self) -> &ZitadelMachineRef {
|
||||||
|
&self.machine
|
||||||
|
}
|
||||||
|
#[doc(hidden)]
|
||||||
|
pub fn secret_name(&self) -> String {
|
||||||
|
format!("zitadel-{}-machine", self.machine.name())
|
||||||
|
}
|
||||||
|
#[doc(hidden)]
|
||||||
|
pub fn user_id_key(&self) -> &'static str {
|
||||||
|
"user_id"
|
||||||
|
}
|
||||||
|
#[doc(hidden)]
|
||||||
|
pub fn key_json_key(&self) -> &'static str {
|
||||||
|
"key.json"
|
||||||
|
}
|
||||||
|
#[doc(hidden)]
|
||||||
|
pub fn client_id_key(&self) -> &'static str {
|
||||||
|
"client_id"
|
||||||
|
}
|
||||||
|
#[doc(hidden)]
|
||||||
|
pub fn client_secret_key(&self) -> &'static str {
|
||||||
|
"client_secret"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn declaration_serializes_only_bootstrap_secret_reference() {
|
||||||
|
let human = ZitadelHumanDeclaration {
|
||||||
|
human: "admin@example.test".into(),
|
||||||
|
first_name: "Admin".into(),
|
||||||
|
last_name: "User".into(),
|
||||||
|
bootstrap_password: ZitadelBootstrapSecretRef::new("initial-admin"),
|
||||||
|
password_change_required: true,
|
||||||
|
};
|
||||||
|
let json = serde_json::to_string(&human).unwrap();
|
||||||
|
assert!(json.contains("initial-admin"));
|
||||||
|
assert!(!json.contains("super-secret"));
|
||||||
|
|
||||||
|
let secrets = ZitadelBootstrapSecrets::new().insert(
|
||||||
|
ZitadelBootstrapSecretRef::new("initial-admin"),
|
||||||
|
"super-secret",
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
<ZitadelBootstrapSecrets as harmony_config::Config>::CLASS,
|
||||||
|
harmony_config::ConfigClass::Secret
|
||||||
|
);
|
||||||
|
let debug = format!("{secrets:?}");
|
||||||
|
assert!(debug.contains("[REDACTED; 1]"));
|
||||||
|
assert!(!debug.contains("super-secret"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn same_application_name_is_scoped_by_project() {
|
||||||
|
let first = ZitadelApplicationRef::new("first".into(), "console");
|
||||||
|
let second = ZitadelApplicationRef::new("second".into(), "console");
|
||||||
|
assert_ne!(first, second);
|
||||||
|
assert_ne!(first.cache_key(), second.cache_key());
|
||||||
|
|
||||||
|
let first_output = ZitadelApplicationOutputRef::new(&first);
|
||||||
|
let second_output = ZitadelApplicationOutputRef::new(&second);
|
||||||
|
assert_ne!(
|
||||||
|
first_output.config_map_name(),
|
||||||
|
second_output.config_map_name()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn duplicate_scoped_application_is_rejected() {
|
||||||
|
let project = ZitadelProjectRef::new("app");
|
||||||
|
let application = ZitadelApplicationRef::new(project.clone(), "console");
|
||||||
|
let declaration = ZitadelOidcApplicationDeclaration::web_pkce(
|
||||||
|
application,
|
||||||
|
vec!["https://app.example.test/callback".into()],
|
||||||
|
);
|
||||||
|
let contract = ZitadelContract::default()
|
||||||
|
.project(ZitadelProjectDeclaration::new(project))
|
||||||
|
.application(declaration.clone())
|
||||||
|
.application(declaration);
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
contract
|
||||||
|
.validate()
|
||||||
|
.unwrap_err()
|
||||||
|
.contains("duplicate application declaration 'app::console'")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn oidc_and_api_applications_share_scoped_uniqueness() {
|
||||||
|
let project = ZitadelProjectRef::new("app");
|
||||||
|
let application = ZitadelApplicationRef::new(project.clone(), "backend");
|
||||||
|
let contract = ZitadelContract::default()
|
||||||
|
.project(ZitadelProjectDeclaration::new(project))
|
||||||
|
.application(ZitadelOidcApplicationDeclaration::web_pkce(
|
||||||
|
application.clone(),
|
||||||
|
vec!["https://app.example.test/callback".into()],
|
||||||
|
))
|
||||||
|
.api_application(ZitadelApiApplicationDeclaration::new(application));
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
contract
|
||||||
|
.validate()
|
||||||
|
.unwrap_err()
|
||||||
|
.contains("duplicate application declaration 'app::backend'")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn same_api_application_name_in_different_projects_is_valid() {
|
||||||
|
let first = ZitadelProjectRef::new("first");
|
||||||
|
let second = ZitadelProjectRef::new("second");
|
||||||
|
let contract = ZitadelContract::default()
|
||||||
|
.project(ZitadelProjectDeclaration::new(first.clone()))
|
||||||
|
.project(ZitadelProjectDeclaration::new(second.clone()))
|
||||||
|
.api_application(ZitadelApiApplicationDeclaration::new(
|
||||||
|
ZitadelApplicationRef::new(first, "backend"),
|
||||||
|
))
|
||||||
|
.api_application(ZitadelApiApplicationDeclaration::new(
|
||||||
|
ZitadelApplicationRef::new(second, "backend"),
|
||||||
|
));
|
||||||
|
|
||||||
|
contract.validate().unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn assignment_rejects_a_role_from_another_project() {
|
||||||
|
let project = ZitadelProjectRef::new("app");
|
||||||
|
let other = ZitadelProjectRef::new("other");
|
||||||
|
let human = ZitadelHumanRef::new("admin@example.test");
|
||||||
|
let role = ZitadelRoleRef::new(other.clone(), "admin");
|
||||||
|
let contract = ZitadelContract::default()
|
||||||
|
.project(ZitadelProjectDeclaration::new(project.clone()))
|
||||||
|
.project(ZitadelProjectDeclaration::new(other))
|
||||||
|
.role(ZitadelRoleDeclaration::new(role.clone(), "Admin"))
|
||||||
|
.human(ZitadelHumanDeclaration {
|
||||||
|
human: human.clone(),
|
||||||
|
first_name: "Admin".into(),
|
||||||
|
last_name: "User".into(),
|
||||||
|
bootstrap_password: ZitadelBootstrapSecretRef::new("admin"),
|
||||||
|
password_change_required: false,
|
||||||
|
})
|
||||||
|
.project_roles(ZitadelProjectRoleAssignment {
|
||||||
|
principal: human.into(),
|
||||||
|
project,
|
||||||
|
roles: vec![role],
|
||||||
|
});
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
contract
|
||||||
|
.validate()
|
||||||
|
.unwrap_err()
|
||||||
|
.contains("not assignment project")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn semantic_outputs_serialize_without_kubernetes_coordinates() {
|
||||||
|
let project_ref = ZitadelProjectRef::new("fleet");
|
||||||
|
let app_ref = ZitadelApplicationRef::new(project_ref.clone(), "console");
|
||||||
|
let machine_ref = ZitadelMachineRef::new("callout");
|
||||||
|
let project = ZitadelProjectOutputRef::new(&project_ref);
|
||||||
|
let app = ZitadelApplicationOutputRef::new(&app_ref);
|
||||||
|
let machine = ZitadelMachineOutputRef::new(&machine_ref);
|
||||||
|
assert_eq!(project.project(), &project_ref);
|
||||||
|
assert_eq!(app.application(), &app_ref);
|
||||||
|
assert_eq!(machine.machine(), &machine_ref);
|
||||||
|
assert!(!serde_json::to_string(&app).unwrap().contains("config_map"));
|
||||||
|
assert_eq!(app.config_map_name(), "zitadel-fleet-console-oidc");
|
||||||
|
assert_eq!(machine.secret_name(), "zitadel-callout-machine");
|
||||||
|
assert_eq!(machine.client_secret_key(), "client_secret");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,12 +1,24 @@
|
|||||||
pub mod admin_auth;
|
pub mod admin_auth;
|
||||||
|
pub mod contract;
|
||||||
pub mod setup;
|
pub mod setup;
|
||||||
|
|
||||||
pub use admin_auth::{ADMIN_API_SCOPES, DeviceCodeError, DeviceCodeFlowConfig, device_code_login};
|
pub use admin_auth::{ADMIN_API_SCOPES, DeviceCodeError, DeviceCodeFlowConfig, device_code_login};
|
||||||
|
pub use contract::{
|
||||||
|
ZitadelAccessTokenType, ZitadelApiApplicationDeclaration, ZitadelApplicationOutputRef,
|
||||||
|
ZitadelApplicationRef, ZitadelBootstrapSecretRef, ZitadelBootstrapSecrets, ZitadelContract,
|
||||||
|
ZitadelHumanDeclaration, ZitadelHumanRef, ZitadelInstanceRoleAssignment, ZitadelLoginVersion,
|
||||||
|
ZitadelMachineDeclaration, ZitadelMachineKeyDeclaration, ZitadelMachineOutputRef,
|
||||||
|
ZitadelMachineRef, ZitadelOidcAppType, ZitadelOidcApplicationDeclaration,
|
||||||
|
ZitadelOidcAuthMethod, ZitadelOidcGrantType, ZitadelOidcResponseType, ZitadelOidcTokenSettings,
|
||||||
|
ZitadelOrgRoleAssignment, ZitadelPrincipalRef, ZitadelProjectDeclaration,
|
||||||
|
ZitadelProjectOutputRef, ZitadelProjectRef, ZitadelProjectRoleAssignment,
|
||||||
|
ZitadelRoleDeclaration, ZitadelRoleRef,
|
||||||
|
};
|
||||||
pub use setup::{
|
pub use setup::{
|
||||||
MachineKeyType, MintedDeviceCredentials, ZitadelApiApp, ZitadelAppType, ZitadelApplication,
|
MachineKeyType, MintedDeviceCredentials, ZitadelApiApp, ZitadelAppType, ZitadelApplication,
|
||||||
ZitadelClientConfig, ZitadelClientIdExportScore, ZitadelCredentialsExportScore,
|
ZitadelClientConfig, ZitadelClientIdExportScore, ZitadelContractSetupScore,
|
||||||
ZitadelHumanUser, ZitadelMachineUser, ZitadelMember, ZitadelRole, ZitadelScheme,
|
ZitadelCredentialsExportScore, ZitadelHumanUser, ZitadelMachineUser, ZitadelMember,
|
||||||
ZitadelSetupScore, mint_device_credentials,
|
ZitadelRole, ZitadelScheme, ZitadelSetupScore, mint_device_credentials,
|
||||||
};
|
};
|
||||||
|
|
||||||
use harmony_k8s::KubernetesDistribution;
|
use harmony_k8s::KubernetesDistribution;
|
||||||
@@ -20,8 +32,7 @@ use std::collections::BTreeMap;
|
|||||||
use std::str::FromStr;
|
use std::str::FromStr;
|
||||||
|
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use harmony_config::Config;
|
use harmony_config::{Config, ConfigError, StateClient};
|
||||||
use harmony_macros::hurl;
|
|
||||||
use harmony_types::id::Id;
|
use harmony_types::id::Id;
|
||||||
use log::{debug, error, info, trace, warn};
|
use log::{debug, error, info, trace, warn};
|
||||||
use non_blank_string_rs::NonBlankString;
|
use non_blank_string_rs::NonBlankString;
|
||||||
@@ -32,7 +43,7 @@ use crate::{
|
|||||||
data::Version,
|
data::Version,
|
||||||
interpret::{Interpret, InterpretError, InterpretName, InterpretStatus, Outcome},
|
interpret::{Interpret, InterpretError, InterpretName, InterpretStatus, Outcome},
|
||||||
inventory::Inventory,
|
inventory::Inventory,
|
||||||
modules::helm::chart::{HelmChartScore, HelmRepository},
|
modules::helm::chart::HelmChartScore,
|
||||||
modules::{k8s::resource::K8sResourceScore, postgresql::capability::PostgreSQLRootAccountRef},
|
modules::{k8s::resource::K8sResourceScore, postgresql::capability::PostgreSQLRootAccountRef},
|
||||||
score::Score,
|
score::Score,
|
||||||
topology::{HelmCommand, K8sclient, Topology},
|
topology::{HelmCommand, K8sclient, Topology},
|
||||||
@@ -251,6 +262,35 @@ async fn read_namespace_scc_uid_start(
|
|||||||
.map_err(|e| format!("parsing uid-range start '{start}': {e}"))
|
.map_err(|e| format!("parsing uid-range start '{start}': {e}"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn read_masterkey(
|
||||||
|
k8s: &harmony_k8s::K8sClient,
|
||||||
|
namespace: &str,
|
||||||
|
) -> Result<Option<String>, InterpretError> {
|
||||||
|
let secret = k8s
|
||||||
|
.get_resource::<K8sSecret>(MASTERKEY_SECRET_NAME, Some(namespace))
|
||||||
|
.await
|
||||||
|
.map_err(|error| {
|
||||||
|
InterpretError::new(format!("Failed to read Zitadel masterkey Secret: {error}"))
|
||||||
|
})?;
|
||||||
|
match secret {
|
||||||
|
Some(secret) => {
|
||||||
|
let bytes = secret
|
||||||
|
.data
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|data| data.get("masterkey"))
|
||||||
|
.ok_or_else(|| {
|
||||||
|
InterpretError::new(
|
||||||
|
"Existing Zitadel masterkey Secret has no masterkey".to_string(),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
Ok(Some(String::from_utf8(bytes.0.clone()).map_err(
|
||||||
|
|error| InterpretError::new(format!("Zitadel masterkey is not UTF-8: {error}")),
|
||||||
|
)?))
|
||||||
|
}
|
||||||
|
None => Ok(None),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl Default for ZitadelScore {
|
impl Default for ZitadelScore {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self {
|
Self {
|
||||||
@@ -308,7 +348,13 @@ impl<T: Topology + K8sclient + HelmCommand> Score<T> for ZitadelScore {
|
|||||||
|
|
||||||
#[doc(hidden)]
|
#[doc(hidden)]
|
||||||
fn create_interpret(&self) -> Box<dyn Interpret<T>> {
|
fn create_interpret(&self) -> Box<dyn Interpret<T>> {
|
||||||
Box::new(ZitadelInterpret {
|
Box::new(self.interpret(None))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ZitadelScore {
|
||||||
|
fn interpret(&self, state_client: Option<StateClient>) -> ZitadelInterpret {
|
||||||
|
ZitadelInterpret {
|
||||||
host: self.host.clone(),
|
host: self.host.clone(),
|
||||||
zitadel_version: self.zitadel_version.clone(),
|
zitadel_version: self.zitadel_version.clone(),
|
||||||
external_secure: self.external_secure,
|
external_secure: self.external_secure,
|
||||||
@@ -318,7 +364,34 @@ impl<T: Topology + K8sclient + HelmCommand> Score<T> for ZitadelScore {
|
|||||||
password_change_required: self.password_change_required,
|
password_change_required: self.password_change_required,
|
||||||
database: self.database.clone(),
|
database: self.database.clone(),
|
||||||
node_port: self.node_port,
|
node_port: self.node_port,
|
||||||
})
|
state_client,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub struct ConfiguredZitadelScore {
|
||||||
|
score: ZitadelScore,
|
||||||
|
#[serde(skip)]
|
||||||
|
state_client: StateClient,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ZitadelScore {
|
||||||
|
pub fn with_state_client(self, state_client: StateClient) -> ConfiguredZitadelScore {
|
||||||
|
ConfiguredZitadelScore {
|
||||||
|
score: self,
|
||||||
|
state_client,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T: Topology + K8sclient + HelmCommand> Score<T> for ConfiguredZitadelScore {
|
||||||
|
fn name(&self) -> String {
|
||||||
|
"ZitadelScore".to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn create_interpret(&self) -> Box<dyn Interpret<T>> {
|
||||||
|
Box::new(self.score.interpret(Some(self.state_client.clone())))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -335,6 +408,23 @@ struct ZitadelInterpret {
|
|||||||
password_change_required: bool,
|
password_change_required: bool,
|
||||||
database: Option<PostgreSQLRootAccountRef>,
|
database: Option<PostgreSQLRootAccountRef>,
|
||||||
node_port: Option<u16>,
|
node_port: Option<u16>,
|
||||||
|
state_client: Option<StateClient>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ZitadelInterpret {
|
||||||
|
async fn get_state<T: Config>(&self) -> Result<T, ConfigError> {
|
||||||
|
match &self.state_client {
|
||||||
|
Some(client) => client.get().await,
|
||||||
|
None => harmony_config::get().await,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn set_state<T: Config>(&self, value: &T) -> Result<(), ConfigError> {
|
||||||
|
match &self.state_client {
|
||||||
|
Some(client) => client.set(value).await,
|
||||||
|
None => harmony_config::set(value).await,
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
@@ -437,19 +527,24 @@ impl<T: Topology + K8sclient + HelmCommand> Interpret<T> for ZitadelInterpret {
|
|||||||
// emit a fresh random — misleading the operator). harmony_config
|
// emit a fresh random — misleading the operator). harmony_config
|
||||||
// namespaces by install context (config-resolved), so two
|
// namespaces by install context (config-resolved), so two
|
||||||
// installs in the same context share credentials.
|
// installs in the same context share credentials.
|
||||||
let admin = match harmony_config::get::<ZitadelAdmin>().await {
|
let admin = match self.get_state::<ZitadelAdmin>().await {
|
||||||
Ok(a) => a,
|
Ok(a) => a,
|
||||||
Err(e) => {
|
Err(ConfigError::NotFound { .. }) => {
|
||||||
debug!("[Zitadel] No persisted admin credentials yet ({e}); generating");
|
debug!("[Zitadel] No persisted admin credentials yet; generating");
|
||||||
let a = ZitadelAdmin {
|
let a = ZitadelAdmin {
|
||||||
username: "admin".to_string(),
|
username: "admin".to_string(),
|
||||||
password: generate_secure_password(16),
|
password: generate_secure_password(16),
|
||||||
};
|
};
|
||||||
harmony_config::set(&a).await.map_err(|err| {
|
self.set_state(&a).await.map_err(|err| {
|
||||||
InterpretError::new(format!("Failed to persist Zitadel admin password: {err}"))
|
InterpretError::new(format!("Failed to persist Zitadel admin password: {err}"))
|
||||||
})?;
|
})?;
|
||||||
a
|
a
|
||||||
}
|
}
|
||||||
|
Err(error) => {
|
||||||
|
return Err(InterpretError::new(format!(
|
||||||
|
"Failed to load Zitadel admin password: {error}"
|
||||||
|
)));
|
||||||
|
}
|
||||||
};
|
};
|
||||||
let admin_username = admin.username.clone();
|
let admin_username = admin.username.clone();
|
||||||
let admin_password = admin.password.clone();
|
let admin_password = admin.password.clone();
|
||||||
@@ -489,38 +584,27 @@ impl<T: Topology + K8sclient + HelmCommand> Interpret<T> for ZitadelInterpret {
|
|||||||
// 3. a freshly generated one.
|
// 3. a freshly generated one.
|
||||||
// Then mirror the resolved value into harmony_config so a deleted/
|
// Then mirror the resolved value into harmony_config so a deleted/
|
||||||
// recreated namespace reuses it rather than minting a new (broken) key.
|
// recreated namespace reuses it rather than minting a new (broken) key.
|
||||||
let existing_masterkey = k8s_client
|
let existing_masterkey = read_masterkey(&k8s_client, &self.namespace).await?;
|
||||||
.get_resource::<K8sSecret>(MASTERKEY_SECRET_NAME, Some(&self.namespace))
|
|
||||||
.await
|
|
||||||
.ok()
|
|
||||||
.flatten()
|
|
||||||
.and_then(|s| s.data?.get("masterkey").cloned())
|
|
||||||
.and_then(|bs| String::from_utf8(bs.0).ok());
|
|
||||||
|
|
||||||
let masterkey = match existing_masterkey {
|
let persisted_masterkey = match self.get_state::<ZitadelMasterkey>().await {
|
||||||
Some(k) => k,
|
Ok(value) => Some(value.masterkey),
|
||||||
None => match harmony_config::get::<ZitadelMasterkey>().await {
|
Err(ConfigError::NotFound { .. }) => None,
|
||||||
Ok(m) => m.masterkey,
|
Err(error) => {
|
||||||
Err(e) => {
|
return Err(InterpretError::new(format!(
|
||||||
debug!("[Zitadel] No persisted masterkey yet ({e}); generating");
|
"Failed to load Zitadel masterkey: {error}"
|
||||||
rng()
|
)));
|
||||||
.sample_iter(&rand::distr::Alphanumeric)
|
}
|
||||||
.take(32)
|
|
||||||
.map(char::from)
|
|
||||||
.collect::<String>()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
let masterkey = existing_masterkey
|
||||||
if harmony_config::get::<ZitadelMasterkey>().await.is_err() {
|
.or_else(|| persisted_masterkey.clone())
|
||||||
harmony_config::set(&ZitadelMasterkey {
|
.unwrap_or_else(|| {
|
||||||
masterkey: masterkey.clone(),
|
debug!("[Zitadel] No persisted masterkey yet; generating");
|
||||||
})
|
rng()
|
||||||
.await
|
.sample_iter(&rand::distr::Alphanumeric)
|
||||||
.map_err(|e| {
|
.take(32)
|
||||||
InterpretError::new(format!("Failed to persist Zitadel masterkey: {e}"))
|
.map(char::from)
|
||||||
})?;
|
.collect::<String>()
|
||||||
}
|
});
|
||||||
|
|
||||||
debug!(
|
debug!(
|
||||||
"[Zitadel] Created masterkey secret '{}' in namespace '{}'",
|
"[Zitadel] Created masterkey secret '{}' in namespace '{}'",
|
||||||
@@ -528,7 +612,10 @@ impl<T: Topology + K8sclient + HelmCommand> Interpret<T> for ZitadelInterpret {
|
|||||||
);
|
);
|
||||||
|
|
||||||
let mut masterkey_data: BTreeMap<String, ByteString> = BTreeMap::new();
|
let mut masterkey_data: BTreeMap<String, ByteString> = BTreeMap::new();
|
||||||
masterkey_data.insert("masterkey".to_string(), ByteString(masterkey.into()));
|
masterkey_data.insert(
|
||||||
|
"masterkey".to_string(),
|
||||||
|
ByteString(masterkey.clone().into()),
|
||||||
|
);
|
||||||
|
|
||||||
let masterkey_secret = K8sSecret {
|
let masterkey_secret = K8sSecret {
|
||||||
metadata: ObjectMeta {
|
metadata: ObjectMeta {
|
||||||
@@ -540,7 +627,7 @@ impl<T: Topology + K8sclient + HelmCommand> Interpret<T> for ZitadelInterpret {
|
|||||||
..K8sSecret::default()
|
..K8sSecret::default()
|
||||||
};
|
};
|
||||||
|
|
||||||
match k8s_client
|
let authoritative_masterkey = match k8s_client
|
||||||
.create(&masterkey_secret, Some(&self.namespace))
|
.create(&masterkey_secret, Some(&self.namespace))
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
@@ -549,12 +636,21 @@ impl<T: Topology + K8sclient + HelmCommand> Interpret<T> for ZitadelInterpret {
|
|||||||
"[Zitadel] Masterkey secret '{}' created",
|
"[Zitadel] Masterkey secret '{}' created",
|
||||||
MASTERKEY_SECRET_NAME
|
MASTERKEY_SECRET_NAME
|
||||||
);
|
);
|
||||||
|
masterkey
|
||||||
}
|
}
|
||||||
Err(KubeError::Api(ErrorResponse { code: 409, .. })) => {
|
Err(KubeError::Api(ErrorResponse { code: 409, .. })) => {
|
||||||
info!(
|
info!(
|
||||||
"[Zitadel] Masterkey secret '{}' already exists, leaving it untouched",
|
"[Zitadel] Masterkey secret '{}' already exists, leaving it untouched",
|
||||||
MASTERKEY_SECRET_NAME
|
MASTERKEY_SECRET_NAME
|
||||||
);
|
);
|
||||||
|
read_masterkey(&k8s_client, &self.namespace)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| {
|
||||||
|
InterpretError::new(
|
||||||
|
"Zitadel masterkey Secret disappeared after create conflict"
|
||||||
|
.to_string(),
|
||||||
|
)
|
||||||
|
})?
|
||||||
}
|
}
|
||||||
Err(other) => {
|
Err(other) => {
|
||||||
let msg = format!(
|
let msg = format!(
|
||||||
@@ -566,6 +662,16 @@ impl<T: Topology + K8sclient + HelmCommand> Interpret<T> for ZitadelInterpret {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if persisted_masterkey.as_deref() != Some(authoritative_masterkey.as_str()) {
|
||||||
|
self.set_state(&ZitadelMasterkey {
|
||||||
|
masterkey: authoritative_masterkey,
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|error| {
|
||||||
|
InterpretError::new(format!("Failed to persist Zitadel masterkey: {error}"))
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
|
||||||
debug!(
|
debug!(
|
||||||
"[Zitadel] Masterkey secret '{}' created successfully",
|
"[Zitadel] Masterkey secret '{}' created successfully",
|
||||||
MASTERKEY_SECRET_NAME
|
MASTERKEY_SECRET_NAME
|
||||||
@@ -976,15 +1082,17 @@ login:
|
|||||||
|
|
||||||
// --- Step 6: Deploy Helm chart ------------------------------------
|
// --- Step 6: Deploy Helm chart ------------------------------------
|
||||||
|
|
||||||
|
let chart_name =
|
||||||
|
NonBlankString::from_str("oci://hub.nationtech.io/harmony/zitadel").unwrap();
|
||||||
info!(
|
info!(
|
||||||
"[Zitadel] Deploying Helm chart 'zitadel/zitadel' as release 'zitadel' in namespace '{}'",
|
"[Zitadel] Deploying Helm chart '{chart_name}' as release 'zitadel' in namespace '{}'",
|
||||||
self.namespace
|
self.namespace
|
||||||
);
|
);
|
||||||
|
|
||||||
let result = HelmChartScore {
|
let result = HelmChartScore {
|
||||||
namespace: Some(NonBlankString::from_str(&self.namespace).unwrap()),
|
namespace: Some(NonBlankString::from_str(&self.namespace).unwrap()),
|
||||||
release_name: NonBlankString::from_str("zitadel").unwrap(),
|
release_name: NonBlankString::from_str("zitadel").unwrap(),
|
||||||
chart_name: NonBlankString::from_str("zitadel/zitadel").unwrap(),
|
chart_name,
|
||||||
// Pinned: newer charts ship a login UI that expects a newer
|
// Pinned: newer charts ship a login UI that expects a newer
|
||||||
// server; with v4.12.x the login pod crashloops on
|
// server; with v4.12.x the login pod crashloops on
|
||||||
// Token.Invalid. 9.27.1 is the last chart matching this
|
// Token.Invalid. 9.27.1 is the last chart matching this
|
||||||
@@ -1004,11 +1112,7 @@ login:
|
|||||||
// explicit action.)
|
// explicit action.)
|
||||||
install_only: true,
|
install_only: true,
|
||||||
force_conflicts: false,
|
force_conflicts: false,
|
||||||
repository: Some(HelmRepository::new(
|
repository: None,
|
||||||
"zitadel".to_string(),
|
|
||||||
hurl!("https://charts.zitadel.com"),
|
|
||||||
true,
|
|
||||||
)),
|
|
||||||
}
|
}
|
||||||
.interpret(inventory, topology)
|
.interpret(inventory, topology)
|
||||||
.await;
|
.await;
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -6,6 +6,7 @@ readme.workspace = true
|
|||||||
license.workspace = true
|
license.workspace = true
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
anyhow.workspace = true
|
||||||
harmony = { path = "../harmony" }
|
harmony = { path = "../harmony" }
|
||||||
harmony-k8s = { path = "../harmony-k8s" }
|
harmony-k8s = { path = "../harmony-k8s" }
|
||||||
harmony_config = { path = "../harmony_config" }
|
harmony_config = { path = "../harmony_config" }
|
||||||
|
|||||||
@@ -137,7 +137,31 @@ pub async fn deploy_with_options<T: Topology + Send + Sync + 'static>(
|
|||||||
app.validate_deploy_images(&options.images)?;
|
app.validate_deploy_images(&options.images)?;
|
||||||
let images = options.images.clone();
|
let images = options.images.clone();
|
||||||
let scores = app.scores_with_options(ctx, options).await?;
|
let scores = app.scores_with_options(ctx, options).await?;
|
||||||
let to_run: Vec<Box<dyn Score<T>>> = scores.iter().map(|s| s.clone_box()).collect();
|
let steps = interpret_scores(topology, scores).await?;
|
||||||
|
|
||||||
|
Ok(DeployReport {
|
||||||
|
context: ctx.name().to_string(),
|
||||||
|
namespace: ctx.namespace().to_string(),
|
||||||
|
cluster: ctx.cluster_target().map(str::to_string),
|
||||||
|
tag: ctx.version().to_string(),
|
||||||
|
images,
|
||||||
|
steps,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn interpret_scores<T: Topology + Send + Sync + 'static>(
|
||||||
|
topology: T,
|
||||||
|
scores: Vec<Box<dyn Score<T>>>,
|
||||||
|
) -> Result<Vec<StepOutcome>, AppError> {
|
||||||
|
interpret_scores_with_progress(topology, scores, |_, _| Ok(())).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn interpret_scores_with_progress<T: Topology + Send + Sync + 'static>(
|
||||||
|
topology: T,
|
||||||
|
scores: Vec<Box<dyn Score<T>>>,
|
||||||
|
mut completed: impl FnMut(&StepOutcome, bool) -> Result<(), AppError>,
|
||||||
|
) -> Result<Vec<StepOutcome>, AppError> {
|
||||||
|
let to_run: Vec<Box<dyn Score<T>>> = scores.iter().map(|score| score.clone_box()).collect();
|
||||||
|
|
||||||
let mut maestro = Maestro::new_without_initialization(Inventory::autoload(), topology);
|
let mut maestro = Maestro::new_without_initialization(Inventory::autoload(), topology);
|
||||||
maestro.register_all(scores);
|
maestro.register_all(scores);
|
||||||
@@ -147,7 +171,8 @@ pub async fn deploy_with_options<T: Topology + Send + Sync + 'static>(
|
|||||||
.map_err(|e| AppError::Deploy(format!("topology preparation failed: {e}")))?;
|
.map_err(|e| AppError::Deploy(format!("topology preparation failed: {e}")))?;
|
||||||
|
|
||||||
let mut steps = Vec::new();
|
let mut steps = Vec::new();
|
||||||
for score in to_run {
|
let score_count = to_run.len();
|
||||||
|
for (index, score) in to_run.into_iter().enumerate() {
|
||||||
let name = score.name();
|
let name = score.name();
|
||||||
let outcome = maestro
|
let outcome = maestro
|
||||||
.interpret(score)
|
.interpret(score)
|
||||||
@@ -162,19 +187,14 @@ pub async fn deploy_with_options<T: Topology + Send + Sync + 'static>(
|
|||||||
outcome.status, outcome.message
|
outcome.status, outcome.message
|
||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
steps.push(StepOutcome {
|
let step = StepOutcome {
|
||||||
name,
|
name,
|
||||||
message: outcome.message,
|
message: outcome.message,
|
||||||
});
|
};
|
||||||
|
completed(&step, index + 1 < score_count)?;
|
||||||
|
steps.push(step);
|
||||||
}
|
}
|
||||||
Ok(DeployReport {
|
Ok(steps)
|
||||||
context: ctx.name().to_string(),
|
|
||||||
namespace: ctx.namespace().to_string(),
|
|
||||||
cluster: ctx.cluster_target().map(str::to_string),
|
|
||||||
tag: ctx.version().to_string(),
|
|
||||||
images,
|
|
||||||
steps,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Build + publish, then deploy (ADR-026 §4).
|
/// Build + publish, then deploy (ADR-026 §4).
|
||||||
|
|||||||
1742
harmony_app/src/application/k8s_anywhere.rs
Normal file
1742
harmony_app/src/application/k8s_anywhere.rs
Normal file
File diff suppressed because it is too large
Load Diff
20
harmony_app/src/application/mod.rs
Normal file
20
harmony_app/src/application/mod.rs
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
//! 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;
|
||||||
562
harmony_app/src/application/model.rs
Normal file
562
harmony_app/src/application/model.rs
Normal file
@@ -0,0 +1,562 @@
|
|||||||
|
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),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
266
harmony_app/src/application/resources.rs
Normal file
266
harmony_app/src/application/resources.rs
Normal file
@@ -0,0 +1,266 @@
|
|||||||
|
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,
|
||||||
|
}
|
||||||
597
harmony_app/src/application/validation.rs
Normal file
597
harmony_app/src/application/validation.rs
Normal file
@@ -0,0 +1,597 @@
|
|||||||
|
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(),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,7 +8,7 @@ use std::sync::Arc;
|
|||||||
|
|
||||||
use harmony::modules::tenant::ClusterAccess;
|
use harmony::modules::tenant::ClusterAccess;
|
||||||
use harmony::topology::{K8sAnywhereConfig, K8sAnywhereTopology};
|
use harmony::topology::{K8sAnywhereConfig, K8sAnywhereTopology};
|
||||||
use harmony_config::{ConfigClient, ConfigSource, LocalFileSource, PromptSource};
|
use harmony_config::{ConfigClient, ConfigSource, LocalFileSource, StateClient};
|
||||||
use harmony_k8s::K8sClient;
|
use harmony_k8s::K8sClient;
|
||||||
use harmony_types::context::{
|
use harmony_types::context::{
|
||||||
ContextName, DomainName, HttpUrl, OciRegistry, OciRepository, OidcAudience, OpenBaoNamespace,
|
ContextName, DomainName, HttpUrl, OciRegistry, OciRepository, OidcAudience, OpenBaoNamespace,
|
||||||
@@ -47,6 +47,9 @@ pub struct RemoteContext {
|
|||||||
pub repository: OciRepository,
|
pub repository: OciRepository,
|
||||||
pub domain: DomainName,
|
pub domain: DomainName,
|
||||||
pub image_pull_secret: Option<K8sName>,
|
pub image_pull_secret: Option<K8sName>,
|
||||||
|
/// Public S3-compatible endpoint for app/browser clients (e.g. `https://s3.cb1.nationtech.io`).
|
||||||
|
/// When set, ManagedBucket credentials use this instead of the cluster-internal RGW DNS.
|
||||||
|
pub object_storage_endpoint: Option<HttpUrl>,
|
||||||
pub access: OpenBaoClusterAccess,
|
pub access: OpenBaoClusterAccess,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -129,6 +132,7 @@ pub struct AppContext {
|
|||||||
kubeconfig: Option<PathBuf>,
|
kubeconfig: Option<PathBuf>,
|
||||||
_kubeconfig_guard: Option<NamedTempFile>,
|
_kubeconfig_guard: Option<NamedTempFile>,
|
||||||
config_client: Arc<ConfigClient>,
|
config_client: Arc<ConfigClient>,
|
||||||
|
state_client: StateClient,
|
||||||
cluster_target: Option<String>,
|
cluster_target: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -182,13 +186,15 @@ impl AppContext {
|
|||||||
Profile::from(&context.spec)
|
Profile::from(&context.spec)
|
||||||
);
|
);
|
||||||
debug!("Context '{name}' definition: {:?}", context.spec);
|
debug!("Context '{name}' definition: {:?}", context.spec);
|
||||||
let config_sources = build_config_sources(&context.spec, local_config_dir.clone())
|
let (config_client, state_client) =
|
||||||
.await
|
build_config_clients(&context.spec, local_config_dir.clone())
|
||||||
.map_err(|e| {
|
.await
|
||||||
ContextError::Config(format!("building config sources for context '{name}': {e}"))
|
.map_err(|e| {
|
||||||
})?;
|
ContextError::Config(format!(
|
||||||
harmony_config::init(config_sources.clone()).await;
|
"building config sources for context '{name}': {e}"
|
||||||
let config_client = Arc::new(ConfigClient::new(config_sources));
|
))
|
||||||
|
})?;
|
||||||
|
harmony_config::init_client(config_client.clone()).await;
|
||||||
let (guard, cluster_target) = match &context.spec {
|
let (guard, cluster_target) = match &context.spec {
|
||||||
ContextSpec::Local(LocalContext::ManagedK3d) => {
|
ContextSpec::Local(LocalContext::ManagedK3d) => {
|
||||||
info!("Cluster access: autoprovision local k3d ('{AUTOPROVISION_CLUSTER}')");
|
info!("Cluster access: autoprovision local k3d ('{AUTOPROVISION_CLUSTER}')");
|
||||||
@@ -226,17 +232,19 @@ impl AppContext {
|
|||||||
context.namespace
|
context.namespace
|
||||||
);
|
);
|
||||||
|
|
||||||
Ok(Self::new(
|
let mut context = Self::new(
|
||||||
context,
|
context,
|
||||||
version.into(),
|
version.into(),
|
||||||
local_config_dir,
|
local_config_dir,
|
||||||
config_client,
|
config_client,
|
||||||
guard,
|
guard,
|
||||||
cluster_target,
|
cluster_target,
|
||||||
))
|
);
|
||||||
|
context.state_client = state_client;
|
||||||
|
Ok(context)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn new(
|
pub(crate) fn new(
|
||||||
context: &Context,
|
context: &Context,
|
||||||
version: String,
|
version: String,
|
||||||
local_config_dir: Option<PathBuf>,
|
local_config_dir: Option<PathBuf>,
|
||||||
@@ -244,6 +252,7 @@ impl AppContext {
|
|||||||
guard: Option<NamedTempFile>,
|
guard: Option<NamedTempFile>,
|
||||||
cluster_target: Option<String>,
|
cluster_target: Option<String>,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
|
let state_client = StateClient::new(config_client.clone(), config_client.clone());
|
||||||
Self {
|
Self {
|
||||||
context: context.clone(),
|
context: context.clone(),
|
||||||
version,
|
version,
|
||||||
@@ -251,6 +260,7 @@ impl AppContext {
|
|||||||
kubeconfig: guard.as_ref().map(|guard| guard.path().to_path_buf()),
|
kubeconfig: guard.as_ref().map(|guard| guard.path().to_path_buf()),
|
||||||
_kubeconfig_guard: guard,
|
_kubeconfig_guard: guard,
|
||||||
config_client,
|
config_client,
|
||||||
|
state_client,
|
||||||
cluster_target,
|
cluster_target,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -282,6 +292,14 @@ impl AppContext {
|
|||||||
ContextSpec::Remote(remote) => Some(remote.domain.as_ref()),
|
ContextSpec::Remote(remote) => Some(remote.domain.as_ref()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
pub fn object_storage_endpoint(&self) -> Option<&str> {
|
||||||
|
match &self.context.spec {
|
||||||
|
ContextSpec::Local(_) => None,
|
||||||
|
ContextSpec::Remote(remote) => {
|
||||||
|
remote.object_storage_endpoint.as_ref().map(|u| u.as_ref())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
pub fn service_host(&self, service: &str) -> String {
|
pub fn service_host(&self, service: &str) -> String {
|
||||||
match &self.context.spec {
|
match &self.context.spec {
|
||||||
ContextSpec::Local(_) => {
|
ContextSpec::Local(_) => {
|
||||||
@@ -311,6 +329,12 @@ impl AppContext {
|
|||||||
pub fn config_client(&self) -> &ConfigClient {
|
pub fn config_client(&self) -> &ConfigClient {
|
||||||
&self.config_client
|
&self.config_client
|
||||||
}
|
}
|
||||||
|
pub(crate) fn config_client_arc(&self) -> Arc<ConfigClient> {
|
||||||
|
self.config_client.clone()
|
||||||
|
}
|
||||||
|
pub(crate) fn state_client(&self, scope: &str, migrate_legacy: bool) -> StateClient {
|
||||||
|
self.state_client.scoped(scope, migrate_legacy)
|
||||||
|
}
|
||||||
pub fn k3d_cluster(&self) -> Option<&str> {
|
pub fn k3d_cluster(&self) -> Option<&str> {
|
||||||
match &self.context.spec {
|
match &self.context.spec {
|
||||||
ContextSpec::Local(LocalContext::ManagedK3d) => Some(AUTOPROVISION_CLUSTER),
|
ContextSpec::Local(LocalContext::ManagedK3d) => Some(AUTOPROVISION_CLUSTER),
|
||||||
@@ -417,18 +441,18 @@ fn kubeconfig_target(contents: &str) -> Result<String, ContextError> {
|
|||||||
Ok(format!("{cluster} via context {current} ({server})"))
|
Ok(format!("{cluster} via context {current} ({server})"))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn build_config_sources(
|
async fn build_config_clients(
|
||||||
spec: &ContextSpec,
|
spec: &ContextSpec,
|
||||||
local_config_dir: Option<PathBuf>,
|
local_config_dir: Option<PathBuf>,
|
||||||
) -> Result<Vec<Arc<dyn ConfigSource>>, ContextError> {
|
) -> Result<(Arc<ConfigClient>, StateClient), ContextError> {
|
||||||
let mut sources: Vec<Arc<dyn ConfigSource>> = Vec::new();
|
let source: Arc<dyn ConfigSource> = match spec {
|
||||||
|
|
||||||
match spec {
|
|
||||||
ContextSpec::Remote(remote) => {
|
ContextSpec::Remote(remote) => {
|
||||||
let access = &remote.access;
|
let access = &remote.access;
|
||||||
let source = harmony_config::openbao_source(
|
harmony_config::openbao_source(
|
||||||
access.namespace.as_ref(),
|
access.namespace.as_ref(),
|
||||||
Some(access.url.to_string()),
|
Some(access.url.to_string()),
|
||||||
|
None,
|
||||||
|
None,
|
||||||
Some(access.zitadel_url.to_string()),
|
Some(access.zitadel_url.to_string()),
|
||||||
Some(access.zitadel_audience.to_string()),
|
Some(access.zitadel_audience.to_string()),
|
||||||
Some(access.role.to_string()),
|
Some(access.role.to_string()),
|
||||||
@@ -439,8 +463,7 @@ async fn build_config_sources(
|
|||||||
"reaching OpenBao for namespace '{}'",
|
"reaching OpenBao for namespace '{}'",
|
||||||
access.namespace
|
access.namespace
|
||||||
))
|
))
|
||||||
})?;
|
})?
|
||||||
sources.push(source);
|
|
||||||
}
|
}
|
||||||
ContextSpec::Local(_) => {
|
ContextSpec::Local(_) => {
|
||||||
let dir = local_config_dir
|
let dir = local_config_dir
|
||||||
@@ -448,12 +471,10 @@ async fn build_config_sources(
|
|||||||
.ok_or_else(|| {
|
.ok_or_else(|| {
|
||||||
ContextError::Missing("local contexts need a config directory".to_string())
|
ContextError::Missing("local contexts need a config directory".to_string())
|
||||||
})?;
|
})?;
|
||||||
sources.push(Arc::new(LocalFileSource::new(dir)));
|
Arc::new(LocalFileSource::new(dir))
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
Ok(harmony_config::clients_for_source(source))
|
||||||
sources.push(Arc::new(PromptSource::new()));
|
|
||||||
Ok(sources)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn write_kubeconfig(contents: &[u8]) -> Result<NamedTempFile, ContextError> {
|
fn write_kubeconfig(contents: &[u8]) -> Result<NamedTempFile, ContextError> {
|
||||||
@@ -506,6 +527,7 @@ mod tests {
|
|||||||
repository: "team/apps".parse().unwrap(),
|
repository: "team/apps".parse().unwrap(),
|
||||||
domain: "example.com".parse().unwrap(),
|
domain: "example.com".parse().unwrap(),
|
||||||
image_pull_secret: Some("registry-auth".parse().unwrap()),
|
image_pull_secret: Some("registry-auth".parse().unwrap()),
|
||||||
|
object_storage_endpoint: None,
|
||||||
access: OpenBaoClusterAccess {
|
access: OpenBaoClusterAccess {
|
||||||
namespace: "team/prod".parse().unwrap(),
|
namespace: "team/prod".parse().unwrap(),
|
||||||
url: "https://bao.example.com".parse().unwrap(),
|
url: "https://bao.example.com".parse().unwrap(),
|
||||||
|
|||||||
@@ -13,8 +13,12 @@
|
|||||||
//! A [`Context`] defines a compiled deployment target. [`AppContext`] resolves
|
//! A [`Context`] defines a compiled deployment target. [`AppContext`] resolves
|
||||||
//! its credentials and runtime state. The verbs converge the same Scores for
|
//! its credentials and runtime state. The verbs converge the same Scores for
|
||||||
//! local and production targets (ADR-026 §1/§10).
|
//! local and production targets (ADR-026 §1/§10).
|
||||||
|
//!
|
||||||
|
//! [`Application`] is a topology-neutral declaration model. K8sAnywhere is its
|
||||||
|
//! first adapter; topology-neutral does not imply every runtime is supported.
|
||||||
|
|
||||||
pub mod app;
|
pub mod app;
|
||||||
|
pub mod application;
|
||||||
pub mod capabilities;
|
pub mod capabilities;
|
||||||
pub mod chart;
|
pub mod chart;
|
||||||
pub mod compose;
|
pub mod compose;
|
||||||
@@ -24,10 +28,19 @@ pub mod error;
|
|||||||
pub mod profile;
|
pub mod profile;
|
||||||
pub mod publish;
|
pub mod publish;
|
||||||
pub mod score;
|
pub mod score;
|
||||||
|
pub mod tenant;
|
||||||
|
|
||||||
pub use app::{
|
pub use app::{
|
||||||
AppIdentity, DeployOptions, DeployReport, HarmonyApp, PodLogs, StatusReport, StepOutcome,
|
AppIdentity, DeployOptions, DeployReport, HarmonyApp, PodLogs, StatusReport, StepOutcome,
|
||||||
WorkloadStatus, deploy, deploy_with_options, logs, ship, ship_with_options, status,
|
WorkloadStatus, deploy, deploy_with_options, interpret_scores, interpret_scores_with_progress,
|
||||||
|
logs, ship, ship_with_options, status,
|
||||||
|
};
|
||||||
|
pub use application::{
|
||||||
|
Application, ApplicationValidationError, BucketRef, Command, Cpu, DatabaseRef, FileRef,
|
||||||
|
HealthCheck, Image, ImageBuild, ImageRef, ImageSource, LogicalEndpoint, ManagedBucket,
|
||||||
|
ManagedPostgres, ManagedResource, ManagedTls, ManagedZitadel, Memory, OidcRedirect, Port,
|
||||||
|
PortRef, Protocol, PublicEndpointRef, ReadinessIntent, ResourceIntent, RolloutIntent,
|
||||||
|
RolloutStrategy, Route, Service, ServiceRef, ValueRef, ZitadelRef, zitadel,
|
||||||
};
|
};
|
||||||
pub use capabilities::{AppRef, Capability, Monitoring, Postgres, ZitadelAuth};
|
pub use capabilities::{AppRef, Capability, Monitoring, Postgres, ZitadelAuth};
|
||||||
pub use chart::{DeployConfig, SecretFileMount, cluster_issuer_for, service_image};
|
pub use chart::{DeployConfig, SecretFileMount, cluster_issuer_for, service_image};
|
||||||
@@ -39,9 +52,14 @@ pub use context::{
|
|||||||
pub use deploy::ComposeDeploy;
|
pub use deploy::ComposeDeploy;
|
||||||
pub use error::{AppError, ContextError, ImageError};
|
pub use error::{AppError, ContextError, ImageError};
|
||||||
pub use harmony::modules::tenant::ClusterAccess;
|
pub use harmony::modules::tenant::ClusterAccess;
|
||||||
|
pub use harmony::topology::tenant::{ResourceLimits, TenantConfig, TenantNetworkPolicy};
|
||||||
pub use profile::Profile;
|
pub use profile::Profile;
|
||||||
pub use publish::{
|
pub use publish::{
|
||||||
ImagePublisher, ImageRefs, ImageSpec, PublicationTopology, RegistryCredentials,
|
ImagePublisher, ImageRefs, ImageSpec, PublicationTopology, RegistryCredentials,
|
||||||
is_digest_pinned,
|
RegistryPullCredentials, is_digest_pinned,
|
||||||
};
|
};
|
||||||
pub use score::{ComposeAppScore, PublicEndpoint};
|
pub use score::{ComposeAppScore, PublicEndpoint};
|
||||||
|
pub use tenant::{
|
||||||
|
provision_application_tenant_on_context_with_progress,
|
||||||
|
provision_application_tenant_with_kubeconfig,
|
||||||
|
};
|
||||||
|
|||||||
@@ -21,6 +21,13 @@ pub struct RegistryCredentials {
|
|||||||
pub token: String,
|
pub token: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize, JsonSchema, Config)]
|
||||||
|
#[config(secret)]
|
||||||
|
pub struct RegistryPullCredentials {
|
||||||
|
pub username: String,
|
||||||
|
pub token: String,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct ImageSpec {
|
pub struct ImageSpec {
|
||||||
pub name: String,
|
pub name: String,
|
||||||
@@ -618,4 +625,9 @@ mod tests {
|
|||||||
assert_eq!(credentials.username, "publisher");
|
assert_eq!(credentials.username, "publisher");
|
||||||
assert_eq!(credentials.token, "secret");
|
assert_eq!(credentials.token, "secret");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn registry_pull_credentials_are_secret_config() {
|
||||||
|
assert_eq!(RegistryPullCredentials::CLASS, ConfigClass::Secret);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
237
harmony_app/src/tenant.rs
Normal file
237
harmony_app/src/tenant.rs
Normal file
@@ -0,0 +1,237 @@
|
|||||||
|
use std::{path::PathBuf, sync::Arc};
|
||||||
|
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use harmony::{
|
||||||
|
modules::tenant::{TenantCredentialScore, TenantScore},
|
||||||
|
score::Score,
|
||||||
|
topology::{K8sAnywhereConfig, K8sAnywhereTopology, tenant::TenantConfig},
|
||||||
|
};
|
||||||
|
use harmony_config::ConfigClient;
|
||||||
|
use harmony_types::k8s_name::K8sName;
|
||||||
|
use k8s_openapi::api::rbac::v1::{PolicyRule, Subject};
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
AppContext, AppError, AppIdentity, Context, ContextSpec, HarmonyApp, ImageRefs,
|
||||||
|
OpenBaoClusterAccess, StepOutcome, deploy, interpret_scores_with_progress,
|
||||||
|
};
|
||||||
|
|
||||||
|
struct ApplicationTenantProvisioner {
|
||||||
|
tenant: TenantConfig,
|
||||||
|
credential_store: OpenBaoClusterAccess,
|
||||||
|
allow_insecure_source: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn provision_application_tenant_on_context_with_progress(
|
||||||
|
kubeconfig: PathBuf,
|
||||||
|
kube_context: String,
|
||||||
|
tenant: TenantConfig,
|
||||||
|
credential_store: OpenBaoClusterAccess,
|
||||||
|
openbao_token: String,
|
||||||
|
owner_usernames: Vec<String>,
|
||||||
|
completed: impl FnMut(&StepOutcome, bool) -> Result<(), AppError>,
|
||||||
|
) -> Result<(), AppError> {
|
||||||
|
let user_subjects = owner_usernames
|
||||||
|
.into_iter()
|
||||||
|
.map(|name| Subject {
|
||||||
|
api_group: Some("rbac.authorization.k8s.io".to_string()),
|
||||||
|
kind: "User".to_string(),
|
||||||
|
name,
|
||||||
|
namespace: None,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
// This operator-driven path preserves the selected session's TLS mode; the CLI warns when verification is disabled.
|
||||||
|
let scores = application_tenant_scores(
|
||||||
|
tenant,
|
||||||
|
&credential_store,
|
||||||
|
Some(openbao_token),
|
||||||
|
true,
|
||||||
|
user_subjects,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
let topology = K8sAnywhereTopology::with_config(K8sAnywhereConfig::kubeconfig(
|
||||||
|
kubeconfig.to_string_lossy(),
|
||||||
|
Some(kube_context),
|
||||||
|
));
|
||||||
|
interpret_scores_with_progress(topology, scores, completed).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn application_tenant_scores(
|
||||||
|
tenant: TenantConfig,
|
||||||
|
credential_store: &OpenBaoClusterAccess,
|
||||||
|
openbao_token: Option<String>,
|
||||||
|
allow_insecure_source: bool,
|
||||||
|
role_subjects: Vec<Subject>,
|
||||||
|
) -> Result<Vec<Box<dyn Score<K8sAnywhereTopology>>>, AppError> {
|
||||||
|
let source = harmony_config::openbao_source(
|
||||||
|
credential_store.namespace.as_ref(),
|
||||||
|
Some(credential_store.url.to_string()),
|
||||||
|
openbao_token,
|
||||||
|
None,
|
||||||
|
Some(credential_store.zitadel_url.to_string()),
|
||||||
|
Some(credential_store.zitadel_audience.to_string()),
|
||||||
|
Some(credential_store.role.to_string()),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.ok_or_else(|| AppError::Deploy("tenant credential store is unavailable".into()))?;
|
||||||
|
let namespace = tenant
|
||||||
|
.name
|
||||||
|
.parse::<K8sName>()
|
||||||
|
.map_err(|error| AppError::InvalidComposition(error.to_string()))?;
|
||||||
|
Ok(vec![
|
||||||
|
Box::new(TenantScore { config: tenant }),
|
||||||
|
Box::new(
|
||||||
|
TenantCredentialScore::new(
|
||||||
|
namespace,
|
||||||
|
"harmony-deployer"
|
||||||
|
.parse()
|
||||||
|
.expect("static Kubernetes name is valid"),
|
||||||
|
application_deployer_rules(),
|
||||||
|
Arc::new(ConfigClient::new(vec![source])),
|
||||||
|
allow_insecure_source,
|
||||||
|
)
|
||||||
|
.with_role_subjects(role_subjects),
|
||||||
|
),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl HarmonyApp<K8sAnywhereTopology> for ApplicationTenantProvisioner {
|
||||||
|
fn identity(&self, _ctx: &AppContext) -> AppIdentity {
|
||||||
|
AppIdentity {
|
||||||
|
name: "application-tenant".to_string(),
|
||||||
|
namespace: self.tenant.name.clone(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn scores(
|
||||||
|
&self,
|
||||||
|
_ctx: &AppContext,
|
||||||
|
_images: &ImageRefs,
|
||||||
|
) -> Result<Vec<Box<dyn Score<K8sAnywhereTopology>>>, AppError> {
|
||||||
|
application_tenant_scores(
|
||||||
|
self.tenant.clone(),
|
||||||
|
&self.credential_store,
|
||||||
|
None,
|
||||||
|
self.allow_insecure_source,
|
||||||
|
Vec::new(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Provision an application tenant with an administrator kubeconfig, then
|
||||||
|
/// store its namespace-scoped kubeconfig as `ClusterAccess` in OpenBao.
|
||||||
|
pub async fn provision_application_tenant_with_kubeconfig(
|
||||||
|
context: Context,
|
||||||
|
kubeconfig: PathBuf,
|
||||||
|
tenant: TenantConfig,
|
||||||
|
credential_store: OpenBaoClusterAccess,
|
||||||
|
allow_insecure_source: bool,
|
||||||
|
) -> anyhow::Result<()> {
|
||||||
|
if !matches!(context.spec, ContextSpec::Remote(_)) {
|
||||||
|
anyhow::bail!("application tenant provisioning requires a remote context");
|
||||||
|
}
|
||||||
|
let app = ApplicationTenantProvisioner {
|
||||||
|
tenant,
|
||||||
|
credential_store,
|
||||||
|
allow_insecure_source,
|
||||||
|
};
|
||||||
|
let ctx = AppContext::from_kubeconfig(&context, "bootstrap", kubeconfig)?;
|
||||||
|
deploy(&app, ctx.topology(), &ctx).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn application_deployer_rules() -> Vec<PolicyRule> {
|
||||||
|
let verbs = || {
|
||||||
|
[
|
||||||
|
"get", "list", "watch", "create", "update", "patch", "delete",
|
||||||
|
]
|
||||||
|
.map(String::from)
|
||||||
|
.to_vec()
|
||||||
|
};
|
||||||
|
vec![
|
||||||
|
rule(
|
||||||
|
"",
|
||||||
|
&[
|
||||||
|
"configmaps",
|
||||||
|
"persistentvolumeclaims",
|
||||||
|
"pods",
|
||||||
|
"secrets",
|
||||||
|
"serviceaccounts",
|
||||||
|
"services",
|
||||||
|
],
|
||||||
|
verbs(),
|
||||||
|
),
|
||||||
|
rule("", &["pods/log"], vec!["get".to_string()]),
|
||||||
|
rule(
|
||||||
|
"",
|
||||||
|
&["pods/exec", "pods/portforward"],
|
||||||
|
vec!["create".to_string()],
|
||||||
|
),
|
||||||
|
rule(
|
||||||
|
"apps",
|
||||||
|
&["deployments", "replicasets", "statefulsets"],
|
||||||
|
verbs(),
|
||||||
|
),
|
||||||
|
rule("batch", &["jobs"], verbs()),
|
||||||
|
rule(
|
||||||
|
"networking.k8s.io",
|
||||||
|
&["ingresses", "networkpolicies"],
|
||||||
|
verbs(),
|
||||||
|
),
|
||||||
|
// TLS passthrough to CNPG (debug_route) needs Routes; plain Ingress is HTTP-only.
|
||||||
|
rule("route.openshift.io", &["routes"], verbs()),
|
||||||
|
rule("policy", &["poddisruptionbudgets"], verbs()),
|
||||||
|
rule(
|
||||||
|
"rbac.authorization.k8s.io",
|
||||||
|
&["roles", "rolebindings"],
|
||||||
|
verbs(),
|
||||||
|
),
|
||||||
|
rule("postgresql.cnpg.io", &["clusters"], verbs()),
|
||||||
|
rule(
|
||||||
|
"objectbucket.io",
|
||||||
|
&["objectbucketclaims", "objectbuckets"],
|
||||||
|
verbs(),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
fn rule(api_group: &str, resources: &[&str], verbs: Vec<String>) -> PolicyRule {
|
||||||
|
PolicyRule {
|
||||||
|
api_groups: Some(vec![api_group.to_string()]),
|
||||||
|
resources: Some(resources.iter().map(|value| (*value).to_string()).collect()),
|
||||||
|
verbs,
|
||||||
|
..Default::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn application_deployer_can_manage_cnpg_obc_and_routes_without_fleet_permissions() {
|
||||||
|
let rules = application_deployer_rules();
|
||||||
|
assert!(rules.iter().any(|rule| {
|
||||||
|
rule.api_groups.as_deref() == Some(&["postgresql.cnpg.io".to_string()])
|
||||||
|
&& rule.resources.as_deref() == Some(&["clusters".to_string()])
|
||||||
|
}));
|
||||||
|
assert!(rules.iter().any(|rule| {
|
||||||
|
rule.api_groups.as_deref() == Some(&["objectbucket.io".to_string()])
|
||||||
|
&& rule
|
||||||
|
.resources
|
||||||
|
.as_ref()
|
||||||
|
.is_some_and(|r| r.iter().any(|n| n == "objectbucketclaims"))
|
||||||
|
}));
|
||||||
|
assert!(rules.iter().any(|rule| {
|
||||||
|
rule.api_groups.as_deref() == Some(&["route.openshift.io".to_string()])
|
||||||
|
&& rule.resources.as_deref() == Some(&["routes".to_string()])
|
||||||
|
}));
|
||||||
|
assert!(!rules.iter().any(|rule| {
|
||||||
|
rule.api_groups
|
||||||
|
.as_ref()
|
||||||
|
.is_some_and(|groups| groups.iter().any(|group| group == "fleet.nationtech.io"))
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,6 +8,9 @@ license.workspace = true
|
|||||||
[dependencies]
|
[dependencies]
|
||||||
async-trait.workspace = true
|
async-trait.workspace = true
|
||||||
chrono = { workspace = true, features = ["serde"] }
|
chrono = { workspace = true, features = ["serde"] }
|
||||||
|
harmony_secret = { path = "../harmony_secret" }
|
||||||
|
harmony_types = { path = "../harmony_types" }
|
||||||
|
harmony_zitadel_auth = { path = "../harmony_zitadel_auth" }
|
||||||
reqwest.workspace = true
|
reqwest.workspace = true
|
||||||
serde.workspace = true
|
serde.workspace = true
|
||||||
serde_json.workspace = true
|
serde_json.workspace = true
|
||||||
|
|||||||
@@ -5,14 +5,19 @@ use std::{
|
|||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
Assignment, AssignmentPlan, AssignmentRequest, AuthError, AuthService, BackendConnection,
|
Assignment, AssignmentPlan, AssignmentRequest, AuthError, AuthService, BackendConnection,
|
||||||
ConnectionStatus, Identity, IdentityAccess, IdentityFilter, IdentityKind, IdentityWithAccess,
|
ConnectionStatus, DeployerCreateRequest, DeployerCreateResult, Identity, IdentityAccess,
|
||||||
ImportedAccess, JwtRole, OpenBaoPolicy, RemovalPlan, Scope, TenantSummary,
|
IdentityFilter, IdentityKind, IdentityWithAccess, ImportedAccess, JwtRole, OpenBaoPolicy,
|
||||||
|
ProvisionStep, RemovalPlan, Scope, TenantAuthConfig, TenantCapability, TenantCreateRequest,
|
||||||
|
TenantCreateResult, TenantDefinition, TenantSummary, valid_slug,
|
||||||
};
|
};
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use reqwest::{Client, Method, StatusCode};
|
use reqwest::{Client, Method, StatusCode};
|
||||||
use serde_json::{Map, Value, json};
|
use serde_json::{Map, Value, json};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use harmony_secret::OpenBaoPolicyManager;
|
||||||
|
use harmony_zitadel_auth::management::ManagementClient;
|
||||||
|
|
||||||
pub struct BackendAuth {
|
pub struct BackendAuth {
|
||||||
client: Client,
|
client: Client,
|
||||||
zitadel_url: String,
|
zitadel_url: String,
|
||||||
@@ -33,6 +38,12 @@ struct PolicyDetails {
|
|||||||
body: String,
|
body: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct ValidatedTenantAuth {
|
||||||
|
zitadel: ManagementClient,
|
||||||
|
project: harmony_zitadel_auth::management::Project,
|
||||||
|
openbao_jwt_role: String,
|
||||||
|
}
|
||||||
|
|
||||||
impl BackendAuth {
|
impl BackendAuth {
|
||||||
pub fn new(
|
pub fn new(
|
||||||
zitadel_url: String,
|
zitadel_url: String,
|
||||||
@@ -57,6 +68,426 @@ impl BackendAuth {
|
|||||||
&self.zitadel_url
|
&self.zitadel_url
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn tenant_definition(
|
||||||
|
&self,
|
||||||
|
slug: &str,
|
||||||
|
) -> Result<Option<TenantDefinition>, AuthError> {
|
||||||
|
let slug = Scope::new(slug, None)?.tenant;
|
||||||
|
self.json(&format!("harmony_auth/data/tenants/{slug}"))
|
||||||
|
.await?
|
||||||
|
.map(|data| serde_json::from_value(data["data"].clone()).map_err(backend))
|
||||||
|
.transpose()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn validate_tenant_auth(
|
||||||
|
&self,
|
||||||
|
config: &TenantAuthConfig,
|
||||||
|
completed: &mut impl FnMut(ProvisionStep) -> Result<(), AuthError>,
|
||||||
|
) -> Result<ValidatedTenantAuth, AuthError> {
|
||||||
|
if config.project.trim().is_empty() || config.groups_action.trim().is_empty() {
|
||||||
|
return Err(AuthError::Invalid(
|
||||||
|
"cloud identity project and groups Action cannot be empty".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if !valid_slug(&config.openbao_kv_mount) || !valid_slug(&config.openbao_jwt_mount) {
|
||||||
|
return Err(AuthError::Invalid(
|
||||||
|
"secret-store mount names may contain lowercase letters, numbers, and dashes"
|
||||||
|
.into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let zitadel = ManagementClient::new(
|
||||||
|
&self.zitadel_url,
|
||||||
|
&self.zitadel_pat,
|
||||||
|
config.zitadel_org_id.clone(),
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
.map_err(backend)?;
|
||||||
|
let project = zitadel.project(&config.project).await.map_err(backend)?;
|
||||||
|
completed(ProvisionStep::detail(format!(
|
||||||
|
"Found Zitadel project '{}' with Resource ID '{}'",
|
||||||
|
project.name, project.id
|
||||||
|
)))?;
|
||||||
|
if !zitadel
|
||||||
|
.action_in_token_flow(&config.groups_action)
|
||||||
|
.await
|
||||||
|
.map_err(backend)?
|
||||||
|
{
|
||||||
|
return Err(AuthError::Invalid(format!(
|
||||||
|
"Zitadel Action '{}' must be attached to the Complement Token flow at both Pre Userinfo creation and Pre access token creation",
|
||||||
|
config.groups_action
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
completed(ProvisionStep::detail(format!(
|
||||||
|
"Found Zitadel Action '{}' on Complement Token triggers Pre Userinfo creation and Pre access token creation",
|
||||||
|
config.groups_action
|
||||||
|
)))?;
|
||||||
|
let jwt_config = self
|
||||||
|
.json(&format!("auth/{}/config", config.openbao_jwt_mount))
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| AuthError::Invalid("cloud secret login is not configured".into()))?;
|
||||||
|
if jwt_config["bound_issuer"]
|
||||||
|
.as_str()
|
||||||
|
.map(|issuer| issuer.trim_end_matches('/'))
|
||||||
|
!= Some(self.zitadel_url.trim_end_matches('/'))
|
||||||
|
{
|
||||||
|
return Err(AuthError::Invalid(
|
||||||
|
"cloud identity issuer does not match the secret-store login configuration".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
completed(ProvisionStep::detail(format!(
|
||||||
|
"OpenBao JWT mount '{}' trusts Zitadel issuer '{}'",
|
||||||
|
config.openbao_jwt_mount, self.zitadel_url
|
||||||
|
)))?;
|
||||||
|
let role_names = match config.openbao_jwt_role.as_deref() {
|
||||||
|
Some(role) if role.trim().is_empty() => {
|
||||||
|
return Err(AuthError::Invalid(
|
||||||
|
"secret login role cannot be empty".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Some(role) => vec![role.to_string()],
|
||||||
|
None => self.role_names(&config.openbao_jwt_mount).await?,
|
||||||
|
};
|
||||||
|
let mut compatible = Vec::new();
|
||||||
|
for name in role_names {
|
||||||
|
let Some(role) = self
|
||||||
|
.json(&format!("auth/{}/role/{name}", config.openbao_jwt_mount))
|
||||||
|
.await?
|
||||||
|
else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let audiences = strings(&role["bound_audiences"]);
|
||||||
|
if role["groups_claim"].as_str() == Some("groups")
|
||||||
|
&& audiences.len() == 1
|
||||||
|
&& audiences.first() == Some(&project.id)
|
||||||
|
{
|
||||||
|
compatible.push(name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if compatible.is_empty() {
|
||||||
|
return Err(AuthError::Invalid(format!(
|
||||||
|
"no compatible OpenBao role found under auth/{}/role: expected groups_claim 'groups' and bound_audiences ['{}'] for Zitadel project '{}'",
|
||||||
|
config.openbao_jwt_mount, project.id, project.name
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
if compatible.len() > 1 {
|
||||||
|
return Err(AuthError::Invalid(format!(
|
||||||
|
"found {} compatible OpenBao roles under auth/{}/role; set OPENBAO_JWT_ROLE to select one",
|
||||||
|
compatible.len(),
|
||||||
|
config.openbao_jwt_mount
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
let openbao_jwt_role = compatible.pop().unwrap();
|
||||||
|
completed(ProvisionStep::detail(format!(
|
||||||
|
"Found OpenBao JWT role '{}' with Zitadel project audience '{}', groups_claim 'groups', and auth mount '{}'",
|
||||||
|
openbao_jwt_role, project.id, config.openbao_jwt_mount
|
||||||
|
)))?;
|
||||||
|
Ok(ValidatedTenantAuth {
|
||||||
|
zitadel,
|
||||||
|
project,
|
||||||
|
openbao_jwt_role,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn create_tenant(
|
||||||
|
&self,
|
||||||
|
request: TenantCreateRequest,
|
||||||
|
) -> Result<TenantCreateResult, AuthError> {
|
||||||
|
self.create_tenant_with_progress(request, |_| Ok(())).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn create_tenant_with_progress(
|
||||||
|
&self,
|
||||||
|
request: TenantCreateRequest,
|
||||||
|
mut completed: impl FnMut(ProvisionStep) -> Result<(), AuthError>,
|
||||||
|
) -> Result<TenantCreateResult, AuthError> {
|
||||||
|
request.tenant.validate()?;
|
||||||
|
let auth = self
|
||||||
|
.validate_tenant_auth(&request.auth, &mut completed)
|
||||||
|
.await?;
|
||||||
|
let zitadel = &auth.zitadel;
|
||||||
|
let project = &auth.project;
|
||||||
|
completed(ProvisionStep::checkpoint(
|
||||||
|
"Tenant identity and secret-login baseline validated",
|
||||||
|
format!(
|
||||||
|
"Ensure Zitadel roles '{}:owner', '{}:deployer', and '{}:viewer' in project '{}' ({})",
|
||||||
|
request.tenant.slug,
|
||||||
|
request.tenant.slug,
|
||||||
|
request.tenant.slug,
|
||||||
|
project.name,
|
||||||
|
project.id
|
||||||
|
),
|
||||||
|
))?;
|
||||||
|
|
||||||
|
for (index, capability) in TenantCapability::ALL.into_iter().enumerate() {
|
||||||
|
let key = capability.role(&request.tenant.slug);
|
||||||
|
zitadel
|
||||||
|
.ensure_project_role(
|
||||||
|
&project.id,
|
||||||
|
&key,
|
||||||
|
&format!("{} {}", request.tenant.slug, capability.name()),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(backend)?;
|
||||||
|
let message = format!(
|
||||||
|
"Zitadel role '{}' is ready in project '{}' ({})",
|
||||||
|
key, project.name, project.id
|
||||||
|
);
|
||||||
|
completed(if index + 1 == TenantCapability::ALL.len() {
|
||||||
|
let next = if request.tenant.owner_usernames.is_empty() {
|
||||||
|
format!(
|
||||||
|
"Create OpenBao owner and deployer policies for tenant '{}'",
|
||||||
|
request.tenant.slug
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
format!(
|
||||||
|
"Grant Zitadel role '{}:owner' to {}",
|
||||||
|
request.tenant.slug,
|
||||||
|
request.tenant.owner_usernames.join(", ")
|
||||||
|
)
|
||||||
|
};
|
||||||
|
ProvisionStep::checkpoint(message, next)
|
||||||
|
} else {
|
||||||
|
ProvisionStep::detail(message)
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
for (index, username) in request.tenant.owner_usernames.iter().enumerate() {
|
||||||
|
let user = zitadel
|
||||||
|
.find_human(username)
|
||||||
|
.await
|
||||||
|
.map_err(backend)?
|
||||||
|
.ok_or_else(|| {
|
||||||
|
AuthError::Invalid(format!("human identity '{username}' not found"))
|
||||||
|
})?;
|
||||||
|
zitadel
|
||||||
|
.ensure_project_role_grant(
|
||||||
|
&user.id,
|
||||||
|
&project.id,
|
||||||
|
&[TenantCapability::Owner.role(&request.tenant.slug)],
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(backend)?;
|
||||||
|
let message = format!(
|
||||||
|
"Granted Zitadel role '{}' to owner '{}' (user ID '{}')",
|
||||||
|
TenantCapability::Owner.role(&request.tenant.slug),
|
||||||
|
username,
|
||||||
|
user.id
|
||||||
|
);
|
||||||
|
completed(if index + 1 == request.tenant.owner_usernames.len() {
|
||||||
|
ProvisionStep::checkpoint(
|
||||||
|
message,
|
||||||
|
format!(
|
||||||
|
"Create OpenBao owner and deployer policies for tenant '{}'",
|
||||||
|
request.tenant.slug
|
||||||
|
),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
ProvisionStep::detail(message)
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
let policies = OpenBaoPolicyManager::new(
|
||||||
|
self.openbao_url.clone(),
|
||||||
|
self.openbao_token.clone(),
|
||||||
|
request.auth.openbao_jwt_mount.clone(),
|
||||||
|
);
|
||||||
|
for (index, capability) in [TenantCapability::Owner, TenantCapability::Deployer]
|
||||||
|
.into_iter()
|
||||||
|
.enumerate()
|
||||||
|
{
|
||||||
|
let policy = capability.policy_name(&request.tenant.slug);
|
||||||
|
let group = capability.role(&request.tenant.slug);
|
||||||
|
policies
|
||||||
|
.reconcile_acl_policy(
|
||||||
|
&policy,
|
||||||
|
Some(
|
||||||
|
&capability
|
||||||
|
.openbao_policy(&request.tenant.slug, &request.auth.openbao_kv_mount),
|
||||||
|
),
|
||||||
|
std::slice::from_ref(&group),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(backend)?;
|
||||||
|
let message = format!(
|
||||||
|
"OpenBao policy '{}' grants group '{}' access to '{}/data/{}/*' and '{}/metadata/{}/*'",
|
||||||
|
policy,
|
||||||
|
group,
|
||||||
|
request.auth.openbao_kv_mount,
|
||||||
|
request.tenant.slug,
|
||||||
|
request.auth.openbao_kv_mount,
|
||||||
|
request.tenant.slug
|
||||||
|
);
|
||||||
|
completed(if index == 1 {
|
||||||
|
ProvisionStep::checkpoint(
|
||||||
|
message,
|
||||||
|
format!(
|
||||||
|
"Store the tenant definition at OpenBao path 'harmony_auth/data/tenants/{}'",
|
||||||
|
request.tenant.slug
|
||||||
|
),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
ProvisionStep::detail(message)
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
|
||||||
|
self.ensure_intent_mount().await?;
|
||||||
|
completed(ProvisionStep::detail(
|
||||||
|
"OpenBao tenant-state mount 'harmony_auth/' is ready",
|
||||||
|
))?;
|
||||||
|
let changed =
|
||||||
|
self.tenant_definition(&request.tenant.slug).await?.as_ref() != Some(&request.tenant);
|
||||||
|
if changed {
|
||||||
|
self.openbao(
|
||||||
|
Method::POST,
|
||||||
|
&format!("harmony_auth/data/tenants/{}", request.tenant.slug),
|
||||||
|
Some(json!({ "data": request.tenant })),
|
||||||
|
)
|
||||||
|
.await?
|
||||||
|
.error_for_status()
|
||||||
|
.map_err(backend)?;
|
||||||
|
}
|
||||||
|
completed(ProvisionStep::detail(format!(
|
||||||
|
"Tenant definition {} at OpenBao path 'harmony_auth/data/tenants/{}'",
|
||||||
|
if changed { "stored" } else { "already matches" },
|
||||||
|
request.tenant.slug
|
||||||
|
)))?;
|
||||||
|
|
||||||
|
Ok(TenantCreateResult {
|
||||||
|
tenant: request.tenant,
|
||||||
|
project_id: project.id.clone(),
|
||||||
|
openbao_jwt_role: auth.openbao_jwt_role,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn prepare_deployer(
|
||||||
|
&self,
|
||||||
|
request: &DeployerCreateRequest,
|
||||||
|
completed: &mut impl FnMut(ProvisionStep),
|
||||||
|
) -> Result<(TenantDefinition, ValidatedTenantAuth), AuthError> {
|
||||||
|
let tenant = self
|
||||||
|
.tenant_definition(&request.tenant)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| {
|
||||||
|
AuthError::Invalid(format!("tenant '{}' does not exist", request.tenant))
|
||||||
|
})?;
|
||||||
|
completed(ProvisionStep::detail(format!(
|
||||||
|
"Found tenant '{}' with namespace '{}'",
|
||||||
|
tenant.slug, tenant.namespace
|
||||||
|
)));
|
||||||
|
if request.username.trim().is_empty() || request.display_name.trim().is_empty() {
|
||||||
|
return Err(AuthError::Invalid(
|
||||||
|
"deployer account and display name cannot be empty".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let auth = {
|
||||||
|
let mut report = |step| {
|
||||||
|
completed(step);
|
||||||
|
Ok(())
|
||||||
|
};
|
||||||
|
self.validate_tenant_auth(&request.auth, &mut report)
|
||||||
|
.await?
|
||||||
|
};
|
||||||
|
if auth
|
||||||
|
.zitadel
|
||||||
|
.find_machine(&request.username)
|
||||||
|
.await
|
||||||
|
.map_err(backend)?
|
||||||
|
.is_some()
|
||||||
|
{
|
||||||
|
return Err(AuthError::Invalid(format!(
|
||||||
|
"deployer account '{}' already exists; no access was changed",
|
||||||
|
request.username
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
completed(ProvisionStep::checkpoint(
|
||||||
|
format!(
|
||||||
|
"Zitadel deployer account name '{}' is available",
|
||||||
|
request.username
|
||||||
|
),
|
||||||
|
format!(
|
||||||
|
"Create Zitadel machine account '{}' and grant role '{}:deployer'",
|
||||||
|
request.username, tenant.slug
|
||||||
|
),
|
||||||
|
));
|
||||||
|
Ok((tenant, auth))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn plan_deployer(&self, request: &DeployerCreateRequest) -> Result<(), AuthError> {
|
||||||
|
self.prepare_deployer(request, &mut |_| {})
|
||||||
|
.await
|
||||||
|
.map(|_| ())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn create_deployer_with_progress(
|
||||||
|
&self,
|
||||||
|
request: DeployerCreateRequest,
|
||||||
|
mut completed: impl FnMut(ProvisionStep),
|
||||||
|
) -> Result<DeployerCreateResult, AuthError> {
|
||||||
|
let (tenant, auth) = self.prepare_deployer(&request, &mut completed).await?;
|
||||||
|
let user = auth
|
||||||
|
.zitadel
|
||||||
|
.create_machine(&request.username, &request.display_name)
|
||||||
|
.await
|
||||||
|
.map_err(backend)?;
|
||||||
|
completed(ProvisionStep::detail(format!(
|
||||||
|
"Created Zitadel machine account '{}' with user ID '{}'",
|
||||||
|
request.username, user.id
|
||||||
|
)));
|
||||||
|
let deployer_role = TenantCapability::Deployer.role(&tenant.slug);
|
||||||
|
if let Err(error) = auth
|
||||||
|
.zitadel
|
||||||
|
.ensure_project_role_grant(
|
||||||
|
&user.id,
|
||||||
|
&auth.project.id,
|
||||||
|
std::slice::from_ref(&deployer_role),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
if let Err(rollback) = auth.zitadel.delete_user(&user.id).await {
|
||||||
|
return Err(AuthError::Backend(format!(
|
||||||
|
"{error}; deleting the partially created deployer also failed: {rollback}"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
return Err(backend(error));
|
||||||
|
}
|
||||||
|
completed(ProvisionStep::detail(format!(
|
||||||
|
"Granted Zitadel role '{}' to machine account '{}' in project '{}' ({})",
|
||||||
|
deployer_role, request.username, auth.project.name, auth.project.id
|
||||||
|
)));
|
||||||
|
let key = match auth.zitadel.create_json_machine_key(&user.id).await {
|
||||||
|
Ok(key) => key,
|
||||||
|
Err(error) => {
|
||||||
|
if let Err(rollback) = auth.zitadel.delete_user(&user.id).await {
|
||||||
|
return Err(AuthError::Backend(format!(
|
||||||
|
"{error}; deleting the partially created deployer also failed: {rollback}"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
return Err(backend(error));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
completed(ProvisionStep::detail(format!(
|
||||||
|
"Generated one-time Zitadel credentials for machine account '{}'",
|
||||||
|
request.username
|
||||||
|
)));
|
||||||
|
Ok(DeployerCreateResult {
|
||||||
|
tenant: tenant.slug,
|
||||||
|
username: request.username,
|
||||||
|
user_id: user.id,
|
||||||
|
key_json: key.json,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn delete_machine_identity(
|
||||||
|
&self,
|
||||||
|
org_id: Option<String>,
|
||||||
|
user_id: &str,
|
||||||
|
) -> Result<(), AuthError> {
|
||||||
|
ManagementClient::new(&self.zitadel_url, &self.zitadel_pat, org_id, false)
|
||||||
|
.map_err(backend)?
|
||||||
|
.delete_user(user_id)
|
||||||
|
.await
|
||||||
|
.map_err(backend)
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn validate(&self) -> Result<(), String> {
|
pub async fn validate(&self) -> Result<(), String> {
|
||||||
let status = self.connection_status().await;
|
let status = self.connection_status().await;
|
||||||
match (status.zitadel.connected, status.openbao.connected) {
|
match (status.zitadel.connected, status.openbao.connected) {
|
||||||
@@ -151,16 +582,16 @@ impl BackendAuth {
|
|||||||
Ok(Some(body["data"].clone()))
|
Ok(Some(body["data"].clone()))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn role_names(&self) -> Result<Vec<String>, AuthError> {
|
async fn role_names(&self, mount: &str) -> Result<Vec<String>, AuthError> {
|
||||||
Ok(self
|
Ok(self
|
||||||
.json("auth/jwt/role?list=true")
|
.json(&format!("auth/{mount}/role?list=true"))
|
||||||
.await?
|
.await?
|
||||||
.map_or_else(Vec::new, |data| strings(&data["keys"])))
|
.map_or_else(Vec::new, |data| strings(&data["keys"])))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn roles(&self) -> Result<Vec<RoleRecord>, AuthError> {
|
async fn roles(&self) -> Result<Vec<RoleRecord>, AuthError> {
|
||||||
let mut roles = Vec::new();
|
let mut roles = Vec::new();
|
||||||
for name in self.role_names().await? {
|
for name in self.role_names("jwt").await? {
|
||||||
if let Some(raw) = self.json(&format!("auth/jwt/role/{name}")).await? {
|
if let Some(raw) = self.json(&format!("auth/jwt/role/{name}")).await? {
|
||||||
let subject_id = role_subject(&raw);
|
let subject_id = role_subject(&raw);
|
||||||
if !subject_id.is_empty() {
|
if !subject_id.is_empty() {
|
||||||
@@ -245,6 +676,15 @@ impl BackendAuth {
|
|||||||
.await?
|
.await?
|
||||||
.error_for_status()
|
.error_for_status()
|
||||||
.map_err(backend)?;
|
.map_err(backend)?;
|
||||||
|
} else {
|
||||||
|
let mount = &mounts["harmony_auth/"];
|
||||||
|
if mount["type"].as_str() != Some("kv")
|
||||||
|
|| mount["options"]["version"].as_str() != Some("2")
|
||||||
|
{
|
||||||
|
return Err(AuthError::Invalid(
|
||||||
|
"OpenBao mount 'harmony_auth/' must be KV v2".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use chrono::{DateTime, Utc};
|
use chrono::{DateTime, Utc};
|
||||||
|
use harmony_secret::render_tenant_policy;
|
||||||
|
use harmony_types::k8s_name::K8sName;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use sha2::{Digest, Sha256};
|
use sha2::{Digest, Sha256};
|
||||||
use thiserror::Error;
|
use thiserror::Error;
|
||||||
@@ -153,6 +155,222 @@ pub struct TenantSummary {
|
|||||||
pub services: usize,
|
pub services: usize,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum TenantCapability {
|
||||||
|
Owner,
|
||||||
|
Deployer,
|
||||||
|
Viewer,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
|
pub struct ProvisionStep {
|
||||||
|
message: String,
|
||||||
|
next_operation: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ProvisionStep {
|
||||||
|
pub(crate) fn detail(message: impl Into<String>) -> Self {
|
||||||
|
Self {
|
||||||
|
message: message.into(),
|
||||||
|
next_operation: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn checkpoint(
|
||||||
|
message: impl Into<String>,
|
||||||
|
next_operation: impl Into<String>,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
message: message.into(),
|
||||||
|
next_operation: Some(next_operation.into()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn message(&self) -> &str {
|
||||||
|
&self.message
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn next_operation(&self) -> Option<&str> {
|
||||||
|
self.next_operation.as_deref()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TenantCapability {
|
||||||
|
pub const ALL: [Self; 3] = [Self::Owner, Self::Deployer, Self::Viewer];
|
||||||
|
|
||||||
|
pub fn name(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::Owner => "owner",
|
||||||
|
Self::Deployer => "deployer",
|
||||||
|
Self::Viewer => "viewer",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn role(self, tenant: &str) -> String {
|
||||||
|
format!("{tenant}:{}", self.name())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn policy_name(self, tenant: &str) -> String {
|
||||||
|
format!("harmony-{tenant}-{}", self.name())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn openbao_policy(self, tenant: &str, mount: &str) -> String {
|
||||||
|
let capabilities = match self {
|
||||||
|
Self::Owner => &["create", "delete", "patch", "read", "update"][..],
|
||||||
|
Self::Deployer | Self::Viewer => &["read"][..],
|
||||||
|
};
|
||||||
|
render_tenant_policy(mount, tenant, capabilities, self == Self::Deployer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||||
|
pub struct TenantResources {
|
||||||
|
pub cpu_request_cores: f32,
|
||||||
|
pub cpu_limit_cores: f32,
|
||||||
|
pub memory_request_gb: f32,
|
||||||
|
pub memory_limit_gb: f32,
|
||||||
|
pub storage_total_gb: f32,
|
||||||
|
pub service_limit: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for TenantResources {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
cpu_request_cores: 4.0,
|
||||||
|
cpu_limit_cores: 4.0,
|
||||||
|
memory_request_gb: 4.0,
|
||||||
|
memory_limit_gb: 4.0,
|
||||||
|
storage_total_gb: 20.0,
|
||||||
|
service_limit: 10,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TenantResources {
|
||||||
|
pub fn validate(&self) -> Result<(), AuthError> {
|
||||||
|
let finite_positive = |value: f32| value.is_finite() && value > 0.0;
|
||||||
|
if !finite_positive(self.cpu_request_cores)
|
||||||
|
|| !finite_positive(self.cpu_limit_cores)
|
||||||
|
|| self.cpu_request_cores > self.cpu_limit_cores
|
||||||
|
{
|
||||||
|
return Err(AuthError::Invalid(
|
||||||
|
"CPU values must be finite and positive, with request <= limit".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if !finite_positive(self.memory_request_gb)
|
||||||
|
|| !finite_positive(self.memory_limit_gb)
|
||||||
|
|| self.memory_request_gb > self.memory_limit_gb
|
||||||
|
{
|
||||||
|
return Err(AuthError::Invalid(
|
||||||
|
"memory values must be finite and positive, with request <= limit".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if !finite_positive(self.storage_total_gb) || self.service_limit == 0 {
|
||||||
|
return Err(AuthError::Invalid(
|
||||||
|
"storage and service limits must be positive".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||||
|
pub struct TenantDefinition {
|
||||||
|
pub id: String,
|
||||||
|
pub slug: String,
|
||||||
|
pub namespace: String,
|
||||||
|
pub resources: TenantResources,
|
||||||
|
#[serde(default)]
|
||||||
|
pub owner_usernames: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TenantDefinition {
|
||||||
|
pub fn new(
|
||||||
|
id: impl Into<String>,
|
||||||
|
slug: &str,
|
||||||
|
namespace: impl Into<String>,
|
||||||
|
resources: TenantResources,
|
||||||
|
) -> Result<Self, AuthError> {
|
||||||
|
let slug = Scope::new(slug, None)?.tenant;
|
||||||
|
let id = id.into();
|
||||||
|
let namespace = namespace.into();
|
||||||
|
if id.trim().is_empty() || namespace.trim().is_empty() {
|
||||||
|
return Err(AuthError::Invalid(
|
||||||
|
"tenant ID and namespace cannot be empty".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
namespace
|
||||||
|
.parse::<K8sName>()
|
||||||
|
.map_err(|error| AuthError::Invalid(format!("invalid tenant namespace: {error}")))?;
|
||||||
|
resources.validate()?;
|
||||||
|
Ok(Self {
|
||||||
|
id,
|
||||||
|
slug,
|
||||||
|
namespace,
|
||||||
|
resources,
|
||||||
|
owner_usernames: Vec::new(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn with_owner_usernames(mut self, usernames: impl IntoIterator<Item = String>) -> Self {
|
||||||
|
self.owner_usernames.extend(usernames);
|
||||||
|
self.owner_usernames.sort();
|
||||||
|
self.owner_usernames.dedup();
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn validate(&self) -> Result<(), AuthError> {
|
||||||
|
if Scope::new(&self.slug, None)?.tenant != self.slug
|
||||||
|
|| self.id.trim().is_empty()
|
||||||
|
|| self.namespace.trim().is_empty()
|
||||||
|
{
|
||||||
|
return Err(AuthError::Invalid("invalid tenant definition".into()));
|
||||||
|
}
|
||||||
|
self.namespace
|
||||||
|
.parse::<K8sName>()
|
||||||
|
.map_err(|error| AuthError::Invalid(format!("invalid tenant namespace: {error}")))?;
|
||||||
|
self.resources.validate()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct TenantCreateRequest {
|
||||||
|
pub tenant: TenantDefinition,
|
||||||
|
pub auth: TenantAuthConfig,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct TenantAuthConfig {
|
||||||
|
pub project: String,
|
||||||
|
pub zitadel_org_id: Option<String>,
|
||||||
|
pub groups_action: String,
|
||||||
|
pub openbao_kv_mount: String,
|
||||||
|
pub openbao_jwt_mount: String,
|
||||||
|
pub openbao_jwt_role: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct TenantCreateResult {
|
||||||
|
pub tenant: TenantDefinition,
|
||||||
|
pub project_id: String,
|
||||||
|
pub openbao_jwt_role: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct DeployerCreateRequest {
|
||||||
|
pub tenant: String,
|
||||||
|
pub username: String,
|
||||||
|
pub display_name: String,
|
||||||
|
pub auth: TenantAuthConfig,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct DeployerCreateResult {
|
||||||
|
pub tenant: String,
|
||||||
|
pub username: String,
|
||||||
|
pub user_id: String,
|
||||||
|
pub key_json: String,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
|
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
|
||||||
pub struct IdentityWithAccess {
|
pub struct IdentityWithAccess {
|
||||||
pub identity: Identity,
|
pub identity: Identity,
|
||||||
@@ -383,4 +601,51 @@ mod tests {
|
|||||||
Err(AuthError::Invalid(_))
|
Err(AuthError::Invalid(_))
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tenant_capabilities_follow_adr_027() {
|
||||||
|
assert_eq!(TenantCapability::Owner.role("acme"), "acme:owner");
|
||||||
|
assert_eq!(
|
||||||
|
TenantCapability::Deployer.policy_name("acme"),
|
||||||
|
"harmony-acme-deployer"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
TenantCapability::Owner
|
||||||
|
.openbao_policy("acme", "secret")
|
||||||
|
.contains("\"update\"")
|
||||||
|
);
|
||||||
|
let deployer = TenantCapability::Deployer.openbao_policy("acme", "secret");
|
||||||
|
assert!(deployer.contains("path \"secret/data/acme/*\" { capabilities = [\"read\"] }"));
|
||||||
|
assert!(deployer.contains(
|
||||||
|
"path \"secret/data/acme/harmony-state/*\" { capabilities = [\"create\", \"read\", \"update\"] }"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn provision_steps_distinguish_details_from_checkpoints() {
|
||||||
|
let detail = ProvisionStep::detail("found project");
|
||||||
|
let checkpoint = ProvisionStep::checkpoint("baseline ready", "create tenant roles");
|
||||||
|
|
||||||
|
assert_eq!(detail.message(), "found project");
|
||||||
|
assert_eq!(detail.next_operation(), None);
|
||||||
|
assert_eq!(checkpoint.next_operation(), Some("create tenant roles"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tenant_definition_normalizes_slug_and_rejects_invalid_limits() {
|
||||||
|
let tenant = TenantDefinition::new(
|
||||||
|
"tenant-1",
|
||||||
|
" Acme ",
|
||||||
|
"acme-apps",
|
||||||
|
TenantResources::default(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(tenant.slug, "acme");
|
||||||
|
|
||||||
|
let invalid = TenantResources {
|
||||||
|
cpu_request_cores: f32::INFINITY,
|
||||||
|
..TenantResources::default()
|
||||||
|
};
|
||||||
|
assert!(TenantDefinition::new("tenant-1", "acme", "acme", invalid).is_err());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,18 @@ path = "src/main.rs"
|
|||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
harmony_auth = { path = "../harmony_auth" }
|
harmony_auth = { path = "../harmony_auth" }
|
||||||
|
harmony_app = { path = "../harmony_app" }
|
||||||
|
harmony-k8s = { path = "../harmony-k8s" }
|
||||||
|
harmony_config = { path = "../harmony_config" }
|
||||||
|
harmony_types = { path = "../harmony_types" }
|
||||||
clap.workspace = true
|
clap.workspace = true
|
||||||
|
inquire.workspace = true
|
||||||
|
schemars = "0.8"
|
||||||
|
serde.workspace = true
|
||||||
serde_json.workspace = true
|
serde_json.workspace = true
|
||||||
tokio.workspace = true
|
tokio.workspace = true
|
||||||
|
tracing.workspace = true
|
||||||
tracing-subscriber = { workspace = true, features = ["env-filter"] }
|
tracing-subscriber = { workspace = true, features = ["env-filter"] }
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
tempfile.workspace = true
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -130,6 +130,12 @@ pub struct ConfigClient {
|
|||||||
sources: Vec<Arc<dyn ConfigSource>>,
|
sources: Vec<Arc<dyn ConfigSource>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Debug for ConfigClient {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
f.debug_struct("ConfigClient").finish_non_exhaustive()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl ConfigClient {
|
impl ConfigClient {
|
||||||
pub fn new(sources: Vec<Arc<dyn ConfigSource>>) -> Self {
|
pub fn new(sources: Vec<Arc<dyn ConfigSource>>) -> Self {
|
||||||
Self { sources }
|
Self { sources }
|
||||||
@@ -151,6 +157,15 @@ impl ConfigClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub async fn get<T: Config>(&self) -> Result<T, ConfigError> {
|
pub async fn get<T: Config>(&self) -> Result<T, ConfigError> {
|
||||||
|
self.get_inner(false).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_strict<T: Config>(&self) -> Result<T, ConfigError> {
|
||||||
|
self.get_inner(true).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_inner<T: Config>(&self, preserve_invalid: bool) -> Result<T, ConfigError> {
|
||||||
|
let mut invalid = None;
|
||||||
for source in &self.sources {
|
for source in &self.sources {
|
||||||
if let Some(value) = source.get(T::CLASS, T::KEY).await? {
|
if let Some(value) = source.get(T::CLASS, T::KEY).await? {
|
||||||
// A deser failure means the stored value is shaped for a
|
// A deser failure means the stored value is shaped for a
|
||||||
@@ -159,16 +174,25 @@ impl ConfigClient {
|
|||||||
// later source — or a re-prompt — overwrites the stale entry.
|
// later source — or a re-prompt — overwrites the stale entry.
|
||||||
match serde_json::from_value::<T>(value) {
|
match serde_json::from_value::<T>(value) {
|
||||||
Ok(config) => return Ok(config),
|
Ok(config) => return Ok(config),
|
||||||
Err(e) => warn!(
|
Err(source) => {
|
||||||
"Stale value for key {} in source; falling through ({e})",
|
warn!(
|
||||||
T::KEY
|
"Stale value for key {} in source; falling through ({source})",
|
||||||
),
|
T::KEY
|
||||||
|
);
|
||||||
|
invalid = Some(source);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(ConfigError::NotFound {
|
match (invalid, preserve_invalid) {
|
||||||
key: T::KEY.to_string(),
|
(Some(source), true) => Err(ConfigError::Deserialization {
|
||||||
})
|
key: T::KEY.to_string(),
|
||||||
|
source,
|
||||||
|
}),
|
||||||
|
_ => Err(ConfigError::NotFound {
|
||||||
|
key: T::KEY.to_string(),
|
||||||
|
}),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn get_or_prompt<T: Config>(&self) -> Result<T, ConfigError> {
|
pub async fn get_or_prompt<T: Config>(&self) -> Result<T, ConfigError> {
|
||||||
@@ -230,9 +254,134 @@ impl ConfigClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct ScopedSource {
|
||||||
|
prefix: String,
|
||||||
|
source: Arc<dyn ConfigSource>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ScopedSource {
|
||||||
|
fn new(prefix: impl Into<String>, source: Arc<dyn ConfigSource>) -> Self {
|
||||||
|
Self {
|
||||||
|
prefix: prefix.into(),
|
||||||
|
source,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn key(&self, key: &str) -> String {
|
||||||
|
format!("{}/{key}", self.prefix)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl ConfigSource for ScopedSource {
|
||||||
|
async fn get(
|
||||||
|
&self,
|
||||||
|
class: ConfigClass,
|
||||||
|
key: &str,
|
||||||
|
) -> Result<Option<serde_json::Value>, ConfigError> {
|
||||||
|
self.source.get(class, &self.key(key)).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn set(
|
||||||
|
&self,
|
||||||
|
class: ConfigClass,
|
||||||
|
key: &str,
|
||||||
|
value: &serde_json::Value,
|
||||||
|
) -> Result<(), ConfigError> {
|
||||||
|
self.source.set(class, &self.key(key), value).await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct StateClient {
|
||||||
|
state: Arc<ConfigClient>,
|
||||||
|
legacy: Option<Arc<ConfigClient>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Debug for StateClient {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
f.debug_struct("StateClient").finish_non_exhaustive()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl StateClient {
|
||||||
|
pub fn new(state: Arc<ConfigClient>, legacy: Arc<ConfigClient>) -> Self {
|
||||||
|
Self {
|
||||||
|
state,
|
||||||
|
legacy: Some(legacy),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get<T: Config>(&self) -> Result<T, ConfigError> {
|
||||||
|
match self.state.get_strict::<T>().await {
|
||||||
|
Err(ConfigError::NotFound { .. }) => {
|
||||||
|
let Some(legacy) = &self.legacy else {
|
||||||
|
return Err(ConfigError::NotFound {
|
||||||
|
key: T::KEY.to_string(),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
let value = legacy.get::<T>().await?;
|
||||||
|
self.state.set(&value).await?;
|
||||||
|
Ok(value)
|
||||||
|
}
|
||||||
|
result => result,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn set<T: Config>(&self, value: &T) -> Result<(), ConfigError> {
|
||||||
|
self.state.set(value).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn scoped(&self, scope: &str, migrate_legacy: bool) -> Self {
|
||||||
|
let scope = match scope {
|
||||||
|
"" => "%00".to_string(),
|
||||||
|
"." => "%2E".to_string(),
|
||||||
|
".." => "%2E%2E".to_string(),
|
||||||
|
_ => scope
|
||||||
|
.bytes()
|
||||||
|
.map(|byte| match byte {
|
||||||
|
b'a'..=b'z' | b'A'..=b'Z' | b'0'..=b'9' | b'-' | b'_' | b'.' => {
|
||||||
|
char::from(byte).to_string()
|
||||||
|
}
|
||||||
|
_ => format!("%{byte:02X}"),
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
};
|
||||||
|
Self {
|
||||||
|
state: Arc::new(ConfigClient::new(
|
||||||
|
self.state
|
||||||
|
.sources
|
||||||
|
.iter()
|
||||||
|
.map(|source| {
|
||||||
|
Arc::new(ScopedSource::new(&scope, source.clone())) as Arc<dyn ConfigSource>
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
)),
|
||||||
|
legacy: if migrate_legacy {
|
||||||
|
self.legacy.clone()
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn clients_for_source(source: Arc<dyn ConfigSource>) -> (Arc<ConfigClient>, StateClient) {
|
||||||
|
let config = Arc::new(ConfigClient::new(vec![
|
||||||
|
Arc::new(EnvSource),
|
||||||
|
source.clone(),
|
||||||
|
Arc::new(PromptSource::new()),
|
||||||
|
]));
|
||||||
|
let state = Arc::new(ConfigClient::new(vec![Arc::new(ScopedSource::new(
|
||||||
|
harmony_secret::HARMONY_STATE_SUBPATH,
|
||||||
|
source,
|
||||||
|
))]));
|
||||||
|
(config.clone(), StateClient::new(state, config))
|
||||||
|
}
|
||||||
|
|
||||||
/// Build an OpenBao-backed `StoreSource` purely from env — the default chain.
|
/// Build an OpenBao-backed `StoreSource` purely from env — the default chain.
|
||||||
async fn openbao_from_env(namespace: &str) -> Option<Arc<dyn ConfigSource>> {
|
async fn openbao_from_env(namespace: &str) -> Option<Arc<dyn ConfigSource>> {
|
||||||
openbao_source(namespace, None, None, None, None).await
|
openbao_source(namespace, None, None, None, None, None, None).await
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Build an OpenBao-backed `StoreSource`. Explicit arguments override their env
|
/// Build an OpenBao-backed `StoreSource`. Explicit arguments override their env
|
||||||
@@ -243,6 +392,8 @@ async fn openbao_from_env(namespace: &str) -> Option<Arc<dyn ConfigSource>> {
|
|||||||
pub async fn openbao_source(
|
pub async fn openbao_source(
|
||||||
namespace: &str,
|
namespace: &str,
|
||||||
openbao_url: Option<String>,
|
openbao_url: Option<String>,
|
||||||
|
openbao_token: Option<String>,
|
||||||
|
openbao_kv_mount: Option<String>,
|
||||||
zitadel_sso_url: Option<String>,
|
zitadel_sso_url: Option<String>,
|
||||||
zitadel_audience: Option<String>,
|
zitadel_audience: Option<String>,
|
||||||
openbao_jwt_role: Option<String>,
|
openbao_jwt_role: Option<String>,
|
||||||
@@ -260,6 +411,7 @@ pub async fn openbao_source(
|
|||||||
let sso_url = zitadel_sso_url.or_else(|| env("HARMONY_SSO_URL"));
|
let sso_url = zitadel_sso_url.or_else(|| env("HARMONY_SSO_URL"));
|
||||||
let jwt_role = openbao_jwt_role.or_else(|| env("OPENBAO_JWT_ROLE"));
|
let jwt_role = openbao_jwt_role.or_else(|| env("OPENBAO_JWT_ROLE"));
|
||||||
let jwt_auth_mount = env("OPENBAO_JWT_AUTH_MOUNT").unwrap_or_else(|| "jwt".to_string());
|
let jwt_auth_mount = env("OPENBAO_JWT_AUTH_MOUNT").unwrap_or_else(|| "jwt".to_string());
|
||||||
|
let token = openbao_token.or_else(|| env("OPENBAO_TOKEN"));
|
||||||
|
|
||||||
// Headless Zitadel-machine → OpenBao (JWT-bearer) rung: needs a machine
|
// Headless Zitadel-machine → OpenBao (JWT-bearer) rung: needs a machine
|
||||||
// keyfile (path or inline JSON) plus the project-ID audience. Absent any
|
// keyfile (path or inline JSON) plus the project-ID audience. Absent any
|
||||||
@@ -284,14 +436,18 @@ pub async fn openbao_source(
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let kv_mount = env("OPENBAO_KV_MOUNT").unwrap_or_else(|| "secret".to_string());
|
let kv_mount = openbao_kv_mount
|
||||||
|
.or_else(|| env("OPENBAO_KV_MOUNT"))
|
||||||
|
.unwrap_or_else(|| "secret".to_string());
|
||||||
let skip_tls = env("OPENBAO_SKIP_TLS").as_deref() == Some("true");
|
let skip_tls = env("OPENBAO_SKIP_TLS").as_deref() == Some("true");
|
||||||
let inline_machine_identity = zitadel_jwt_bearer.as_ref().is_some_and(|config| {
|
let inline_machine_identity = token.is_none()
|
||||||
config
|
&& zitadel_jwt_bearer.as_ref().is_some_and(|config| {
|
||||||
.key_json
|
config
|
||||||
.as_deref()
|
.key_json
|
||||||
.is_some_and(|key| !key.trim().is_empty())
|
.as_deref()
|
||||||
}) && jwt_role.is_some();
|
.is_some_and(|key| !key.trim().is_empty())
|
||||||
|
})
|
||||||
|
&& jwt_role.is_some();
|
||||||
let store = if inline_machine_identity {
|
let store = if inline_machine_identity {
|
||||||
match (zitadel_jwt_bearer.as_ref(), jwt_role.as_deref()) {
|
match (zitadel_jwt_bearer.as_ref(), jwt_role.as_deref()) {
|
||||||
(Some(config), Some(role)) => {
|
(Some(config), Some(role)) => {
|
||||||
@@ -313,7 +469,7 @@ pub async fn openbao_source(
|
|||||||
kv_mount,
|
kv_mount,
|
||||||
auth_mount: env("OPENBAO_AUTH_MOUNT").unwrap_or_else(|| "jwt".to_string()),
|
auth_mount: env("OPENBAO_AUTH_MOUNT").unwrap_or_else(|| "jwt".to_string()),
|
||||||
skip_tls,
|
skip_tls,
|
||||||
token: env("OPENBAO_TOKEN"),
|
token,
|
||||||
username: env("OPENBAO_USERNAME"),
|
username: env("OPENBAO_USERNAME"),
|
||||||
password: env("OPENBAO_PASSWORD"),
|
password: env("OPENBAO_PASSWORD"),
|
||||||
zitadel_sso_url: sso_url,
|
zitadel_sso_url: sso_url,
|
||||||
@@ -328,7 +484,7 @@ pub async fn openbao_source(
|
|||||||
match store {
|
match store {
|
||||||
Ok(store) => Some(Arc::new(StoreSource::new(namespace.to_string(), store))),
|
Ok(store) => Some(Arc::new(StoreSource::new(namespace.to_string(), store))),
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
warn!("OpenBao unreachable ({e}); source omitted from chain");
|
warn!("OpenBao source unavailable ({e}); source omitted from chain");
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -337,8 +493,12 @@ pub async fn openbao_source(
|
|||||||
static CONFIG_CLIENT: Mutex<Option<Arc<ConfigClient>>> = Mutex::const_new(None);
|
static CONFIG_CLIENT: Mutex<Option<Arc<ConfigClient>>> = Mutex::const_new(None);
|
||||||
|
|
||||||
pub async fn init(sources: Vec<Arc<dyn ConfigSource>>) {
|
pub async fn init(sources: Vec<Arc<dyn ConfigSource>>) {
|
||||||
|
init_client(Arc::new(ConfigClient::new(sources))).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn init_client(client: Arc<ConfigClient>) {
|
||||||
let mut manager = CONFIG_CLIENT.lock().await;
|
let mut manager = CONFIG_CLIENT.lock().await;
|
||||||
*manager = Some(Arc::new(ConfigClient::new(sources)));
|
*manager = Some(client);
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn get<T: Config>() -> Result<T, ConfigError> {
|
pub async fn get<T: Config>() -> Result<T, ConfigError> {
|
||||||
@@ -494,6 +654,99 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn state_client_migrates_legacy_values_without_writing_legacy_source() {
|
||||||
|
let mut legacy_data = std::collections::HashMap::new();
|
||||||
|
legacy_data.insert(
|
||||||
|
TestConfig::KEY.to_string(),
|
||||||
|
serde_json::json!({"name": "legacy", "count": 1}),
|
||||||
|
);
|
||||||
|
let state = Arc::new(MockSource::new());
|
||||||
|
let legacy = Arc::new(MockSource::with_data(legacy_data));
|
||||||
|
let client = StateClient::new(
|
||||||
|
Arc::new(ConfigClient::new(vec![state.clone()])),
|
||||||
|
Arc::new(ConfigClient::new(vec![legacy.clone()])),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(client.get::<TestConfig>().await.unwrap().name, "legacy");
|
||||||
|
assert_eq!(state.set_call_count(), 1);
|
||||||
|
assert_eq!(legacy.set_call_count(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn context_state_is_namespaced_and_component_scoped() {
|
||||||
|
let source = Arc::new(MockSource::new());
|
||||||
|
let (_, state) = clients_for_source(source.clone());
|
||||||
|
|
||||||
|
state
|
||||||
|
.scoped("identity/api", false)
|
||||||
|
.set(&TestConfig {
|
||||||
|
name: "scoped".into(),
|
||||||
|
count: 1,
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
source.observed(),
|
||||||
|
vec![(
|
||||||
|
ConfigClass::Standard,
|
||||||
|
"harmony-state/identity%2Fapi/TestConfig".into(),
|
||||||
|
"set"
|
||||||
|
)]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn scoped_state_migrates_legacy_only_when_enabled() {
|
||||||
|
let mut data = std::collections::HashMap::new();
|
||||||
|
data.insert(
|
||||||
|
TestConfig::KEY.to_string(),
|
||||||
|
serde_json::json!({"name": "legacy", "count": 1}),
|
||||||
|
);
|
||||||
|
let state = Arc::new(MockSource::new());
|
||||||
|
let legacy = Arc::new(MockSource::with_data(data));
|
||||||
|
let client = StateClient::new(
|
||||||
|
Arc::new(ConfigClient::new(vec![state])),
|
||||||
|
Arc::new(ConfigClient::new(vec![legacy])),
|
||||||
|
);
|
||||||
|
let first = client.scoped("first", true);
|
||||||
|
let second = client.scoped("second", false);
|
||||||
|
|
||||||
|
assert_eq!(first.get::<TestConfig>().await.unwrap().name, "legacy");
|
||||||
|
assert!(matches!(
|
||||||
|
second.get::<TestConfig>().await,
|
||||||
|
Err(ConfigError::NotFound { .. })
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn malformed_state_does_not_fall_back_to_legacy() {
|
||||||
|
let mut state_data = std::collections::HashMap::new();
|
||||||
|
state_data.insert(
|
||||||
|
TestConfig::KEY.to_string(),
|
||||||
|
serde_json::json!({"name": "invalid", "count": "not-a-number"}),
|
||||||
|
);
|
||||||
|
let mut legacy_data = std::collections::HashMap::new();
|
||||||
|
legacy_data.insert(
|
||||||
|
TestConfig::KEY.to_string(),
|
||||||
|
serde_json::json!({"name": "legacy", "count": 1}),
|
||||||
|
);
|
||||||
|
let legacy = Arc::new(MockSource::with_data(legacy_data));
|
||||||
|
let client = StateClient::new(
|
||||||
|
Arc::new(ConfigClient::new(vec![Arc::new(MockSource::with_data(
|
||||||
|
state_data,
|
||||||
|
))])),
|
||||||
|
Arc::new(ConfigClient::new(vec![legacy.clone()])),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert!(matches!(
|
||||||
|
client.get::<TestConfig>().await,
|
||||||
|
Err(ConfigError::Deserialization { .. })
|
||||||
|
));
|
||||||
|
assert_eq!(legacy.get_call_count(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
/// A `FieldPrompter` double: returns canned answers and records which
|
/// A `FieldPrompter` double: returns canned answers and records which
|
||||||
/// fields it was asked for, so a test can assert only the missing fields
|
/// fields it was asked for, so a test can assert only the missing fields
|
||||||
/// were prompted.
|
/// were prompted.
|
||||||
@@ -1010,19 +1263,44 @@ mod tests {
|
|||||||
source
|
source
|
||||||
.set(
|
.set(
|
||||||
ConfigClass::Standard,
|
ConfigClass::Standard,
|
||||||
"TestConfig",
|
"state/TestConfig",
|
||||||
&serde_json::to_value(&config).unwrap(),
|
&serde_json::to_value(&config).unwrap(),
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
let file_path = dir.path().join("TestConfig.json");
|
let file_path = dir.path().join("state/TestConfig.json");
|
||||||
let contents = std::fs::read_to_string(&file_path).unwrap();
|
let contents = std::fs::read_to_string(&file_path).unwrap();
|
||||||
let parsed: TestConfig = serde_json::from_str(&contents).unwrap();
|
let parsed: TestConfig = serde_json::from_str(&contents).unwrap();
|
||||||
|
|
||||||
assert_eq!(parsed, config);
|
assert_eq!(parsed, config);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
#[tokio::test]
|
||||||
|
async fn local_file_source_protects_secret_config() {
|
||||||
|
use std::os::unix::fs::PermissionsExt;
|
||||||
|
use tempfile::tempdir;
|
||||||
|
|
||||||
|
let dir = tempdir().unwrap();
|
||||||
|
let source = LocalFileSource::new(dir.path().to_path_buf());
|
||||||
|
source
|
||||||
|
.set(
|
||||||
|
ConfigClass::Secret,
|
||||||
|
"Credentials",
|
||||||
|
&serde_json::json!({"token": "secret"}),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let mode = std::fs::metadata(dir.path().join("Credentials.json"))
|
||||||
|
.unwrap()
|
||||||
|
.permissions()
|
||||||
|
.mode()
|
||||||
|
& 0o777;
|
||||||
|
assert_eq!(mode, 0o600);
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_sqlite_set_and_get() {
|
async fn test_sqlite_set_and_get() {
|
||||||
use tempfile::NamedTempFile;
|
use tempfile::NamedTempFile;
|
||||||
@@ -1389,6 +1667,22 @@ mod tests {
|
|||||||
assert!(result.is_none());
|
assert!(result.is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn explicit_openbao_token_builds_source_without_environment_auth() {
|
||||||
|
let source = openbao_source(
|
||||||
|
"tenant",
|
||||||
|
Some("https://explicit-token.invalid".into()),
|
||||||
|
Some("context-token".into()),
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
assert!(source.is_some());
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_full_chain_with_prompt_source_falls_through_to_prompt() {
|
async fn test_full_chain_with_prompt_source_falls_through_to_prompt() {
|
||||||
use tempfile::NamedTempFile;
|
use tempfile::NamedTempFile;
|
||||||
@@ -1423,7 +1717,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_store_source_error_falls_through_to_sqlite() {
|
async fn test_store_source_error_stops_the_chain() {
|
||||||
use tempfile::NamedTempFile;
|
use tempfile::NamedTempFile;
|
||||||
|
|
||||||
let temp_file = NamedTempFile::new().unwrap();
|
let temp_file = NamedTempFile::new().unwrap();
|
||||||
@@ -1449,9 +1743,8 @@ mod tests {
|
|||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
let result: TestConfig = manager.get().await.unwrap();
|
let result: Result<TestConfig, ConfigError> = manager.get().await;
|
||||||
assert_eq!(result.name, "from_sqlite");
|
assert!(matches!(result, Err(ConfigError::StoreError(_))));
|
||||||
assert_eq!(result.count, 42);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use tokio::fs;
|
use tokio::fs;
|
||||||
|
#[cfg(unix)]
|
||||||
|
use tokio::io::AsyncWriteExt;
|
||||||
|
|
||||||
use crate::{ConfigClass, ConfigError, ConfigSource};
|
use crate::{ConfigClass, ConfigError, ConfigSource};
|
||||||
|
|
||||||
@@ -52,19 +54,45 @@ impl ConfigSource for LocalFileSource {
|
|||||||
|
|
||||||
async fn set(
|
async fn set(
|
||||||
&self,
|
&self,
|
||||||
_class: ConfigClass,
|
class: ConfigClass,
|
||||||
key: &str,
|
key: &str,
|
||||||
value: &serde_json::Value,
|
value: &serde_json::Value,
|
||||||
) -> Result<(), ConfigError> {
|
) -> Result<(), ConfigError> {
|
||||||
fs::create_dir_all(&self.base_path).await?;
|
|
||||||
|
|
||||||
let path = self.file_path_for(key);
|
let path = self.file_path_for(key);
|
||||||
|
if let Some(parent) = path.parent() {
|
||||||
|
fs::create_dir_all(parent).await?;
|
||||||
|
}
|
||||||
let contents =
|
let contents =
|
||||||
serde_json::to_string_pretty(value).map_err(|e| ConfigError::Serialization {
|
serde_json::to_string_pretty(value).map_err(|e| ConfigError::Serialization {
|
||||||
key: key.to_string(),
|
key: key.to_string(),
|
||||||
source: e,
|
source: e,
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
{
|
||||||
|
use std::os::unix::fs::PermissionsExt;
|
||||||
|
|
||||||
|
let mode = if class == ConfigClass::Secret {
|
||||||
|
0o600
|
||||||
|
} else {
|
||||||
|
0o666
|
||||||
|
};
|
||||||
|
let mut file = fs::OpenOptions::new()
|
||||||
|
.write(true)
|
||||||
|
.create(true)
|
||||||
|
.truncate(true)
|
||||||
|
.mode(mode)
|
||||||
|
.open(&path)
|
||||||
|
.await?;
|
||||||
|
if class == ConfigClass::Secret {
|
||||||
|
file.set_permissions(std::fs::Permissions::from_mode(mode))
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
file.write_all(contents.as_bytes()).await?;
|
||||||
|
file.flush().await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(unix))]
|
||||||
fs::write(&path, contents).await?;
|
fs::write(&path, contents).await?;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
|
use crate::{ConfigClass, ConfigError, ConfigSource};
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use harmony_secret::SecretStore;
|
use harmony_secret::SecretStore;
|
||||||
use log::warn;
|
|
||||||
|
|
||||||
use crate::{ConfigClass, ConfigError, ConfigSource};
|
|
||||||
|
|
||||||
pub struct StoreSource<S> {
|
pub struct StoreSource<S> {
|
||||||
namespace: String,
|
namespace: String,
|
||||||
@@ -34,14 +32,7 @@ impl<S: SecretStore + 'static> ConfigSource for StoreSource<S> {
|
|||||||
Ok(Some(value))
|
Ok(Some(value))
|
||||||
}
|
}
|
||||||
Err(harmony_secret::SecretStoreError::NotFound { .. }) => Ok(None),
|
Err(harmony_secret::SecretStoreError::NotFound { .. }) => Ok(None),
|
||||||
// Log before swallowing: a down/misconfigured OpenBao must not look identical to "key absent".
|
Err(e) => Err(ConfigError::StoreError(e)),
|
||||||
Err(e) => {
|
|
||||||
warn!(
|
|
||||||
"StoreSource: get for key '{key}' failed ({e}); treating as \
|
|
||||||
absent and falling through to the next source"
|
|
||||||
);
|
|
||||||
Ok(None)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,37 +6,23 @@
|
|||||||
//! the role's `groups_claim`, so attaching/detaching the policy binds
|
//! the role's `groups_claim`, so attaching/detaching the policy binds
|
||||||
//! for every member's existing tokens at request time — O(groups)
|
//! for every member's existing tokens at request time — O(groups)
|
||||||
//! writes per deployment change, regardless of fleet size (ADR-025).
|
//! writes per deployment change, regardless of fleet size (ADR-025).
|
||||||
//!
|
|
||||||
//! External groups are matched to the token claim through a group alias
|
|
||||||
//! on the JWT mount; we create group + alias on first grant so the grant
|
|
||||||
//! can precede any device's first login.
|
|
||||||
|
|
||||||
use std::collections::HashSet;
|
|
||||||
|
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use reqwest::StatusCode;
|
|
||||||
use serde_json::json;
|
|
||||||
use tokio::sync::OnceCell;
|
|
||||||
|
|
||||||
use harmony_reconciler_contracts::{
|
use harmony_reconciler_contracts::{
|
||||||
DEVICE_PULL_SECRET_PATH, DeploymentName, DeploymentSecretGrants, SecretAccessError,
|
DEVICE_PULL_SECRET_PATH, DeploymentName, DeploymentSecretGrants, SecretAccessError,
|
||||||
validate_image_pull_secret_reference,
|
validate_image_pull_secret_reference,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
use crate::OpenBaoPolicyManager;
|
||||||
|
|
||||||
const JWT_AUTH_MOUNT: &str = "jwt";
|
const JWT_AUTH_MOUNT: &str = "jwt";
|
||||||
|
|
||||||
pub struct OpenBaoDeploymentSecretGrants {
|
pub struct OpenBaoDeploymentSecretGrants {
|
||||||
client: reqwest::Client,
|
policies: OpenBaoPolicyManager,
|
||||||
base_url: String,
|
|
||||||
token: String,
|
|
||||||
kv_mount: String,
|
kv_mount: String,
|
||||||
/// Path prefix under the KV mount where the fleet's secrets live
|
/// Path prefix under the KV mount where the fleet's secrets live
|
||||||
/// (`<kv_mount>/data/<prefix>/<deployment>/…`).
|
/// (`<kv_mount>/data/<prefix>/<deployment>/…`).
|
||||||
secret_prefix: String,
|
secret_prefix: String,
|
||||||
jwt_mount: String,
|
|
||||||
/// JWT auth mount accessor, resolved once and reused — needed to bind
|
|
||||||
/// a group alias to the login claim value.
|
|
||||||
jwt_accessor: OnceCell<String>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl OpenBaoDeploymentSecretGrants {
|
impl OpenBaoDeploymentSecretGrants {
|
||||||
@@ -58,20 +44,12 @@ impl OpenBaoDeploymentSecretGrants {
|
|||||||
jwt_mount: String,
|
jwt_mount: String,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
Self {
|
Self {
|
||||||
client: reqwest::Client::new(),
|
policies: OpenBaoPolicyManager::new(base_url, token, jwt_mount),
|
||||||
base_url: base_url.trim_end_matches('/').to_string(),
|
|
||||||
token,
|
|
||||||
kv_mount,
|
kv_mount,
|
||||||
secret_prefix,
|
secret_prefix,
|
||||||
jwt_mount,
|
|
||||||
jwt_accessor: OnceCell::new(),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn err(context: impl std::fmt::Display, e: impl std::fmt::Display) -> SecretAccessError {
|
|
||||||
SecretAccessError::Backend(format!("{context}: {e}"))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn policy_name(deployment: &DeploymentName) -> String {
|
fn policy_name(deployment: &DeploymentName) -> String {
|
||||||
format!("deployment-{}", deployment.as_str())
|
format!("deployment-{}", deployment.as_str())
|
||||||
}
|
}
|
||||||
@@ -103,243 +81,6 @@ path "{kv}/metadata/{prefix}/{dep}/*" {{ capabilities = ["read", "list"] }}"#,
|
|||||||
}
|
}
|
||||||
Ok(hcl)
|
Ok(hcl)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn request(
|
|
||||||
&self,
|
|
||||||
method: reqwest::Method,
|
|
||||||
path: &str,
|
|
||||||
body: Option<serde_json::Value>,
|
|
||||||
) -> Result<reqwest::Response, SecretAccessError> {
|
|
||||||
let mut req = self
|
|
||||||
.client
|
|
||||||
.request(method.clone(), format!("{}/v1/{path}", self.base_url))
|
|
||||||
.header("X-Vault-Token", &self.token);
|
|
||||||
if let Some(body) = body {
|
|
||||||
req = req.json(&body);
|
|
||||||
}
|
|
||||||
req.send()
|
|
||||||
.await
|
|
||||||
.map_err(|e| Self::err(format!("{method} {path}"), e))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// JWT mount accessor from `sys/auth`, cached for the client's life.
|
|
||||||
async fn jwt_accessor(&self) -> Result<&str, SecretAccessError> {
|
|
||||||
self.jwt_accessor
|
|
||||||
.get_or_try_init(|| async {
|
|
||||||
let body: serde_json::Value = self
|
|
||||||
.request(reqwest::Method::GET, "sys/auth", None)
|
|
||||||
.await?
|
|
||||||
.error_for_status()
|
|
||||||
.map_err(|e| Self::err("GET sys/auth", e))?
|
|
||||||
.json()
|
|
||||||
.await
|
|
||||||
.map_err(|e| Self::err("parse sys/auth", e))?;
|
|
||||||
// sys/auth nests mounts under `data` over the HTTP API but
|
|
||||||
// emits them at the document root via the CLI; tolerate both.
|
|
||||||
let mount_key = format!("{}/", self.jwt_mount);
|
|
||||||
body.get("data")
|
|
||||||
.and_then(|d| d.get(&mount_key))
|
|
||||||
.or_else(|| body.get(&mount_key))
|
|
||||||
.and_then(|m| m.get("accessor"))
|
|
||||||
.and_then(|a| a.as_str())
|
|
||||||
.map(str::to_string)
|
|
||||||
.ok_or_else(|| {
|
|
||||||
Self::err(
|
|
||||||
"resolve jwt accessor",
|
|
||||||
format!("mount '{mount_key}' not found in sys/auth"),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
.map(String::as_str)
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn upsert_policy(
|
|
||||||
&self,
|
|
||||||
deployment: &DeploymentName,
|
|
||||||
image_pull_secrets: &[String],
|
|
||||||
) -> Result<(), SecretAccessError> {
|
|
||||||
let name = Self::policy_name(deployment);
|
|
||||||
let hcl = self.policy_hcl(deployment, image_pull_secrets)?;
|
|
||||||
self.request(
|
|
||||||
reqwest::Method::PUT,
|
|
||||||
&format!("sys/policies/acl/{name}"),
|
|
||||||
Some(json!({ "policy": hcl })),
|
|
||||||
)
|
|
||||||
.await?
|
|
||||||
.error_for_status()
|
|
||||||
.map_err(|e| Self::err(format!("write policy {name}"), e))?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn delete_policy(&self, deployment: &DeploymentName) -> Result<(), SecretAccessError> {
|
|
||||||
let name = Self::policy_name(deployment);
|
|
||||||
let resp = self
|
|
||||||
.request(
|
|
||||||
reqwest::Method::DELETE,
|
|
||||||
&format!("sys/policies/acl/{name}"),
|
|
||||||
None,
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
if !resp.status().is_success() && resp.status() != StatusCode::NOT_FOUND {
|
|
||||||
return Err(Self::err(format!("delete policy {name}"), resp.status()));
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Group's current policy list, or `None` when the group doesn't
|
|
||||||
/// exist.
|
|
||||||
async fn read_group_policies(
|
|
||||||
&self,
|
|
||||||
group: &str,
|
|
||||||
) -> Result<Option<Vec<String>>, SecretAccessError> {
|
|
||||||
let resp = self
|
|
||||||
.request(
|
|
||||||
reqwest::Method::GET,
|
|
||||||
&format!("identity/group/name/{group}"),
|
|
||||||
None,
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
if resp.status() == StatusCode::NOT_FOUND {
|
|
||||||
return Ok(None);
|
|
||||||
}
|
|
||||||
let body: serde_json::Value = resp
|
|
||||||
.error_for_status()
|
|
||||||
.map_err(|e| Self::err(format!("read group {group}"), e))?
|
|
||||||
.json()
|
|
||||||
.await
|
|
||||||
.map_err(|e| Self::err(format!("parse group {group}"), e))?;
|
|
||||||
Ok(Some(
|
|
||||||
body["data"]["policies"]
|
|
||||||
.as_array()
|
|
||||||
.map(|a| {
|
|
||||||
a.iter()
|
|
||||||
.filter_map(|v| v.as_str().map(str::to_string))
|
|
||||||
.collect()
|
|
||||||
})
|
|
||||||
.unwrap_or_default(),
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn write_group_policies(
|
|
||||||
&self,
|
|
||||||
group: &str,
|
|
||||||
policies: &[String],
|
|
||||||
) -> Result<(), SecretAccessError> {
|
|
||||||
self.request(
|
|
||||||
reqwest::Method::POST,
|
|
||||||
&format!("identity/group/name/{group}"),
|
|
||||||
Some(json!({ "type": "external", "policies": policies })),
|
|
||||||
)
|
|
||||||
.await?
|
|
||||||
.error_for_status()
|
|
||||||
.map_err(|e| Self::err(format!("write group {group}"), e))?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Ensure the external group exists with its alias bound to the JWT
|
|
||||||
/// mount, and that it carries `policy`.
|
|
||||||
async fn attach(&self, group: &str, policy: &str) -> Result<(), SecretAccessError> {
|
|
||||||
match self.read_group_policies(group).await? {
|
|
||||||
Some(policies) if policies.iter().any(|p| p == policy) => {}
|
|
||||||
Some(mut policies) => {
|
|
||||||
policies.push(policy.to_string());
|
|
||||||
self.write_group_policies(group, &policies).await?;
|
|
||||||
}
|
|
||||||
None => {
|
|
||||||
self.write_group_policies(group, &[policy.to_string()])
|
|
||||||
.await?;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
self.ensure_group_alias(group).await?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn detach(&self, group: &str, policy: &str) -> Result<(), SecretAccessError> {
|
|
||||||
if let Some(policies) = self.read_group_policies(group).await?
|
|
||||||
&& policies.iter().any(|p| p == policy)
|
|
||||||
{
|
|
||||||
let remaining: Vec<String> = policies.into_iter().filter(|p| p != policy).collect();
|
|
||||||
self.write_group_policies(group, &remaining).await?;
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn ensure_group_alias(&self, group: &str) -> Result<(), SecretAccessError> {
|
|
||||||
let accessor = self.jwt_accessor().await?.to_string();
|
|
||||||
let body: serde_json::Value = self
|
|
||||||
.request(
|
|
||||||
reqwest::Method::GET,
|
|
||||||
&format!("identity/group/name/{group}"),
|
|
||||||
None,
|
|
||||||
)
|
|
||||||
.await?
|
|
||||||
.error_for_status()
|
|
||||||
.map_err(|e| Self::err(format!("read group {group}"), e))?
|
|
||||||
.json()
|
|
||||||
.await
|
|
||||||
.map_err(|e| Self::err(format!("parse group {group}"), e))?;
|
|
||||||
if !body["data"]["alias"]["id"]
|
|
||||||
.as_str()
|
|
||||||
.unwrap_or("")
|
|
||||||
.is_empty()
|
|
||||||
{
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
let canonical_id = body["data"]["id"]
|
|
||||||
.as_str()
|
|
||||||
.ok_or_else(|| Self::err("read group", format!("{group} missing data.id")))?;
|
|
||||||
let resp = self
|
|
||||||
.request(
|
|
||||||
reqwest::Method::POST,
|
|
||||||
"identity/group-alias",
|
|
||||||
Some(json!({
|
|
||||||
"name": group,
|
|
||||||
"mount_accessor": accessor,
|
|
||||||
"canonical_id": canonical_id,
|
|
||||||
})),
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
if resp.status().is_success() {
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
let status = resp.status();
|
|
||||||
let body = resp.text().await.unwrap_or_default();
|
|
||||||
let alias_exists = status == StatusCode::BAD_REQUEST
|
|
||||||
&& body.to_ascii_lowercase().contains("alias")
|
|
||||||
&& body.to_ascii_lowercase().contains("already");
|
|
||||||
if !alias_exists {
|
|
||||||
return Err(Self::err(
|
|
||||||
format!("create group alias {group}"),
|
|
||||||
format!("{status}: {body}"),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Names of all identity groups, empty when none exist.
|
|
||||||
async fn list_groups(&self) -> Result<Vec<String>, SecretAccessError> {
|
|
||||||
let resp = self
|
|
||||||
.request(reqwest::Method::GET, "identity/group/name?list=true", None)
|
|
||||||
.await?;
|
|
||||||
if resp.status() == StatusCode::NOT_FOUND {
|
|
||||||
return Ok(vec![]);
|
|
||||||
}
|
|
||||||
let body: serde_json::Value = resp
|
|
||||||
.error_for_status()
|
|
||||||
.map_err(|e| Self::err("list groups", e))?
|
|
||||||
.json()
|
|
||||||
.await
|
|
||||||
.map_err(|e| Self::err("parse group list", e))?;
|
|
||||||
Ok(body["data"]["keys"]
|
|
||||||
.as_array()
|
|
||||||
.map(|a| {
|
|
||||||
a.iter()
|
|
||||||
.filter_map(|v| v.as_str().map(str::to_string))
|
|
||||||
.collect()
|
|
||||||
})
|
|
||||||
.unwrap_or_default())
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
@@ -350,20 +91,12 @@ impl DeploymentSecretGrants for OpenBaoDeploymentSecretGrants {
|
|||||||
) -> Result<(), SecretAccessError> {
|
) -> Result<(), SecretAccessError> {
|
||||||
for (deployment, groups, image_pull_secrets) in grants {
|
for (deployment, groups, image_pull_secrets) in grants {
|
||||||
let policy = Self::policy_name(deployment);
|
let policy = Self::policy_name(deployment);
|
||||||
let allowed: HashSet<&str> = groups.iter().map(String::as_str).collect();
|
let policy_hcl = (!groups.is_empty())
|
||||||
for group in self.list_groups().await? {
|
.then(|| self.policy_hcl(deployment, image_pull_secrets))
|
||||||
if !allowed.contains(group.as_str()) {
|
.transpose()?;
|
||||||
self.detach(&group, &policy).await?;
|
self.policies
|
||||||
}
|
.reconcile_acl_policy(&policy, policy_hcl.as_deref(), groups)
|
||||||
}
|
.await?;
|
||||||
if groups.is_empty() {
|
|
||||||
self.delete_policy(deployment).await?;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
self.upsert_policy(deployment, image_pull_secrets).await?;
|
|
||||||
for group in groups {
|
|
||||||
self.attach(group, &policy).await?;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
pub mod config;
|
pub mod config;
|
||||||
mod deployment_grants;
|
mod deployment_grants;
|
||||||
|
mod openbao_policy;
|
||||||
pub mod store;
|
pub mod store;
|
||||||
|
|
||||||
use crate::config::SECRET_NAMESPACE;
|
use crate::config::SECRET_NAMESPACE;
|
||||||
@@ -33,6 +34,7 @@ use tokio::sync::OnceCell;
|
|||||||
|
|
||||||
pub use deployment_grants::OpenBaoDeploymentSecretGrants;
|
pub use deployment_grants::OpenBaoDeploymentSecretGrants;
|
||||||
pub use harmony_secret_derive::Secret;
|
pub use harmony_secret_derive::Secret;
|
||||||
|
pub use openbao_policy::{HARMONY_STATE_SUBPATH, OpenBaoPolicyManager, render_tenant_policy};
|
||||||
|
|
||||||
// The Secret trait remains the same.
|
// The Secret trait remains the same.
|
||||||
// pub trait Secret: Serialize + DeserializeOwned + Sized {
|
// pub trait Secret: Serialize + DeserializeOwned + Sized {
|
||||||
|
|||||||
343
harmony_secret/src/openbao_policy.rs
Normal file
343
harmony_secret/src/openbao_policy.rs
Normal file
@@ -0,0 +1,343 @@
|
|||||||
|
use std::{collections::HashSet, fmt};
|
||||||
|
|
||||||
|
use harmony_reconciler_contracts::SecretAccessError;
|
||||||
|
use reqwest::StatusCode;
|
||||||
|
use serde_json::json;
|
||||||
|
use tokio::sync::OnceCell;
|
||||||
|
|
||||||
|
pub const HARMONY_STATE_SUBPATH: &str = "harmony-state";
|
||||||
|
|
||||||
|
pub fn render_tenant_policy(
|
||||||
|
mount: &str,
|
||||||
|
tenant: &str,
|
||||||
|
capabilities: &[&str],
|
||||||
|
writable_state: bool,
|
||||||
|
) -> String {
|
||||||
|
let capabilities = capabilities
|
||||||
|
.iter()
|
||||||
|
.map(|capability| format!("\"{capability}\""))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(", ");
|
||||||
|
let mut policy = format!(
|
||||||
|
"path \"{mount}/data/{tenant}/*\" {{ capabilities = [{capabilities}] }}\n\
|
||||||
|
path \"{mount}/metadata/{tenant}/*\" {{ capabilities = [\"list\", \"read\"] }}"
|
||||||
|
);
|
||||||
|
if writable_state {
|
||||||
|
policy.push_str(&format!(
|
||||||
|
"\npath \"{mount}/data/{tenant}/{HARMONY_STATE_SUBPATH}/*\" {{ capabilities = [\"create\", \"read\", \"update\"] }}"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
policy
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reconciles an OpenBao ACL policy with external identity groups and JWT aliases.
|
||||||
|
pub struct OpenBaoPolicyManager {
|
||||||
|
client: reqwest::Client,
|
||||||
|
base_url: String,
|
||||||
|
token: String,
|
||||||
|
jwt_mount: String,
|
||||||
|
jwt_accessor: OnceCell<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Debug for OpenBaoPolicyManager {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
f.debug_struct("OpenBaoPolicyManager")
|
||||||
|
.field("base_url", &self.base_url)
|
||||||
|
.field("jwt_mount", &self.jwt_mount)
|
||||||
|
.finish_non_exhaustive()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl OpenBaoPolicyManager {
|
||||||
|
pub fn new(base_url: String, token: String, jwt_mount: String) -> Self {
|
||||||
|
Self {
|
||||||
|
client: reqwest::Client::new(),
|
||||||
|
base_url: base_url.trim_end_matches('/').to_string(),
|
||||||
|
token,
|
||||||
|
jwt_mount,
|
||||||
|
jwt_accessor: OnceCell::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Upserts `policy_hcl` and grants it only to `groups`.
|
||||||
|
///
|
||||||
|
/// Passing `None` removes the policy. Other policies attached to each
|
||||||
|
/// identity group are preserved.
|
||||||
|
pub async fn reconcile_acl_policy(
|
||||||
|
&self,
|
||||||
|
name: &str,
|
||||||
|
policy_hcl: Option<&str>,
|
||||||
|
groups: &[String],
|
||||||
|
) -> Result<(), SecretAccessError> {
|
||||||
|
let allowed: HashSet<&str> = groups.iter().map(String::as_str).collect();
|
||||||
|
for group in self.list_groups().await? {
|
||||||
|
if !allowed.contains(group.as_str()) {
|
||||||
|
self.detach(&group, name).await?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let Some(policy_hcl) = policy_hcl else {
|
||||||
|
self.delete_policy(name).await?;
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
|
||||||
|
self.upsert_policy(name, policy_hcl).await?;
|
||||||
|
for group in groups {
|
||||||
|
self.attach(group, name).await?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn err(context: impl fmt::Display, error: impl fmt::Display) -> SecretAccessError {
|
||||||
|
SecretAccessError::Backend(format!("{context}: {error}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn request(
|
||||||
|
&self,
|
||||||
|
method: reqwest::Method,
|
||||||
|
path: &str,
|
||||||
|
body: Option<serde_json::Value>,
|
||||||
|
) -> Result<reqwest::Response, SecretAccessError> {
|
||||||
|
let mut request = self
|
||||||
|
.client
|
||||||
|
.request(method.clone(), format!("{}/v1/{path}", self.base_url))
|
||||||
|
.header("X-Vault-Token", &self.token);
|
||||||
|
if let Some(body) = body {
|
||||||
|
request = request.json(&body);
|
||||||
|
}
|
||||||
|
request
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|error| Self::err(format!("{method} {path}"), error))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn upsert_policy(&self, name: &str, policy_hcl: &str) -> Result<(), SecretAccessError> {
|
||||||
|
self.request(
|
||||||
|
reqwest::Method::PUT,
|
||||||
|
&format!("sys/policies/acl/{name}"),
|
||||||
|
Some(json!({ "policy": policy_hcl })),
|
||||||
|
)
|
||||||
|
.await?
|
||||||
|
.error_for_status()
|
||||||
|
.map_err(|error| Self::err(format!("write policy {name}"), error))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn delete_policy(&self, name: &str) -> Result<(), SecretAccessError> {
|
||||||
|
let response = self
|
||||||
|
.request(
|
||||||
|
reqwest::Method::DELETE,
|
||||||
|
&format!("sys/policies/acl/{name}"),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
if !response.status().is_success() && response.status() != StatusCode::NOT_FOUND {
|
||||||
|
return Err(Self::err(
|
||||||
|
format!("delete policy {name}"),
|
||||||
|
response.status(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn read_group_policies(
|
||||||
|
&self,
|
||||||
|
group: &str,
|
||||||
|
) -> Result<Option<Vec<String>>, SecretAccessError> {
|
||||||
|
let response = self
|
||||||
|
.request(
|
||||||
|
reqwest::Method::GET,
|
||||||
|
&format!("identity/group/name/{group}"),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
if response.status() == StatusCode::NOT_FOUND {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
let body: serde_json::Value = response
|
||||||
|
.error_for_status()
|
||||||
|
.map_err(|error| Self::err(format!("read group {group}"), error))?
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
|
.map_err(|error| Self::err(format!("parse group {group}"), error))?;
|
||||||
|
Ok(Some(
|
||||||
|
body["data"]["policies"]
|
||||||
|
.as_array()
|
||||||
|
.map(|policies| {
|
||||||
|
policies
|
||||||
|
.iter()
|
||||||
|
.filter_map(|policy| policy.as_str().map(str::to_string))
|
||||||
|
.collect()
|
||||||
|
})
|
||||||
|
.unwrap_or_default(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn write_group_policies(
|
||||||
|
&self,
|
||||||
|
group: &str,
|
||||||
|
policies: &[String],
|
||||||
|
) -> Result<(), SecretAccessError> {
|
||||||
|
self.request(
|
||||||
|
reqwest::Method::POST,
|
||||||
|
&format!("identity/group/name/{group}"),
|
||||||
|
Some(json!({ "type": "external", "policies": policies })),
|
||||||
|
)
|
||||||
|
.await?
|
||||||
|
.error_for_status()
|
||||||
|
.map_err(|error| Self::err(format!("write group {group}"), error))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn attach(&self, group: &str, policy: &str) -> Result<(), SecretAccessError> {
|
||||||
|
match self.read_group_policies(group).await? {
|
||||||
|
Some(policies) if policies.iter().any(|existing| existing == policy) => {}
|
||||||
|
Some(mut policies) => {
|
||||||
|
policies.push(policy.to_string());
|
||||||
|
self.write_group_policies(group, &policies).await?;
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
self.write_group_policies(group, &[policy.to_string()])
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.ensure_group_alias(group).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn detach(&self, group: &str, policy: &str) -> Result<(), SecretAccessError> {
|
||||||
|
if let Some(policies) = self.read_group_policies(group).await?
|
||||||
|
&& policies.iter().any(|existing| existing == policy)
|
||||||
|
{
|
||||||
|
let remaining: Vec<String> = policies
|
||||||
|
.into_iter()
|
||||||
|
.filter(|existing| existing != policy)
|
||||||
|
.collect();
|
||||||
|
self.write_group_policies(group, &remaining).await?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn ensure_group_alias(&self, group: &str) -> Result<(), SecretAccessError> {
|
||||||
|
let accessor = self.jwt_accessor().await?.to_string();
|
||||||
|
let body: serde_json::Value = self
|
||||||
|
.request(
|
||||||
|
reqwest::Method::GET,
|
||||||
|
&format!("identity/group/name/{group}"),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await?
|
||||||
|
.error_for_status()
|
||||||
|
.map_err(|error| Self::err(format!("read group {group}"), error))?
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
|
.map_err(|error| Self::err(format!("parse group {group}"), error))?;
|
||||||
|
if !body["data"]["alias"]["id"]
|
||||||
|
.as_str()
|
||||||
|
.unwrap_or("")
|
||||||
|
.is_empty()
|
||||||
|
{
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
let canonical_id = body["data"]["id"]
|
||||||
|
.as_str()
|
||||||
|
.ok_or_else(|| Self::err("read group", format!("{group} missing data.id")))?;
|
||||||
|
let response = self
|
||||||
|
.request(
|
||||||
|
reqwest::Method::POST,
|
||||||
|
"identity/group-alias",
|
||||||
|
Some(json!({
|
||||||
|
"name": group,
|
||||||
|
"mount_accessor": accessor,
|
||||||
|
"canonical_id": canonical_id,
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
if response.status().is_success() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
let status = response.status();
|
||||||
|
let body = response.text().await.unwrap_or_default();
|
||||||
|
let body_lower = body.to_ascii_lowercase();
|
||||||
|
if status != StatusCode::BAD_REQUEST
|
||||||
|
|| !body_lower.contains("alias")
|
||||||
|
|| !body_lower.contains("already")
|
||||||
|
{
|
||||||
|
return Err(Self::err(
|
||||||
|
format!("create group alias {group}"),
|
||||||
|
format!("{status}: {body}"),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn jwt_accessor(&self) -> Result<&str, SecretAccessError> {
|
||||||
|
self.jwt_accessor
|
||||||
|
.get_or_try_init(|| async {
|
||||||
|
let body: serde_json::Value = self
|
||||||
|
.request(reqwest::Method::GET, "sys/auth", None)
|
||||||
|
.await?
|
||||||
|
.error_for_status()
|
||||||
|
.map_err(|error| Self::err("GET sys/auth", error))?
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
|
.map_err(|error| Self::err("parse sys/auth", error))?;
|
||||||
|
// The HTTP API usually nests mounts under `data`, while some
|
||||||
|
// compatible responses expose them at the document root.
|
||||||
|
let mount_key = format!("{}/", self.jwt_mount);
|
||||||
|
body.get("data")
|
||||||
|
.and_then(|data| data.get(&mount_key))
|
||||||
|
.or_else(|| body.get(&mount_key))
|
||||||
|
.and_then(|mount| mount.get("accessor"))
|
||||||
|
.and_then(|accessor| accessor.as_str())
|
||||||
|
.map(str::to_string)
|
||||||
|
.ok_or_else(|| {
|
||||||
|
Self::err(
|
||||||
|
"resolve jwt accessor",
|
||||||
|
format!("mount '{mount_key}' not found in sys/auth"),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map(String::as_str)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn list_groups(&self) -> Result<Vec<String>, SecretAccessError> {
|
||||||
|
let response = self
|
||||||
|
.request(reqwest::Method::GET, "identity/group/name?list=true", None)
|
||||||
|
.await?;
|
||||||
|
if response.status() == StatusCode::NOT_FOUND {
|
||||||
|
return Ok(vec![]);
|
||||||
|
}
|
||||||
|
let body: serde_json::Value = response
|
||||||
|
.error_for_status()
|
||||||
|
.map_err(|error| Self::err("list groups", error))?
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
|
.map_err(|error| Self::err("parse group list", error))?;
|
||||||
|
Ok(body["data"]["keys"]
|
||||||
|
.as_array()
|
||||||
|
.map(|groups| {
|
||||||
|
groups
|
||||||
|
.iter()
|
||||||
|
.filter_map(|group| group.as_str().map(str::to_string))
|
||||||
|
.collect()
|
||||||
|
})
|
||||||
|
.unwrap_or_default())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn debug_omits_token() {
|
||||||
|
let manager = OpenBaoPolicyManager::new(
|
||||||
|
"https://openbao.example".into(),
|
||||||
|
"super-secret-token".into(),
|
||||||
|
"jwt".into(),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert!(!format!("{manager:?}").contains("super-secret-token"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -87,9 +87,9 @@ impl OpenbaoSecretStore {
|
|||||||
options.base_url
|
options.base_url
|
||||||
);
|
);
|
||||||
|
|
||||||
// 1. If token is provided via env var, use it directly
|
// 1. If a token is provided, use it directly.
|
||||||
if let Some(t) = &options.token {
|
if let Some(t) = &options.token {
|
||||||
debug!("OPENBAO_STORE: Using token from environment variable");
|
debug!("OPENBAO_STORE: Using supplied token");
|
||||||
return Self::with_token(
|
return Self::with_token(
|
||||||
&options.base_url,
|
&options.base_url,
|
||||||
options.skip_tls,
|
options.skip_tls,
|
||||||
|
|||||||
@@ -28,8 +28,12 @@ tokio = { workspace = true, features = ["time"] }
|
|||||||
arc-swap = "1"
|
arc-swap = "1"
|
||||||
time = "0.3"
|
time = "0.3"
|
||||||
tracing = { workspace = true }
|
tracing = { workspace = true }
|
||||||
|
thiserror.workspace = true
|
||||||
|
|
||||||
jsonwebtoken = "9"
|
jsonwebtoken = "9"
|
||||||
openidconnect = { version = "4", default-features = false, features = ["reqwest", "rustls-tls"] }
|
openidconnect = { version = "4", default-features = false, features = ["reqwest", "rustls-tls"] }
|
||||||
axum = { version = "0.8", optional = true }
|
axum = { version = "0.8", optional = true }
|
||||||
axum-extra = { version = "0.10", features = ["cookie", "cookie-private"], optional = true }
|
axum-extra = { version = "0.10", features = ["cookie", "cookie-private"], optional = true }
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
httptest = "0.16"
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ pub mod config;
|
|||||||
mod device_groups;
|
mod device_groups;
|
||||||
pub mod jwks;
|
pub mod jwks;
|
||||||
pub mod login;
|
pub mod login;
|
||||||
|
pub mod management;
|
||||||
pub mod session;
|
pub mod session;
|
||||||
|
|
||||||
pub use config::{OperatorCookieKey, ZitadelAuthConfig};
|
pub use config::{OperatorCookieKey, ZitadelAuthConfig};
|
||||||
|
|||||||
790
harmony_zitadel_auth/src/management.rs
Normal file
790
harmony_zitadel_auth/src/management.rs
Normal file
@@ -0,0 +1,790 @@
|
|||||||
|
use std::fmt;
|
||||||
|
|
||||||
|
use base64::Engine;
|
||||||
|
use reqwest::{Method, StatusCode};
|
||||||
|
use serde::Deserialize;
|
||||||
|
use serde_json::json;
|
||||||
|
|
||||||
|
#[derive(Debug, thiserror::Error)]
|
||||||
|
pub enum ManagementError {
|
||||||
|
#[error("failed to build Zitadel HTTP client: {0}")]
|
||||||
|
BuildClient(reqwest::Error),
|
||||||
|
#[error("Zitadel Management API request failed: {0}")]
|
||||||
|
Request(#[from] reqwest::Error),
|
||||||
|
#[error("invalid Zitadel Management API response: {0}")]
|
||||||
|
Json(#[from] serde_json::Error),
|
||||||
|
#[error("Zitadel {operation} returned {status}: {body}")]
|
||||||
|
Api {
|
||||||
|
operation: &'static str,
|
||||||
|
status: StatusCode,
|
||||||
|
body: String,
|
||||||
|
},
|
||||||
|
#[error("Zitadel project '{0}' does not exist")]
|
||||||
|
ProjectNotFound(String),
|
||||||
|
#[error("Zitadel user '{username}' is {actual}, not {expected}")]
|
||||||
|
WrongUserKind {
|
||||||
|
username: String,
|
||||||
|
expected: UserKind,
|
||||||
|
actual: UserKind,
|
||||||
|
},
|
||||||
|
#[error("Zitadel user '{0}' has no human or machine type")]
|
||||||
|
UnknownUserKind(String),
|
||||||
|
#[error("invalid base64 machine key: {0}")]
|
||||||
|
MachineKeyBase64(#[from] base64::DecodeError),
|
||||||
|
#[error("machine key is not UTF-8: {0}")]
|
||||||
|
MachineKeyUtf8(#[from] std::string::FromUtf8Error),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum UserKind {
|
||||||
|
Human,
|
||||||
|
Machine,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Display for UserKind {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
f.write_str(match self {
|
||||||
|
Self::Human => "human",
|
||||||
|
Self::Machine => "machine",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct Project {
|
||||||
|
pub id: String,
|
||||||
|
pub name: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct User {
|
||||||
|
pub id: String,
|
||||||
|
pub username: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct CreatedMachineKey {
|
||||||
|
pub json: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct ManagementClient {
|
||||||
|
http: reqwest::Client,
|
||||||
|
base_url: String,
|
||||||
|
pat: String,
|
||||||
|
org_id: Option<String>,
|
||||||
|
host_header: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Debug for ManagementClient {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
f.debug_struct("ManagementClient")
|
||||||
|
.field("base_url", &self.base_url)
|
||||||
|
.field("org_id", &self.org_id)
|
||||||
|
.field("host_header", &self.host_header)
|
||||||
|
.finish_non_exhaustive()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ManagementClient {
|
||||||
|
pub fn new(
|
||||||
|
base_url: impl Into<String>,
|
||||||
|
pat: impl Into<String>,
|
||||||
|
org_id: Option<String>,
|
||||||
|
accept_invalid_certs: bool,
|
||||||
|
) -> Result<Self, ManagementError> {
|
||||||
|
let http = reqwest::Client::builder()
|
||||||
|
.danger_accept_invalid_certs(accept_invalid_certs)
|
||||||
|
.build()
|
||||||
|
.map_err(ManagementError::BuildClient)?;
|
||||||
|
Ok(Self {
|
||||||
|
http,
|
||||||
|
base_url: base_url.into().trim_end_matches('/').to_string(),
|
||||||
|
pat: pat.into(),
|
||||||
|
org_id,
|
||||||
|
host_header: None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn with_host_header(mut self, host: impl Into<String>) -> Self {
|
||||||
|
self.host_header = Some(host.into());
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
fn request(&self, method: Method, path: &str) -> reqwest::RequestBuilder {
|
||||||
|
let mut request = self
|
||||||
|
.http
|
||||||
|
.request(method, format!("{}{}", self.base_url, path))
|
||||||
|
.bearer_auth(&self.pat);
|
||||||
|
if let Some(org_id) = &self.org_id {
|
||||||
|
request = request.header("x-zitadel-orgid", org_id);
|
||||||
|
}
|
||||||
|
if let Some(host) = &self.host_header {
|
||||||
|
request = request.header("host", host);
|
||||||
|
}
|
||||||
|
request
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn response(
|
||||||
|
&self,
|
||||||
|
operation: &'static str,
|
||||||
|
request: reqwest::RequestBuilder,
|
||||||
|
) -> Result<String, ManagementError> {
|
||||||
|
let response = request.send().await?;
|
||||||
|
let status = response.status();
|
||||||
|
let body = response.text().await?;
|
||||||
|
if !status.is_success() {
|
||||||
|
return Err(ManagementError::Api {
|
||||||
|
operation,
|
||||||
|
status,
|
||||||
|
body,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Ok(body)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn project(&self, name: &str) -> Result<Project, ManagementError> {
|
||||||
|
let body = self
|
||||||
|
.response(
|
||||||
|
"project search",
|
||||||
|
self.request(Method::POST, "/management/v1/projects/_search")
|
||||||
|
.json(&json!({
|
||||||
|
"queries": [{ "nameQuery": {
|
||||||
|
"name": name,
|
||||||
|
"method": "TEXT_QUERY_METHOD_EQUALS"
|
||||||
|
}}]
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
serde_json::from_str::<ProjectSearchResult>(&body)?
|
||||||
|
.result
|
||||||
|
.unwrap_or_default()
|
||||||
|
.into_iter()
|
||||||
|
.find(|project| project.name == name)
|
||||||
|
.map(Into::into)
|
||||||
|
.ok_or_else(|| ManagementError::ProjectNotFound(name.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn ensure_project_role(
|
||||||
|
&self,
|
||||||
|
project_id: &str,
|
||||||
|
key: &str,
|
||||||
|
display_name: &str,
|
||||||
|
group: Option<&str>,
|
||||||
|
) -> Result<(), ManagementError> {
|
||||||
|
let path = format!("/management/v1/projects/{project_id}/roles/_search");
|
||||||
|
let body = self
|
||||||
|
.response(
|
||||||
|
"role search",
|
||||||
|
self.request(Method::POST, &path).json(&json!({})),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
if serde_json::from_str::<RoleSearchResult>(&body)?
|
||||||
|
.result
|
||||||
|
.unwrap_or_default()
|
||||||
|
.iter()
|
||||||
|
.any(|role| role.key == key)
|
||||||
|
{
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut payload = json!({ "roleKey": key, "displayName": display_name });
|
||||||
|
if let Some(group) = group {
|
||||||
|
payload["group"] = group.into();
|
||||||
|
}
|
||||||
|
let path = format!("/management/v1/projects/{project_id}/roles");
|
||||||
|
self.response(
|
||||||
|
"role creation",
|
||||||
|
self.request(Method::POST, &path).json(&payload),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn find_human(&self, username: &str) -> Result<Option<User>, ManagementError> {
|
||||||
|
self.find_user(username, UserKind::Human).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn find_machine(&self, username: &str) -> Result<Option<User>, ManagementError> {
|
||||||
|
self.find_user(username, UserKind::Machine).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn find_user(
|
||||||
|
&self,
|
||||||
|
username: &str,
|
||||||
|
expected: UserKind,
|
||||||
|
) -> Result<Option<User>, ManagementError> {
|
||||||
|
let body = self
|
||||||
|
.response(
|
||||||
|
"user search",
|
||||||
|
self.request(Method::POST, "/management/v1/users/_search")
|
||||||
|
.json(&json!({ "queries": [{ "userNameQuery": {
|
||||||
|
"userName": username,
|
||||||
|
"method": "TEXT_QUERY_METHOD_EQUALS"
|
||||||
|
}}] })),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
let mut found = serde_json::from_str::<UserSearchResult>(&body)?
|
||||||
|
.result
|
||||||
|
.unwrap_or_default()
|
||||||
|
.into_iter()
|
||||||
|
.find(|user| {
|
||||||
|
user.user_name.as_deref() == Some(username)
|
||||||
|
|| user.preferred_login_name.as_deref() == Some(username)
|
||||||
|
});
|
||||||
|
if found.is_none() {
|
||||||
|
let body = self
|
||||||
|
.response(
|
||||||
|
"user login search",
|
||||||
|
self.request(Method::POST, "/management/v1/users/_search")
|
||||||
|
.json(&json!({ "queries": [{ "loginNameQuery": {
|
||||||
|
"loginName": username,
|
||||||
|
"method": "TEXT_QUERY_METHOD_EQUALS"
|
||||||
|
}}] })),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
found = serde_json::from_str::<UserSearchResult>(&body)?
|
||||||
|
.result
|
||||||
|
.unwrap_or_default()
|
||||||
|
.into_iter()
|
||||||
|
.find(|user| {
|
||||||
|
user.user_name.as_deref() == Some(username)
|
||||||
|
|| user.preferred_login_name.as_deref() == Some(username)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let Some(found) = found else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
let actual = match (found.human.is_some(), found.machine.is_some()) {
|
||||||
|
(true, false) => UserKind::Human,
|
||||||
|
(false, true) => UserKind::Machine,
|
||||||
|
_ => return Err(ManagementError::UnknownUserKind(username.to_string())),
|
||||||
|
};
|
||||||
|
if actual != expected {
|
||||||
|
return Err(ManagementError::WrongUserKind {
|
||||||
|
username: username.to_string(),
|
||||||
|
expected,
|
||||||
|
actual,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Ok(Some(User {
|
||||||
|
id: found.id,
|
||||||
|
username: username.to_string(),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn ensure_machine(
|
||||||
|
&self,
|
||||||
|
username: &str,
|
||||||
|
name: &str,
|
||||||
|
) -> Result<User, ManagementError> {
|
||||||
|
if let Some(user) = self.find_machine(username).await? {
|
||||||
|
return Ok(user);
|
||||||
|
}
|
||||||
|
self.create_machine(username, name).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn create_machine(
|
||||||
|
&self,
|
||||||
|
username: &str,
|
||||||
|
name: &str,
|
||||||
|
) -> Result<User, ManagementError> {
|
||||||
|
let body = self
|
||||||
|
.response(
|
||||||
|
"machine user creation",
|
||||||
|
self.request(Method::POST, "/management/v1/users/machine")
|
||||||
|
.json(&json!({
|
||||||
|
"userName": username,
|
||||||
|
"name": name,
|
||||||
|
"description": "Provisioned by Harmony",
|
||||||
|
"accessTokenType": "ACCESS_TOKEN_TYPE_JWT"
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
let created: UserCreateResponse = serde_json::from_str(&body)?;
|
||||||
|
Ok(User {
|
||||||
|
id: created.user_id,
|
||||||
|
username: username.to_string(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn create_json_machine_key(
|
||||||
|
&self,
|
||||||
|
user_id: &str,
|
||||||
|
) -> Result<CreatedMachineKey, ManagementError> {
|
||||||
|
let path = format!("/management/v1/users/{user_id}/keys");
|
||||||
|
let body = self
|
||||||
|
.response(
|
||||||
|
"machine key creation",
|
||||||
|
self.request(Method::POST, &path)
|
||||||
|
.json(&json!({ "type": "KEY_TYPE_JSON" })),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
let key: MachineKeyResponse = serde_json::from_str(&body)?;
|
||||||
|
Ok(CreatedMachineKey {
|
||||||
|
json: String::from_utf8(
|
||||||
|
base64::engine::general_purpose::STANDARD.decode(key.key_details)?,
|
||||||
|
)?,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn delete_user(&self, user_id: &str) -> Result<(), ManagementError> {
|
||||||
|
let path = format!("/management/v1/users/{user_id}");
|
||||||
|
self.response("user deletion", self.request(Method::DELETE, &path))
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn ensure_project_role_grant(
|
||||||
|
&self,
|
||||||
|
user_id: &str,
|
||||||
|
project_id: &str,
|
||||||
|
role_keys: &[String],
|
||||||
|
) -> Result<String, ManagementError> {
|
||||||
|
let body = self
|
||||||
|
.response(
|
||||||
|
"user grant search",
|
||||||
|
self.request(Method::POST, "/management/v1/users/grants/_search")
|
||||||
|
.json(&json!({ "queries": [{ "userIdQuery": { "userId": user_id }}] })),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
let grant = serde_json::from_str::<UserGrantSearchResult>(&body)?
|
||||||
|
.result
|
||||||
|
.unwrap_or_default()
|
||||||
|
.into_iter()
|
||||||
|
.find(|grant| grant.project_id == project_id);
|
||||||
|
|
||||||
|
if let Some(grant) = grant {
|
||||||
|
let mut roles = grant.role_keys;
|
||||||
|
let original_role_count = roles.len();
|
||||||
|
for role in role_keys {
|
||||||
|
if !roles.contains(role) {
|
||||||
|
roles.push(role.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if roles.len() != original_role_count {
|
||||||
|
let path = format!("/management/v1/users/{user_id}/grants/{}", grant.id);
|
||||||
|
let response = self
|
||||||
|
.request(Method::PUT, &path)
|
||||||
|
.json(&json!({ "roleKeys": roles }))
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
let status = response.status();
|
||||||
|
let body = response.text().await?;
|
||||||
|
if !status.is_success() && !is_no_changes(&body) {
|
||||||
|
return Err(ManagementError::Api {
|
||||||
|
operation: "user grant update",
|
||||||
|
status,
|
||||||
|
body,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Ok(grant.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
let path = format!("/management/v1/users/{user_id}/grants");
|
||||||
|
let body = self
|
||||||
|
.response(
|
||||||
|
"user grant creation",
|
||||||
|
self.request(Method::POST, &path)
|
||||||
|
.json(&json!({ "projectId": project_id, "roleKeys": role_keys })),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
Ok(serde_json::from_str::<UserGrantCreateResponse>(&body)?.user_grant_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn set_project_role_grant(
|
||||||
|
&self,
|
||||||
|
user_id: &str,
|
||||||
|
project_id: &str,
|
||||||
|
role_keys: &[String],
|
||||||
|
) -> Result<String, ManagementError> {
|
||||||
|
let body = self
|
||||||
|
.response(
|
||||||
|
"user grant search",
|
||||||
|
self.request(Method::POST, "/management/v1/users/grants/_search")
|
||||||
|
.json(&json!({ "queries": [{ "userIdQuery": { "userId": user_id }}] })),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
if let Some(grant) = serde_json::from_str::<UserGrantSearchResult>(&body)?
|
||||||
|
.result
|
||||||
|
.unwrap_or_default()
|
||||||
|
.into_iter()
|
||||||
|
.find(|grant| grant.project_id == project_id)
|
||||||
|
{
|
||||||
|
let path = format!("/management/v1/users/{user_id}/grants/{}", grant.id);
|
||||||
|
let response = self
|
||||||
|
.request(Method::PUT, &path)
|
||||||
|
.json(&json!({ "roleKeys": role_keys }))
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
let status = response.status();
|
||||||
|
let body = response.text().await?;
|
||||||
|
if !status.is_success() && !is_no_changes(&body) {
|
||||||
|
return Err(ManagementError::Api {
|
||||||
|
operation: "user grant update",
|
||||||
|
status,
|
||||||
|
body,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return Ok(grant.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
let path = format!("/management/v1/users/{user_id}/grants");
|
||||||
|
let body = self
|
||||||
|
.response(
|
||||||
|
"user grant creation",
|
||||||
|
self.request(Method::POST, &path)
|
||||||
|
.json(&json!({ "projectId": project_id, "roleKeys": role_keys })),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
Ok(serde_json::from_str::<UserGrantCreateResponse>(&body)?.user_grant_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn action_in_token_flow(&self, name: &str) -> Result<bool, ManagementError> {
|
||||||
|
let body = self
|
||||||
|
.response(
|
||||||
|
"action search",
|
||||||
|
self.request(Method::POST, "/management/v1/actions/_search")
|
||||||
|
.json(&json!({ "queries": [{ "actionNameQuery": { "name": name }}] })),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
let Some(action) = serde_json::from_str::<ActionSearchResult>(&body)?
|
||||||
|
.result
|
||||||
|
.unwrap_or_default()
|
||||||
|
.into_iter()
|
||||||
|
.find(|action| action.name == name)
|
||||||
|
else {
|
||||||
|
return Ok(false);
|
||||||
|
};
|
||||||
|
let flow: serde_json::Value = serde_json::from_str(
|
||||||
|
&self
|
||||||
|
.response(
|
||||||
|
"token flow read",
|
||||||
|
self.request(Method::GET, "/management/v1/flows/2"),
|
||||||
|
)
|
||||||
|
.await?,
|
||||||
|
)?;
|
||||||
|
Ok(["4", "5"].into_iter().all(|trigger| {
|
||||||
|
flow["flow"]["triggerActions"]
|
||||||
|
.as_array()
|
||||||
|
.into_iter()
|
||||||
|
.flatten()
|
||||||
|
.filter(|entry| {
|
||||||
|
entry["triggerType"]["id"].as_str() == Some(trigger)
|
||||||
|
|| entry["triggerType"].as_str() == Some(trigger)
|
||||||
|
})
|
||||||
|
.flat_map(|entry| entry["actions"].as_array().into_iter().flatten())
|
||||||
|
.any(|entry| entry["id"].as_str() == Some(action.id.as_str()))
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct ProjectSearchResult {
|
||||||
|
result: Option<Vec<ProjectEntry>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct ProjectEntry {
|
||||||
|
id: String,
|
||||||
|
name: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<ProjectEntry> for Project {
|
||||||
|
fn from(value: ProjectEntry) -> Self {
|
||||||
|
Self {
|
||||||
|
id: value.id,
|
||||||
|
name: value.name,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct RoleSearchResult {
|
||||||
|
result: Option<Vec<RoleEntry>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct RoleEntry {
|
||||||
|
key: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct UserSearchResult {
|
||||||
|
result: Option<Vec<UserEntry>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct UserEntry {
|
||||||
|
id: String,
|
||||||
|
#[serde(rename = "userName")]
|
||||||
|
user_name: Option<String>,
|
||||||
|
#[serde(rename = "preferredLoginName")]
|
||||||
|
preferred_login_name: Option<String>,
|
||||||
|
human: Option<serde_json::Value>,
|
||||||
|
machine: Option<serde_json::Value>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct UserCreateResponse {
|
||||||
|
#[serde(rename = "userId")]
|
||||||
|
user_id: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct MachineKeyResponse {
|
||||||
|
#[serde(rename = "keyDetails")]
|
||||||
|
key_details: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_no_changes(body: &str) -> bool {
|
||||||
|
// Zitadel returns gRPC 9 FAILED_PRECONDITION with several command
|
||||||
|
// ids when a PUT matches stored state (OIDC config: COMMAND-1m88i;
|
||||||
|
// user grants: COMMAND-Rs8fy). Treat all as idempotent success.
|
||||||
|
body.contains("\"code\":9")
|
||||||
|
&& (body.contains("COMMAND-1m88i")
|
||||||
|
|| body.contains("COMMAND-Rs8fy")
|
||||||
|
|| body.contains("No changes")
|
||||||
|
|| body.contains("has not been changed"))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct UserGrantSearchResult {
|
||||||
|
result: Option<Vec<UserGrantEntry>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct UserGrantEntry {
|
||||||
|
id: String,
|
||||||
|
#[serde(rename = "projectId")]
|
||||||
|
project_id: String,
|
||||||
|
#[serde(rename = "roleKeys", default)]
|
||||||
|
role_keys: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct UserGrantCreateResponse {
|
||||||
|
#[serde(rename = "userGrantId")]
|
||||||
|
user_grant_id: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct ActionSearchResult {
|
||||||
|
result: Option<Vec<ActionEntry>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct ActionEntry {
|
||||||
|
id: String,
|
||||||
|
name: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use httptest::{Expectation, Server, matchers::*, responders::*};
|
||||||
|
use serde_json::json;
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn client(server: &Server) -> ManagementClient {
|
||||||
|
ManagementClient::new(
|
||||||
|
server.url_str(""),
|
||||||
|
"super-secret-pat",
|
||||||
|
Some("org-1".into()),
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn project_lookup_is_exact_and_sends_org_context() {
|
||||||
|
let server = Server::run();
|
||||||
|
server.expect(
|
||||||
|
Expectation::matching(all_of![
|
||||||
|
request::method_path("POST", "/management/v1/projects/_search"),
|
||||||
|
request::headers(contains(("x-zitadel-orgid", "org-1"))),
|
||||||
|
request::body(json_decoded(eq(json!({
|
||||||
|
"queries": [{"nameQuery": {
|
||||||
|
"name": "fleet",
|
||||||
|
"method": "TEXT_QUERY_METHOD_EQUALS"
|
||||||
|
}}]
|
||||||
|
}))))
|
||||||
|
])
|
||||||
|
.respond_with(json_encoded(json!({
|
||||||
|
"result": [
|
||||||
|
{"id": "near", "name": "fleet-dev"},
|
||||||
|
{"id": "exact", "name": "fleet"}
|
||||||
|
]
|
||||||
|
}))),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(client(&server).project("fleet").await.unwrap().id, "exact");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn machine_lookup_rejects_a_human_with_the_same_username() {
|
||||||
|
let server = Server::run();
|
||||||
|
server.expect(
|
||||||
|
Expectation::matching(request::method_path("POST", "/management/v1/users/_search"))
|
||||||
|
.respond_with(json_encoded(json!({
|
||||||
|
"result": [{"id": "human-1", "userName": "robot", "human": {}}]
|
||||||
|
}))),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert!(matches!(
|
||||||
|
client(&server).find_machine("robot").await,
|
||||||
|
Err(ManagementError::WrongUserKind {
|
||||||
|
expected: UserKind::Machine,
|
||||||
|
actual: UserKind::Human,
|
||||||
|
..
|
||||||
|
})
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn create_machine_propagates_an_account_name_conflict() {
|
||||||
|
let server = Server::run();
|
||||||
|
server.expect(
|
||||||
|
Expectation::matching(request::method_path("POST", "/management/v1/users/machine"))
|
||||||
|
.respond_with(status_code(409)),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert!(matches!(
|
||||||
|
client(&server).create_machine("robot", "Robot").await,
|
||||||
|
Err(ManagementError::Api {
|
||||||
|
status: StatusCode::CONFLICT,
|
||||||
|
..
|
||||||
|
})
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn machine_key_decodes_the_one_time_json_payload() {
|
||||||
|
let server = Server::run();
|
||||||
|
server.expect(
|
||||||
|
Expectation::matching(all_of![
|
||||||
|
request::method_path("POST", "/management/v1/users/user-1/keys"),
|
||||||
|
request::body(json_decoded(eq(json!({"type": "KEY_TYPE_JSON"}))))
|
||||||
|
])
|
||||||
|
.respond_with(json_encoded(json!({
|
||||||
|
"keyId": "key-1",
|
||||||
|
"keyDetails": "eyJrZXkiOiJwcml2YXRlIn0="
|
||||||
|
}))),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
client(&server)
|
||||||
|
.create_json_machine_key("user-1")
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.json,
|
||||||
|
r#"{"key":"private"}"#
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn grant_update_adds_roles_without_removing_existing_ones() {
|
||||||
|
let server = Server::run();
|
||||||
|
server.expect(
|
||||||
|
Expectation::matching(request::method_path(
|
||||||
|
"POST",
|
||||||
|
"/management/v1/users/grants/_search",
|
||||||
|
))
|
||||||
|
.respond_with(json_encoded(json!({
|
||||||
|
"result": [{
|
||||||
|
"id": "grant-1",
|
||||||
|
"projectId": "project-1",
|
||||||
|
"roleKeys": ["existing"]
|
||||||
|
}]
|
||||||
|
}))),
|
||||||
|
);
|
||||||
|
server.expect(
|
||||||
|
Expectation::matching(all_of![
|
||||||
|
request::method_path("PUT", "/management/v1/users/user-1/grants/grant-1"),
|
||||||
|
request::body(json_decoded(eq(json!({
|
||||||
|
"roleKeys": ["existing", "requested"]
|
||||||
|
}))))
|
||||||
|
])
|
||||||
|
.respond_with(status_code(200)),
|
||||||
|
);
|
||||||
|
|
||||||
|
let roles = vec!["requested".to_string()];
|
||||||
|
assert_eq!(
|
||||||
|
client(&server)
|
||||||
|
.ensure_project_role_grant("user-1", "project-1", &roles)
|
||||||
|
.await
|
||||||
|
.unwrap(),
|
||||||
|
"grant-1"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn set_grant_replaces_roles_for_declarative_contracts() {
|
||||||
|
let server = Server::run();
|
||||||
|
server.expect(
|
||||||
|
Expectation::matching(request::method_path(
|
||||||
|
"POST",
|
||||||
|
"/management/v1/users/grants/_search",
|
||||||
|
))
|
||||||
|
.respond_with(json_encoded(json!({
|
||||||
|
"result": [{
|
||||||
|
"id": "grant-1",
|
||||||
|
"projectId": "project-1",
|
||||||
|
"roleKeys": ["stale"]
|
||||||
|
}]
|
||||||
|
}))),
|
||||||
|
);
|
||||||
|
server.expect(
|
||||||
|
Expectation::matching(all_of![
|
||||||
|
request::method_path("PUT", "/management/v1/users/user-1/grants/grant-1"),
|
||||||
|
request::body(json_decoded(eq(json!({ "roleKeys": ["declared"] }))))
|
||||||
|
])
|
||||||
|
.respond_with(status_code(200)),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
client(&server)
|
||||||
|
.set_project_role_grant("user-1", "project-1", &["declared".to_string()])
|
||||||
|
.await
|
||||||
|
.unwrap(),
|
||||||
|
"grant-1"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn action_requires_both_token_flow_triggers() {
|
||||||
|
let server = Server::run();
|
||||||
|
server.expect(
|
||||||
|
Expectation::matching(request::method_path(
|
||||||
|
"POST",
|
||||||
|
"/management/v1/actions/_search",
|
||||||
|
))
|
||||||
|
.respond_with(json_encoded(json!({
|
||||||
|
"result": [{"id": "action-1", "name": "harmonyGroupsClaim"}]
|
||||||
|
}))),
|
||||||
|
);
|
||||||
|
server.expect(
|
||||||
|
Expectation::matching(request::method_path("GET", "/management/v1/flows/2"))
|
||||||
|
.respond_with(json_encoded(json!({
|
||||||
|
"flow": {"triggerActions": [
|
||||||
|
{"triggerType": {"id": "4"}, "actions": [{"id": "action-1"}]},
|
||||||
|
{"triggerType": {"id": "5"}, "actions": [{"id": "action-1"}]}
|
||||||
|
]}
|
||||||
|
}))),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
client(&server)
|
||||||
|
.action_in_token_flow("harmonyGroupsClaim")
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn debug_does_not_expose_the_pat() {
|
||||||
|
let server = Server::run();
|
||||||
|
let debug = format!("{:?}", client(&server));
|
||||||
|
assert!(!debug.contains("super-secret-pat"));
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user