ci/fleet-argo-cd #301
64
.agents/skills/guidelines.md
Normal file
64
.agents/skills/guidelines.md
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
---
|
||||||
|
name: karpathy-guidelines
|
||||||
|
description: Behavioral guidelines to reduce common LLM coding mistakes. Use when writing, reviewing, or refactoring code to avoid overcomplication, make surgical changes, surface assumptions, and define verifiable success criteria.
|
||||||
|
license: MIT
|
||||||
|
---
|
||||||
|
|
||||||
|
Tradeoff: These guidelines bias toward caution over speed. For trivial tasks, use judgment.
|
||||||
|
|
||||||
|
1. Think Before Coding
|
||||||
|
|
||||||
|
Don't assume. Don't hide confusion. Surface tradeoffs.
|
||||||
|
|
||||||
|
Before implementing:
|
||||||
|
|
||||||
|
State your assumptions explicitly. If uncertain, ask.
|
||||||
|
If multiple interpretations exist, present them - don't pick silently.
|
||||||
|
If a simpler approach exists, say so. Push back when warranted.
|
||||||
|
If something is unclear, stop. Name what's confusing. Ask.
|
||||||
|
|
||||||
|
2. Simplicity First
|
||||||
|
|
||||||
|
Minimum code that solves the problem. Nothing speculative.
|
||||||
|
|
||||||
|
No features beyond what was asked.
|
||||||
|
No abstractions for single-use code.
|
||||||
|
No "flexibility" or "configurability" that wasn't requested.
|
||||||
|
No error handling for impossible scenarios.
|
||||||
|
If you write 200 lines and it could be 50, rewrite it.
|
||||||
|
|
||||||
|
Ask yourself: "Would a senior engineer say this is overcomplicated?" If yes, simplify.
|
||||||
|
3. Surgical Changes
|
||||||
|
|
||||||
|
Touch only what you must. Clean up only your own mess.
|
||||||
|
|
||||||
|
When editing existing code:
|
||||||
|
|
||||||
|
Don't "improve" adjacent code, comments, or formatting.
|
||||||
|
Don't refactor things that aren't broken.
|
||||||
|
Match existing style, even if you'd do it differently.
|
||||||
|
If you notice unrelated dead code, mention it - don't delete it.
|
||||||
|
|
||||||
|
When your changes create orphans:
|
||||||
|
|
||||||
|
Remove imports/variables/functions that YOUR changes made unused.
|
||||||
|
Don't remove pre-existing dead code unless asked.
|
||||||
|
|
||||||
|
The test: Every changed line should trace directly to the user's request.
|
||||||
|
4. Goal-Driven Execution
|
||||||
|
|
||||||
|
Define success criteria. Loop until verified.
|
||||||
|
|
||||||
|
Transform tasks into verifiable goals:
|
||||||
|
|
||||||
|
"Add validation" → "Write tests for invalid inputs, then make them pass"
|
||||||
|
"Fix the bug" → "Write a test that reproduces it, then make it pass"
|
||||||
|
"Refactor X" → "Ensure tests pass before and after"
|
||||||
|
|
||||||
|
For multi-step tasks, state a brief plan:
|
||||||
|
|
||||||
|
1. [Step] → verify: [check]
|
||||||
|
2. [Step] → verify: [check]
|
||||||
|
3. [Step] → verify: [check]
|
||||||
|
|
||||||
|
Strong success criteria let you loop independently. Weak criteria ("make it work") require constant clarification.
|
||||||
38
.gitea/scripts/resolve-release-version.sh
Executable file
38
.gitea/scripts/resolve-release-version.sh
Executable file
@@ -0,0 +1,38 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Resolve the release version for a per-crate release workflow.
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# resolve-release-version.sh <tag-prefix> <ref-name> [manual-version]
|
||||||
|
#
|
||||||
|
# Inputs are positional so callers can plug it from any CI without
|
||||||
|
# environment-variable contracts:
|
||||||
|
# - tag-prefix: e.g. "harmony-fleet-operator-" (NO trailing v)
|
||||||
|
# - ref-name: e.g. "harmony-fleet-operator-v0.1.0" (push-tag case)
|
||||||
|
# - manual-version: optional; takes precedence over ref parsing
|
||||||
|
# (workflow_dispatch case)
|
||||||
|
#
|
||||||
|
# Prints the resolved version (e.g. "v0.1.0") to stdout; exits non-zero
|
||||||
|
# with a message to stderr if neither input yields one.
|
||||||
|
#
|
||||||
|
# Interim: this should eventually live in a harmony Rust binary that
|
||||||
|
# understands git refs natively. See PR discussion on
|
||||||
|
# .gitea/workflows/harmony-fleet-operator.yaml.
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
PREFIX="${1:?usage: resolve-release-version.sh <prefix> <ref-name> [manual-version]}"
|
||||||
|
REF="${2:?usage: resolve-release-version.sh <prefix> <ref-name> [manual-version]}"
|
||||||
|
MANUAL="${3-}"
|
||||||
|
|
||||||
|
if [ -n "$MANUAL" ]; then
|
||||||
|
VERSION="$MANUAL"
|
||||||
|
else
|
||||||
|
VERSION="${REF#${PREFIX}}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -z "$VERSION" ] || [ "$VERSION" = "$REF" ]; then
|
||||||
|
echo "could not resolve version from ref '$REF' (prefix '$PREFIX', manual '$MANUAL')" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "$VERSION"
|
||||||
@@ -1,12 +1,19 @@
|
|||||||
name: Build and push harmony-fleet-operator image
|
name: Release harmony-fleet-operator (image + chart)
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches:
|
tags:
|
||||||
- master
|
# Per-crate release tag. One tag → one image + one chart, both
|
||||||
|
# at the same version. Format: `harmony-fleet-operator-v0.1.0`.
|
||||||
|
- 'harmony-fleet-operator-v*'
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
version:
|
||||||
|
description: 'Version tag to release (e.g. v0.1.0). Required for manual runs.'
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build_and_push:
|
release:
|
||||||
container:
|
container:
|
||||||
image: hub.nationtech.io/harmony/harmony_composer:latest
|
image: hub.nationtech.io/harmony/harmony_composer:latest
|
||||||
runs-on: dind
|
runs-on: dind
|
||||||
@@ -14,7 +21,7 @@ jobs:
|
|||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Log in to hub.nationtech.io
|
- name: Log in to hub.nationtech.io (docker)
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v3
|
||||||
with:
|
with:
|
||||||
registry: hub.nationtech.io
|
registry: hub.nationtech.io
|
||||||
@@ -24,21 +31,39 @@ jobs:
|
|||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v3
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
# Build context is the workspace root because the operator's
|
# helm is not in harmony_composer:latest at time of writing; pull
|
||||||
# Cargo.toml has `path = "../../harmony"` deps. The multi-stage
|
# the official installer. One-shot, no apt source needed.
|
||||||
# Dockerfile runs `cargo build` itself inside a pinned rust
|
|
||||||
# image, so no host-side cargo step is needed.
|
|
||||||
#
|
#
|
||||||
# TODO: add buildx layer caching. Each run currently recompiles
|
# TODO: bake helm into harmony_composer so this step disappears.
|
||||||
# the whole `harmony` workspace from scratch in the builder
|
- name: Install helm
|
||||||
# stage. Add `cache-from: type=gha` + `cache-to: type=gha,mode=max`
|
run: |
|
||||||
# below once build time becomes the bottleneck. If layer cache
|
curl -fsSL https://raw.githubusercontent.com/helm/helm/main/scripts/get-helm-3 | bash
|
||||||
# alone isn't enough, consider splitting the Dockerfile with
|
|
||||||
# cargo-chef (no other crate in this repo does that yet).
|
- name: Log in to hub.nationtech.io (helm OCI)
|
||||||
- name: Build and push
|
run: |
|
||||||
uses: docker/build-push-action@v6
|
echo "${{ secrets.HUB_BOT_PASSWORD }}" \
|
||||||
with:
|
| helm registry login hub.nationtech.io \
|
||||||
context: .
|
--username "${{ secrets.HUB_BOT_USER }}" \
|
||||||
file: fleet/harmony-fleet-operator/Dockerfile
|
--password-stdin
|
||||||
push: true
|
|
||||||
tags: hub.nationtech.io/harmony/harmony-fleet-operator:latest
|
- name: Resolve version
|
||||||
|
id: ver
|
||||||
|
run: |
|
||||||
|
VERSION=$(.gitea/scripts/resolve-release-version.sh \
|
||||||
|
"harmony-fleet-operator-" "$GITHUB_REF_NAME" "${{ inputs.version }}")
|
||||||
|
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
|
||||||
|
|
|||||||
|
|
||||||
|
# Same script a developer would run from their laptop in an
|
||||||
|
# outage. All build logic lives in Rust under
|
||||||
|
# fleet/harmony-fleet-deploy; CI is just a thin trigger.
|
||||||
|
#
|
||||||
|
# TODO (carried over from the previous workflow): add buildx
|
||||||
|
# layer caching. Each run currently recompiles the whole
|
||||||
|
# `harmony` workspace from scratch in the Dockerfile's builder
|
||||||
|
# stage. cargo-chef + `cache-from: type=gha` would help once
|
||||||
|
# build time becomes the bottleneck.
|
||||||
|
- name: Build and push image + chart
|
||||||
|
run: |
|
||||||
|
./fleet/harmony-fleet-operator/release.sh \
|
||||||
|
hub.nationtech.io \
|
||||||
|
"${{ steps.ver.outputs.version }}"
|
||||||
|
|||||||
@@ -2,6 +2,10 @@
|
|||||||
|
|
||||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||||
|
|
||||||
|
## Read First — Engineering Guidelines
|
||||||
|
|
||||||
|
**[.agents/skills/guidelines.md](.agents/skills/guidelines.md) is mandatory reading before any code change.** It encodes the bar this codebase holds — Think Before Coding, Simplicity First, Surgical Changes, Goal-Driven Execution. The cardinal sins flagged in past reviews: rebuilding modules that already exist instead of composing with them, per-component scaffolding when a function or macro would do, inlined bash in YAML workflows, and comment density that drowns the code. If you write 200 lines and a senior would call it overcomplicated, rewrite it.
|
||||||
|
|
||||||
## Build & Test Commands
|
## Build & Test Commands
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
1
Cargo.lock
generated
1
Cargo.lock
generated
@@ -4107,6 +4107,7 @@ dependencies = [
|
|||||||
"tokio",
|
"tokio",
|
||||||
"toml",
|
"toml",
|
||||||
"tracing",
|
"tracing",
|
||||||
|
"tracing-subscriber",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|||||||
92
ROADMAP/fleet_platform/dashboard_ingress.md
Normal file
92
ROADMAP/fleet_platform/dashboard_ingress.md
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
# Fleet operator dashboard — make shippable and expose via Ingress
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
The operator binary has a server-side dashboard (axum + Maud + HTMX
|
||||||
|
under `fleet/harmony-fleet-operator/src/frontend/`), but it is **not
|
||||||
|
shippable today**. The k3d smoke-test of the release pipeline made
|
||||||
|
this concrete: the chart correctly omits any `Service` or `Ingress`
|
||||||
|
because there is no production-ready dashboard endpoint to point them
|
||||||
|
at. Three blockers, in order of dependency.
|
||||||
|
|
||||||
|
## Work to be done
|
||||||
|
|
||||||
|
### 1. Build the production image with the dashboard included
|
||||||
|
|
||||||
|
- [ ] Update `fleet/harmony-fleet-operator/Dockerfile` to build with
|
||||||
|
`--features web-frontend` (currently
|
||||||
|
`cargo build --release --locked -p harmony-fleet-operator`,
|
||||||
|
no features).
|
||||||
|
- [ ] Confirm Tailwind CSS is embedded at build time inside the
|
||||||
|
builder stage. The crate doc says the CSS is embedded when
|
||||||
|
`tailwindcss` is on PATH at build time, otherwise the bundle is
|
||||||
|
empty and `--css-from` must be passed at runtime. Decide: ship
|
||||||
|
with embedded CSS (install `tailwindcss` in the builder stage)
|
||||||
|
or document the empty-bundle path.
|
||||||
|
- [ ] Confirm the build still satisfies the cross-compile gating
|
||||||
|
added in PR #291 (`ci: fix Windows cross-compile by gating
|
||||||
|
unix-only harmony code`) — the `web-frontend` feature must not
|
||||||
|
pull in unix-only code on Windows targets if Windows is still a
|
||||||
|
CI target.
|
||||||
|
|
||||||
|
### 2. Replace the mock-only `serve-web` with a real implementation
|
||||||
|
|
||||||
|
- [ ] Implement `FleetService` against the real NATS + Kubernetes
|
||||||
|
backend (the operator currently uses
|
||||||
|
`MockFleetService::default()` and bails when `--mock` is
|
||||||
|
not passed: `main.rs:125` — `"serve-web without --mock is not
|
||||||
|
implemented yet (real FleetService impl pending)"`).
|
||||||
|
- [ ] Decide the runtime topology: does the controller and the web
|
||||||
|
server share a Pod and a process? Two containers in one Pod?
|
||||||
|
Two separate Deployments? Current code suggests "same process,
|
||||||
|
different subcommand"; the chart will need to be updated
|
||||||
|
whichever way it goes.
|
||||||
|
- [ ] Wire the Zitadel auth env vars (`FLEET_AUTH_*` from `dev.sh`)
|
||||||
|
through the chart's Pod env. These are
|
||||||
|
operator-environment-specific (like the existing
|
||||||
|
`FLEET_OPERATOR_CREDENTIALS_TOML` Secret) and should likely
|
||||||
|
stay out of the redistributable chart, mounted by the deploy
|
||||||
|
pipeline.
|
||||||
|
- [ ] Decide on the `FLEET_OPERATOR_COOKIE_KEY_B64` lifecycle:
|
||||||
|
operator-generated on first boot? Deploy-time secret? Document.
|
||||||
|
|
||||||
|
### 3. Expose the dashboard via Service + Ingress in the chart
|
||||||
|
|
||||||
|
- [ ] Add a `Service` resource to `chart.rs` (ClusterIP, target port
|
||||||
|
18080 to match the default `serve-web --addr`).
|
||||||
|
- [ ] Add an `Ingress` resource. Open questions:
|
||||||
|
- Ingress class: assume `traefik` (k3d default)? Make it
|
||||||
|
configurable via `ChartOptions`?
|
||||||
|
- Host: configurable via `ChartOptions` (e.g.,
|
||||||
|
`fleet.my-cluster.example.com`); no sensible default.
|
||||||
|
- TLS: cert-manager `ClusterIssuer` reference, or expect TLS to be
|
||||||
|
terminated upstream? Probably a `ChartOptions.tls_issuer:
|
||||||
|
Option<String>` knob — `None` means "no TLS section on the
|
||||||
|
Ingress."
|
||||||
|
- [ ] Decide whether the Ingress is in scope for the chart at all,
|
||||||
|
or whether it should live in a separate `*-ingress` chart that
|
||||||
|
the deploy layer composes. The first path is simpler;
|
||||||
|
the second matches "small composable Scores" from ADR-023.
|
||||||
|
- [ ] Smoke-test on k3d: install the chart, `curl` the dashboard
|
||||||
|
through the k3d LoadBalancer, confirm HTTP 200 and the page
|
||||||
|
renders.
|
||||||
|
|
||||||
|
## Out of scope here
|
||||||
|
|
||||||
|
- Decisions about who hosts the dashboard's auth (Zitadel-only or
|
||||||
|
multi-IdP) — that's a product question, not a chart question.
|
||||||
|
- Operator HA. The current chart is `replicas: 1`. Multi-replica
|
||||||
|
needs leader election in the controller, which is its own work.
|
||||||
|
- Dashboard observability (metrics endpoint, structured access
|
||||||
|
logs) — fold in when adding the Service.
|
||||||
|
|
||||||
|
## Why this lives in its own roadmap
|
||||||
|
|
||||||
|
These three items are dependency-chained (1 → 2 → 3) and each is
|
||||||
|
non-trivial. Bundling them with the CI release pipeline would couple
|
||||||
|
unrelated risks and make the PR un-reviewable. Keeping this file
|
||||||
|
unnumbered (per
|
||||||
|
[`ROADMAP/fleet_platform/v0_1_plan.md`](v0_1_plan.md) and
|
||||||
|
[`v0_2_plan.md`](v0_2_plan.md) — numbered files are versioned
|
||||||
|
milestones) signals that this is a free-floating workstream that
|
||||||
|
slots into whichever milestone picks it up.
|
||||||
390
docs/adr/drafts/012-1-release-architecture.md
Normal file
390
docs/adr/drafts/012-1-release-architecture.md
Normal file
@@ -0,0 +1,390 @@
|
|||||||
|
# ADR-012 follow-up: Release Architecture Mechanism (Capability-Driven Build, Package, Push)
|
||||||
|
|
||||||
|
Initial Author: Jean-Gabriel Gill-Couture (drafted with Claude)
|
||||||
|
|
||||||
|
Initial Date: 2026-05-27
|
||||||
|
|
||||||
|
Last Updated Date: 2026-05-27
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
**Draft. Clarification + mechanism for ADR-012, not a separate
|
||||||
|
decision.** ADR-012 (Project Delivery Automation, 2025-06-04)
|
||||||
|
locked the *intent*: application-level Scores (LAMPScore-style)
|
||||||
|
drive an opinionated pipeline — *empty-check → package&publish →
|
||||||
|
deploy staging → sanity-check → deploy production → sanity-check* —
|
||||||
|
with CD tools (Argo / Flux) activated by default and called via
|
||||||
|
API; the same `harmony` binary runs the project's module locally
|
||||||
|
and in CI.
|
||||||
|
|
||||||
|
This document supplements that intent with the concrete *mechanism*:
|
||||||
|
how steps 2, 3, 5 (package&publish, deploy staging, deploy
|
||||||
|
production) actually compile and execute against today's
|
||||||
|
Score-Topology-Capability primitives, and what to delete from the
|
||||||
|
two interim attempts (`modules/application` + the per-component
|
||||||
|
`harmony-fleet-release` binary) when the mechanism lands.
|
||||||
|
|
||||||
|
Companion references:
|
||||||
|
- **ADR-023 (Deploy Architecture, Accepted)** — the `DeployScore`
|
||||||
|
proposed here plugs into the `harmony_cli::run` flow and
|
||||||
|
`*-deploy` crate pattern ADR-023 mandates; the smoke-test
|
||||||
|
contract (ADR-023 principle 4) is how steps 4 and 6 of ADR-012
|
||||||
|
block.
|
||||||
|
- **ADR-024 draft (Fleet Platform Capability Decomposition)** —
|
||||||
|
same decomposition shape, applied to release: framework-level
|
||||||
|
capability traits, not per-app methods.
|
||||||
|
- **ADR-003** — capability traits represent industry concepts, not
|
||||||
|
tools. Every new capability here passes the swap-out test.
|
||||||
|
- **ADR-018** — template hydration. `ChartSource::Builder` keeps
|
||||||
|
the chart fully typed up to package time.
|
||||||
|
|
||||||
|
Open questions remain in the *Open questions* section below; they
|
||||||
|
are deliberately left open for follow-up clarifications to bolt on
|
||||||
|
the same way this one bolts on to ADR-012.
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
ADR-012's intent never got a concrete mechanism beyond `LAMPScore`
|
||||||
|
and the (later-abandoned) `modules/application` parallel hierarchy.
|
||||||
|
As a result, today every app harmony hosts re-implements its own
|
||||||
|
release pipeline:
|
||||||
|
|
||||||
|
- A ~270-line per-component release binary (`fleet_release.rs`).
|
||||||
|
- A per-component `release.sh` wrapper script.
|
||||||
|
- A per-component `.gitea/workflows/<app>-<component>.yaml`.
|
||||||
|
- Hand-written chart hydration (`fleet/harmony-fleet-deploy/src/operator/chart.rs`).
|
||||||
|
|
||||||
|
PR review (#301) flagged this directly: *"we should not have to
|
||||||
|
rebuild a new cli for every component of every app using harmony"*
|
||||||
|
and *"the burden of designing the process and cli should not be on
|
||||||
|
the user. Fleet is the equivalent of a user."* The framework owns
|
||||||
|
deploy (ADR-023); release is the missing half of ADR-012.
|
||||||
|
|
||||||
|
A naive fix — "add a `define_release!` macro so each component is
|
||||||
|
one declarative invocation" — solves the duplication but leaves the
|
||||||
|
mechanism wrong. Building, packaging, and pushing are not app
|
||||||
|
concerns. They are *capabilities* that an environment provides (a
|
||||||
|
docker daemon, an OCI registry, a CI runner), the same way DNS,
|
||||||
|
LoadBalancer, or PostgreSQL are.
|
||||||
|
|
||||||
|
This ADR pulls release into the Score-Topology-Capability pattern
|
||||||
|
that already governs deploy.
|
||||||
|
|
||||||
|
## Two prior attempts and what each got right
|
||||||
|
|
||||||
|
### Attempt 1 — `modules/application` (the `RustWebapp` example, `ApplicationScore`, `ApplicationFeature`)
|
||||||
|
|
||||||
|
`examples/rust/src/main.rs` shows what good DX looks like:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
let application = Arc::new(RustWebapp {
|
||||||
|
name: "harmony-example-rust-webapp".to_string(),
|
||||||
|
project_root: PathBuf::from("./webapp"),
|
||||||
|
framework: Some(RustWebFramework::Leptos),
|
||||||
|
service_port: 3000,
|
||||||
|
..
|
||||||
|
});
|
||||||
|
|
||||||
|
let app = ApplicationScore {
|
||||||
|
features: vec![
|
||||||
|
Box::new(PackagingDeployment { application: application.clone() }),
|
||||||
|
Box::new(Monitoring { application: application.clone(), alert_receiver: vec![...] }),
|
||||||
|
],
|
||||||
|
application,
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
**What it got right:**
|
||||||
|
|
||||||
|
| Property | Why it's right |
|
||||||
|
|---|---|
|
||||||
|
| Declarative app spec at the call site | One struct, ~15 lines, describes the whole thing |
|
||||||
|
| Per-app type implements capability traits (`OCICompliant`, `HelmPackage`, `Webapp`) | Compiler rejects nonsensical combos — features state their bounds (`impl<A: OCICompliant + HelmPackage> ApplicationFeature for PackagingDeployment<A>`) |
|
||||||
|
| Features compose orthogonally | `vec![PackagingDeployment, Monitoring, …]` extensible without touching the app |
|
||||||
|
| One feature does one thing | Small, testable, replaceable |
|
||||||
|
|
||||||
|
**What it got wrong:**
|
||||||
|
|
||||||
|
| Anti-pattern | Symptom |
|
||||||
|
|---|---|
|
||||||
|
| Parallel hierarchy | `Application` / `ApplicationFeature` / `ApplicationInterpret` / `ApplicationScore` live alongside `Score` / `Interpret` — two ways to express the same thing |
|
||||||
|
| App-owned capabilities | `OCICompliant::build_push_oci_image()` is a method on the app — the app *does* the build. Should be: topology *provides* a builder, score *uses* it |
|
||||||
|
| `MultiTargetTopology` + `DeploymentTarget::LocalDev / Production` | Inline `match topology.current_target() { LocalDev => …, _ => Argo… }` in `PackagingDeployment::ensure_installed`. The author's own comment: *"It still does not feel right though."* |
|
||||||
|
| ArgoCD hardcoded as the production mechanism | Should be a `ContinuousDelivery` capability; OPNsense vs CoreDNS distinction (ADR-003) applies — Argo today, Flux or something else tomorrow |
|
||||||
|
|
||||||
|
### Attempt 2 — `harmony-fleet-release` binary + `release.sh` (the current branch)
|
||||||
|
|
||||||
|
After abandoning `modules/application`'s parallel hierarchy, fleet
|
||||||
|
went the other direction: hand-rolled per-component binaries, no
|
||||||
|
framework-level abstraction. Solved the parallel-hierarchy problem
|
||||||
|
by removing the hierarchy entirely; created a 270-line per-component
|
||||||
|
duplication problem in its place.
|
||||||
|
|
||||||
|
**Net diagnosis:** attempt 1 had the right *shape* (declarative
|
||||||
|
app + composable features + capability bounds), wrong *mechanism*
|
||||||
|
(parallel layer, app-side capabilities). Attempt 2 had the right
|
||||||
|
mechanism (use the existing Score/Topology primitives) but no
|
||||||
|
abstraction. The fix is to keep attempt 1's shape and express it
|
||||||
|
in attempt 2's mechanism.
|
||||||
|
|
||||||
|
## Mechanism (proposed)
|
||||||
|
|
||||||
|
**A release is a Score driven by Topology capabilities, exactly like
|
||||||
|
a deploy.** No parallel hierarchy. No per-component CLI binaries. No
|
||||||
|
app-side build/push methods. ADR-012's opinionated lifecycle pipeline
|
||||||
|
becomes a composed `Vec<Box<dyn Score<T>>>` — each pipeline step is
|
||||||
|
one Score, harmony executes them in order, each blocks on the
|
||||||
|
smoke-test contract (ADR-023, step 4).
|
||||||
|
|
||||||
|
### Capabilities (topology-side)
|
||||||
|
|
||||||
|
New capability traits, alongside `DnsServer`, `HelmCommand`, etc.:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub trait ContainerBuilder {
|
||||||
|
async fn build(&self, ctx: &BuildContext, image_ref: &str) -> Result<(), Error>;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub trait OciRegistry {
|
||||||
|
async fn push_image(&self, image_ref: &str) -> Result<(), Error>;
|
||||||
|
fn registry_url(&self) -> &str;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub trait HelmRegistry {
|
||||||
|
async fn push_chart(&self, tgz: &Path, project: &str) -> Result<(), Error>;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub trait ContinuousDelivery {
|
||||||
|
async fn sync_to(&self, chart_ref: &ChartRef, target: &DeployTarget) -> Result<(), Error>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Each capability is an industry concept (per ADR-003 rule). Adapters:
|
||||||
|
|
||||||
|
| Capability | LocalDev provider | CI provider | Production provider |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `ContainerBuilder` | local docker daemon | gitea runner docker | remote buildkit |
|
||||||
|
| `OciRegistry` | local registry, or noop (k3d image-import) | hub.nationtech.io | hub.nationtech.io |
|
||||||
|
| `HelmRegistry` | noop (local helm) | hub.nationtech.io | hub.nationtech.io |
|
||||||
|
| `ContinuousDelivery` | direct helm install | direct helm install | ArgoCD `ArgoHelmScore` |
|
||||||
|
|
||||||
|
`K8sAnywhereTopology` composes these capabilities the way it already
|
||||||
|
composes `K8sclient + HelmCommand + Ingress + …`. A CI-runner
|
||||||
|
topology variant composes the same caps with CI-flavored adapters.
|
||||||
|
|
||||||
|
### Application spec (data only)
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub struct AppSpec {
|
||||||
|
pub name: &'static str,
|
||||||
|
pub project_root: PathBuf,
|
||||||
|
pub image: ImageSource, // Dockerfile path + build args, OR a `Buildable` impl that emits one
|
||||||
|
pub chart: ChartSource, // PathBuf to a directory, OR a `ChartBuilder` closure / impl returning HelmChart
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
No methods that *do* work. The spec is pure data. Methods (`build`,
|
||||||
|
`push`, `package`) live on the topology capabilities. This is the
|
||||||
|
shift that fixes attempt 1: `RustWebapp` no longer implements
|
||||||
|
`OCICompliant::build_push_oci_image()`; instead, `AppSpec` describes
|
||||||
|
*what* to build, and `ContainerBuilder` (on the topology) does it.
|
||||||
|
|
||||||
|
### Scores
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub struct ReleaseScore { app: Arc<AppSpec>, version: String }
|
||||||
|
pub struct DeployScore { app: Arc<AppSpec>, version: String, target: DeployTarget }
|
||||||
|
|
||||||
|
impl<T: Topology + ContainerBuilder + OciRegistry + HelmRegistry> Score<T> for ReleaseScore { ... }
|
||||||
|
impl<T: Topology + ContinuousDelivery> Score<T> for DeployScore { ... }
|
||||||
|
```
|
||||||
|
|
||||||
|
The trait bounds *are* the spec of what a topology must provide.
|
||||||
|
Compile-time guarantee: you cannot construct `ReleaseScore` against
|
||||||
|
a topology that can't release.
|
||||||
|
|
||||||
|
### The opinionated pipeline (ADR-012 realized)
|
||||||
|
|
||||||
|
ADR-012's pipeline maps directly onto a Score sequence:
|
||||||
|
|
||||||
|
| ADR-012 step | Mechanism |
|
||||||
|
|---|---|
|
||||||
|
| 1. Empty check | Out of scope for harmony (matches ADR-012). Project's own CI step runs ahead of `harmony-<app>` invocation. |
|
||||||
|
| 2. Package & publish | `ReleaseScore` — builds image, hydrates chart, pushes both. |
|
||||||
|
| 3. Deploy to staging | `DeployScore { target: DeployTarget::Staging }` — talks to the topology's `ContinuousDelivery` (Argo by default per ADR-012). |
|
||||||
|
| 4. Sanity check staging | Smoke-test companion on the `DeployScore` (ADR-023 principle 4). |
|
||||||
|
| 5. Deploy to production | Same `DeployScore` with `DeployTarget::Production`. CI gates approval; harmony just runs the score. |
|
||||||
|
| 6. Sanity check production | Same smoke-test companion against production. |
|
||||||
|
|
||||||
|
### Call site (after the migration)
|
||||||
|
|
||||||
|
```rust
|
||||||
|
let app = Arc::new(AppSpec {
|
||||||
|
name: "harmony-fleet-operator",
|
||||||
|
project_root: ".".into(),
|
||||||
|
image: ImageSource::Dockerfile("fleet/harmony-fleet-operator/Dockerfile".into()),
|
||||||
|
chart: ChartSource::Builder(Box::new(operator::chart::build)),
|
||||||
|
});
|
||||||
|
|
||||||
|
let scores: Vec<Box<dyn Score<_>>> = vec![
|
||||||
|
Box::new(ReleaseScore { app: app.clone(), version: from_tag(ref_name)? }),
|
||||||
|
Box::new(DeployScore { app: app.clone(), version, target: DeployTarget::Staging }),
|
||||||
|
Box::new(DeployScore { app: app.clone(), version, target: DeployTarget::Production }),
|
||||||
|
];
|
||||||
|
|
||||||
|
harmony_cli::run(Inventory::autoload(), CITopology::from_env(), scores, None).await?
|
||||||
|
```
|
||||||
|
|
||||||
|
**That's the user-facing surface for any app.** It matches ADR-012's
|
||||||
|
"run the same command anywhere" promise — same binary, local laptop
|
||||||
|
or CI runner; topology adapters differ, Scores don't. No per-component
|
||||||
|
release binary. No `release.sh` per component. Adding a component is
|
||||||
|
one new `AppSpec` value.
|
||||||
|
|
||||||
|
### CI integration
|
||||||
|
|
||||||
|
Workflow yaml shrinks to:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
- name: Release
|
||||||
|
run: |
|
||||||
|
harmony-fleet --release --from-tag "$GITHUB_REF_NAME"
|
||||||
|
```
|
||||||
|
|
||||||
|
The `--from-tag` flag is implemented inside `harmony_release` (Rust,
|
||||||
|
not bash). It parses `<app>-<component>-v<semver>` against the
|
||||||
|
registered `AppSpec`s and constructs `ReleaseScore { app, version }`
|
||||||
|
for the matching one. Tag malformed → fail at CI start, not three
|
||||||
|
minutes into the docker build.
|
||||||
|
|
||||||
|
`.gitea/scripts/resolve-release-version.sh` (just shipped) is the
|
||||||
|
interim form; the script disappears once `--from-tag` lands.
|
||||||
|
|
||||||
|
### What replaces the `modules/application` layer
|
||||||
|
|
||||||
|
`Application` / `ApplicationFeature` / `ApplicationInterpret` /
|
||||||
|
`ApplicationScore` are deleted. Migration path:
|
||||||
|
|
||||||
|
| Old | New |
|
||||||
|
|---|---|
|
||||||
|
| `RustWebapp` (struct implementing capabilities) | `AppSpec { name, project_root, image: ImageSource::Buildable(Box::new(RustLeptosBuilder)), chart: … }` |
|
||||||
|
| `OCICompliant::build_push_oci_image` | `ContainerBuilder::build` on the topology (Score calls it) |
|
||||||
|
| `HelmPackage::build_push_helm_package` | `HelmRegistry::push_chart` on the topology (Score calls it) |
|
||||||
|
| `PackagingDeployment` (ApplicationFeature) | `ReleaseScore + DeployScore` (regular Scores) |
|
||||||
|
| `Monitoring` (ApplicationFeature) | `MonitoringScore` (regular Score) |
|
||||||
|
| `ApplicationScore` wrapping features | `Vec<Box<dyn Score<T>>>` passed to `harmony_cli::run` |
|
||||||
|
|
||||||
|
The `RustWebapp` Leptos Dockerfile generator survives — it becomes a
|
||||||
|
`Buildable` impl that emits a `Dockerfile` for `ImageSource::Buildable`
|
||||||
|
to use. Same code, different home.
|
||||||
|
|
||||||
|
## Alternatives considered
|
||||||
|
|
||||||
|
### Macro per component — `define_release!(Component::Operator { … })`
|
||||||
|
|
||||||
|
Solves the duplication. Still leaves build/push as app-side work,
|
||||||
|
still gives every app its own CLI surface. Doesn't address the
|
||||||
|
capability gap. **Rejected.**
|
||||||
|
|
||||||
|
### Keep `modules/application` as-is, add a release feature
|
||||||
|
|
||||||
|
Adds a third release variant to the existing parallel hierarchy.
|
||||||
|
Doubles down on the wrong mechanism. **Rejected.**
|
||||||
|
|
||||||
|
### Plugin-discovery `harmony` top-level binary (ADR-023 "tomorrow C")
|
||||||
|
|
||||||
|
Orthogonal to this clarification. Plugin discovery is *how the
|
||||||
|
binaries are invoked*; this is *what the binaries do*. Compatible:
|
||||||
|
each app ships a `harmony-<app>` plugin binary that registers its
|
||||||
|
`AppSpec`s with `harmony_release::cli`.
|
||||||
|
|
||||||
|
### Promote to a fresh ADR-025
|
||||||
|
|
||||||
|
The shape proposed here doesn't disagree with ADR-012 — it
|
||||||
|
*implements* it. A new ADR would invite reading them as competing
|
||||||
|
decisions and split the conversation in two. Keeping this as
|
||||||
|
ADR-012's first clarification (012-1) keeps the lineage explicit.
|
||||||
|
**Rejected for now;** revisit if a future decision genuinely
|
||||||
|
diverges from ADR-012's intent.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
### Positive
|
||||||
|
|
||||||
|
- ADR-012 intent finally has a mechanism. The opinionated pipeline
|
||||||
|
becomes a composed `Vec<Box<dyn Score<T>>>` — concrete, testable,
|
||||||
|
identical local and in CI.
|
||||||
|
- One framework, one mechanism. Release joins deploy in the
|
||||||
|
Score-Topology-Capability pattern. No second concept to learn.
|
||||||
|
- Capability-driven swap-out. Move from docker→podman, Harbor→ECR,
|
||||||
|
helm→ko, Argo→Flux without touching any app's `AppSpec`. ADR-003
|
||||||
|
rule held.
|
||||||
|
- ~250 lines deleted from each app (per-component bin + release.sh
|
||||||
|
+ workflow yaml duplication).
|
||||||
|
- Adding a component is one `AppSpec` value, not a new binary.
|
||||||
|
- LocalDev vs CI vs Production is *topology selection at runtime*
|
||||||
|
(ADR-023 principle 6), not branching inside a Score.
|
||||||
|
|
||||||
|
### Negative / costs
|
||||||
|
|
||||||
|
- Migration of `modules/application` and the `RustWebapp` example to
|
||||||
|
the new shape is a non-trivial PR (preserve the Leptos Dockerfile
|
||||||
|
generation as a `Buildable` impl).
|
||||||
|
- New crate `harmony_release` (or new module inside `harmony` core).
|
||||||
|
Sizing TBD during implementation; target the same ~ADR-023 scope.
|
||||||
|
- Adapter coverage: at least three `ContainerBuilder` adapters
|
||||||
|
(local docker, CI docker, k3d-image-import) and two `OciRegistry`
|
||||||
|
/ `HelmRegistry` adapters (hub.nationtech.io, noop) need to land
|
||||||
|
for the first migration to be useful.
|
||||||
|
|
||||||
|
### Risks to watch
|
||||||
|
|
||||||
|
- **Capability surface creep.** Resist adding a fourth or fifth
|
||||||
|
capability for every minor variant (e.g. `MultiArchContainerBuilder`,
|
||||||
|
`SignedOciRegistry`). Extend existing capabilities first; create
|
||||||
|
new ones only when the swap-out test (ADR-003) holds.
|
||||||
|
- **Build context size.** `BuildContext` could absorb every flag
|
||||||
|
ever needed (`build_args`, `secrets`, `platforms`, `targets`,
|
||||||
|
`cache_from`, …). Start small; grow with documented justification.
|
||||||
|
- **`Buildable` trait explosion.** Per-language Dockerfile generators
|
||||||
|
(`RustLeptos`, `Python`, `Node`, …) belong as `Buildable` impls,
|
||||||
|
not as variants on `AppSpec`. Resist adding them to core until at
|
||||||
|
least two callers need each one.
|
||||||
|
|
||||||
|
## Implementation order (separate PRs, each shippable)
|
||||||
|
|
||||||
|
1. **`harmony_release` crate** — `AppSpec`, `ImageSource`,
|
||||||
|
`ChartSource`, `ReleaseScore`, capability trait stubs, `--from-tag`
|
||||||
|
parsing. No adapters yet.
|
||||||
|
2. **Local + CI capability adapters** — `LocalDockerBuilder`,
|
||||||
|
`CiDockerBuilder`, `HarborOciRegistry`, `HarborHelmRegistry`.
|
||||||
|
Compose into `K8sAnywhereTopology` and a new `CiTopology`.
|
||||||
|
3. **Migrate fleet operator release** to `AppSpec` + `ReleaseScore`.
|
||||||
|
Delete `harmony-fleet-release` binary, `release.sh`,
|
||||||
|
`.gitea/scripts/resolve-release-version.sh`. Workflow yaml goes to
|
||||||
|
the ~15-line form. Validates the framework on a real app.
|
||||||
|
4. **Add fleet agent + callout** as `AppSpec` entries when their
|
||||||
|
pipelines land. One-line additions, no scaffolding.
|
||||||
|
5. **Migrate `modules/application` `RustWebapp` example** to the new
|
||||||
|
shape. Preserves the Leptos Dockerfile generator. Deletes the
|
||||||
|
parallel `Application` / `ApplicationFeature` hierarchy.
|
||||||
|
6. **(Stretch) `ContinuousDelivery` capability** as a real abstraction
|
||||||
|
above the current `ArgoHelmScore`. Enables Flux / direct-helm as
|
||||||
|
peer providers. Out of v1 scope; ADR remains compatible.
|
||||||
|
|
||||||
|
## Open questions
|
||||||
|
|
||||||
|
- **Chart hydration genericism.** Should `ChartSource::FromScore` exist?
|
||||||
|
The deploy Score's resources are a near-superset of what the chart
|
||||||
|
contains. If we get this right, `ChartSource::Builder` becomes the
|
||||||
|
exception, not the rule. Worth scoping in #5 or later.
|
||||||
|
- **`DeployTarget` shape.** Today fleet uses `(namespace, image_tag)`.
|
||||||
|
A typed enum (`InCluster { namespace, … }`, `MultiCluster { … }`,
|
||||||
|
`EdgeDevice { device_id, … }`) may capture more. Defer to the
|
||||||
|
agent-CD work (manual upgrades per device) where this gets exercised.
|
||||||
|
- **Image identity.** The image's full ref (`<registry>/<project>/<name>:<version>`)
|
||||||
|
is currently built by string concatenation in three places. Should
|
||||||
|
there be an `ImageRef` type with parse/display impls? Probably; add
|
||||||
|
in #1 if cost is small.
|
||||||
|
- **Buildable for non-Rust languages.** Out of scope until a non-Rust
|
||||||
|
app needs it, then add by demand.
|
||||||
@@ -675,10 +675,10 @@ key_json = """
|
|||||||
output_dir: PathBuf::new(), // unused on this code path
|
output_dir: PathBuf::new(), // unused on this code path
|
||||||
image: OPERATOR_IMAGE_TAG.to_string(),
|
image: OPERATOR_IMAGE_TAG.to_string(),
|
||||||
image_pull_policy: "IfNotPresent".to_string(),
|
image_pull_policy: "IfNotPresent".to_string(),
|
||||||
namespace: OPERATOR_NAMESPACE.to_string(),
|
|
||||||
nats_url: format!("nats://{NATS_RELEASE}.{NATS_NAMESPACE}.svc.cluster.local:4222"),
|
nats_url: format!("nats://{NATS_RELEASE}.{NATS_NAMESPACE}.svc.cluster.local:4222"),
|
||||||
log_level: "info,kube_runtime=warn".to_string(),
|
log_level: "info,kube_runtime=warn".to_string(),
|
||||||
credentials: Some(OperatorCredentials { credentials_toml }),
|
credentials: Some(OperatorCredentials { credentials_toml }),
|
||||||
|
chart_version: None,
|
||||||
};
|
};
|
||||||
|
|
||||||
// CRDs first — the operator watches them on startup.
|
// CRDs first — the operator watches them on startup.
|
||||||
@@ -693,7 +693,7 @@ key_json = """
|
|||||||
|
|
||||||
// RBAC.
|
// RBAC.
|
||||||
K8sResourceScore::single(
|
K8sResourceScore::single(
|
||||||
build_service_account(&opts),
|
build_service_account(),
|
||||||
Some(OPERATOR_NAMESPACE.to_string()),
|
Some(OPERATOR_NAMESPACE.to_string()),
|
||||||
)
|
)
|
||||||
.interpret(&Inventory::autoload(), topology)
|
.interpret(&Inventory::autoload(), topology)
|
||||||
@@ -705,7 +705,7 @@ key_json = """
|
|||||||
.await
|
.await
|
||||||
.context("operator ClusterRole apply")?;
|
.context("operator ClusterRole apply")?;
|
||||||
|
|
||||||
K8sResourceScore::single(build_cluster_role_binding(&opts), None)
|
K8sResourceScore::single(build_cluster_role_binding(OPERATOR_NAMESPACE), None)
|
||||||
.interpret(&Inventory::autoload(), topology)
|
.interpret(&Inventory::autoload(), topology)
|
||||||
.await
|
.await
|
||||||
.context("operator ClusterRoleBinding apply")?;
|
.context("operator ClusterRoleBinding apply")?;
|
||||||
|
|||||||
@@ -16,6 +16,16 @@ path = "src/lib.rs"
|
|||||||
name = "harmony-fleet-deploy"
|
name = "harmony-fleet-deploy"
|
||||||
path = "src/main.rs"
|
path = "src/main.rs"
|
||||||
|
|
||||||
|
# Release tool: builds and pushes the image + hydrated helm chart for
|
||||||
|
# one fleet component (operator today; agent and nats-callout will
|
||||||
|
# join as their pipelines land). Driven by the per-component
|
||||||
|
# release.sh wrappers and the .gitea CI workflows. App-scoped (not
|
||||||
|
# component-scoped) so a single binary covers every fleet component
|
||||||
|
# behind a `--component` flag.
|
||||||
|
[[bin]]
|
||||||
|
name = "harmony-fleet-release"
|
||||||
|
path = "src/bin/fleet_release.rs"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
harmony = { path = "../../harmony", features = ["podman"] }
|
harmony = { path = "../../harmony", features = ["podman"] }
|
||||||
harmony_cli = { path = "../../harmony_cli" }
|
harmony_cli = { path = "../../harmony_cli" }
|
||||||
@@ -40,3 +50,4 @@ thiserror = { workspace = true }
|
|||||||
tokio = { workspace = true, features = ["full"] }
|
tokio = { workspace = true, features = ["full"] }
|
||||||
toml = { workspace = true }
|
toml = { workspace = true }
|
||||||
tracing = { workspace = true }
|
tracing = { workspace = true }
|
||||||
|
tracing-subscriber = { workspace = true }
|
||||||
|
|||||||
71
fleet/harmony-fleet-deploy/src/argo/mod.rs
Normal file
71
fleet/harmony-fleet-deploy/src/argo/mod.rs
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
//! Argo `Application` definition for the operator chart. Composed
|
||||||
|
//! into [`ArgoHelmScore::argo_apps`] by main.rs.
|
||||||
|
|
||||||
|
use harmony::modules::application::features::{
|
||||||
|
ArgoApplication, Automated, Backoff, Helm, Retry, Source, SyncPolicy,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub const CHART_NAME: &str = "harmony-fleet-operator";
|
||||||
|
|
||||||
|
/// Build an Argo Application syncing
|
||||||
|
/// `oci://<registry>/<project>/harmony-fleet-operator:<chart_version>`
|
||||||
|
/// into `target_namespace`.
|
||||||
|
pub fn operator_application(
|
||||||
|
target_namespace: &str,
|
||||||
|
registry: &str,
|
||||||
|
project: &str,
|
||||||
|
chart_version: &str,
|
||||||
|
) -> ArgoApplication {
|
||||||
|
// Helm chart versions are SemVer; the publish pipeline strips the
|
||||||
|
// leading `v`. Accept either form from the caller.
|
||||||
|
let version = chart_version
|
||||||
|
.strip_prefix('v')
|
||||||
|
.unwrap_or(chart_version)
|
||||||
|
.to_string();
|
||||||
|
ArgoApplication {
|
||||||
|
name: CHART_NAME.to_string(),
|
||||||
|
namespace: None,
|
||||||
|
project: "default".to_string(),
|
||||||
|
source: Source {
|
||||||
|
// OCI urls intentionally have no scheme; see Source::repo_url.
|
||||||
|
repo_url: format!("{registry}/{project}"),
|
||||||
|
target_revision: Some(version),
|
||||||
|
chart: CHART_NAME.to_string(),
|
||||||
|
path: String::new(),
|
||||||
|
helm: Helm {
|
||||||
|
pass_credentials: None,
|
||||||
|
parameters: vec![],
|
||||||
|
file_parameters: vec![],
|
||||||
|
release_name: Some(CHART_NAME.to_string()),
|
||||||
|
value_files: vec![],
|
||||||
|
ignore_missing_value_files: None,
|
||||||
|
values: None,
|
||||||
|
values_object: None,
|
||||||
|
skip_crds: None,
|
||||||
|
skip_schema_validation: None,
|
||||||
|
version: None,
|
||||||
|
kube_version: None,
|
||||||
|
api_versions: vec![],
|
||||||
|
namespace: Some(target_namespace.to_string()),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
sync_policy: SyncPolicy {
|
||||||
|
automated: Automated {
|
||||||
|
prune: true,
|
||||||
|
self_heal: true,
|
||||||
|
allow_empty: false,
|
||||||
|
},
|
||||||
|
sync_options: vec!["CreateNamespace=true".to_string()],
|
||||||
|
retry: Retry {
|
||||||
|
limit: 5,
|
||||||
|
backoff: Backoff {
|
||||||
|
duration: "5s".to_string(),
|
||||||
|
factor: 2,
|
||||||
|
max_duration: "3m".to_string(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
revision_history_limit: 10,
|
||||||
|
destination_namespace: Some(target_namespace.to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
270
fleet/harmony-fleet-deploy/src/bin/fleet_release.rs
Normal file
270
fleet/harmony-fleet-deploy/src/bin/fleet_release.rs
Normal file
@@ -0,0 +1,270 @@
|
|||||||
|
//! `harmony-fleet-release` — build + push the image + helm chart for
|
||||||
|
//! one fleet component at a tagged version.
|
||||||
|
//!
|
||||||
|
//! Invoked by the per-component `release.sh` wrappers (which prefill
|
||||||
|
//! `--component`) and by the `.gitea/workflows/harmony-fleet-*.yaml`
|
||||||
|
//! CI jobs. The same binary is the developer-laptop fallback during
|
||||||
|
//! outages.
|
||||||
|
//!
|
||||||
|
//! Steps, in order, for the selected component:
|
||||||
|
//!
|
||||||
|
//! 1. `docker build` the canonical multi-stage Dockerfile against the
|
||||||
|
//! workspace root, tagged
|
||||||
|
//! `<registry>/<project>/<image>:<version>`.
|
||||||
|
//! 2. `docker push` that image.
|
||||||
|
//! 3. Hydrate the helm chart for the component, with the pushed image
|
||||||
|
//! reference baked into the manifest and `chart_version` set to
|
||||||
|
//! `<version>` so the OCI chart artifact lands at the matching tag.
|
||||||
|
//! 4. `helm package` the chart directory into a tgz.
|
||||||
|
//! 5. `helm push` the tgz to `oci://<registry>/<project>`.
|
||||||
|
//!
|
||||||
|
//! `docker` (not `podman`) because the existing build scripts and the
|
||||||
|
//! gitea `dind` runner both use it. `docker login <registry>` and
|
||||||
|
//! `helm registry login <registry>` are expected to have been run by
|
||||||
|
//! the caller (CI's `docker/login-action`, dev's manual login).
|
||||||
|
//!
|
||||||
|
//! Adding a new component is a new variant on [`Component`] plus a
|
||||||
|
//! match arm in [`Component::spec`] — no new binary, no new CLI for
|
||||||
|
//! users to learn.
|
||||||
|
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::process::{Command, Stdio};
|
||||||
|
|
||||||
|
use anyhow::{Context, Result, bail};
|
||||||
|
use clap::{Parser, ValueEnum};
|
||||||
|
use harmony_fleet_deploy::operator::chart::{ChartOptions, build_chart};
|
||||||
|
use tracing::info;
|
||||||
|
|
||||||
|
#[derive(Parser, Debug)]
|
||||||
|
#[command(
|
||||||
|
name = "harmony-fleet-release",
|
||||||
|
about = "Build and push a fleet component's image + helm chart for a tagged release"
|
||||||
|
)]
|
||||||
|
struct Cli {
|
||||||
|
johnride
commented
We will have to make this simpler, we should not have to rebuild a new cli for every component of every app using harmony. This should be a macro call or a simple function call, I don't know yet but definitely not a 270 lines file. We will have to make this simpler, we should not have to rebuild a new cli for every component of every app using harmony. This should be a macro call or a simple function call, I don't know yet but definitely not a 270 lines file.
|
|||||||
|
/// Which fleet component to release. Only `operator` is wired up
|
||||||
|
/// today; `agent` and `nats-callout` are reserved for their
|
||||||
|
/// upcoming pipelines and will bail with an unimplemented error
|
||||||
|
/// until then.
|
||||||
|
#[arg(long, value_enum)]
|
||||||
|
component: Component,
|
||||||
|
|
||||||
|
/// Registry host, e.g. `hub.nationtech.io`.
|
||||||
|
#[arg(long)]
|
||||||
|
registry: String,
|
||||||
|
|
||||||
|
/// Version tag for both image and chart, e.g. `v0.1.0`. A leading
|
||||||
|
/// `v` is stripped from the chart-version (helm rejects non-semver
|
||||||
|
/// chart versions, and the OCI tag stays whatever was passed).
|
||||||
|
#[arg(long)]
|
||||||
|
version: String,
|
||||||
|
|
||||||
|
/// Project/namespace under the registry. Both image and chart
|
||||||
|
/// land under this path.
|
||||||
|
#[arg(long, default_value = "harmony")]
|
||||||
|
project: String,
|
||||||
|
|
||||||
|
/// Build the image and package the chart but skip both pushes.
|
||||||
|
/// Useful for local smoke-tests on k3d (sideload the image, helm
|
||||||
|
/// install the local tgz) without polluting the production
|
||||||
|
/// registry. CI never sets this.
|
||||||
|
#[arg(long)]
|
||||||
|
no_push: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The set of fleet components that a release can target. App-scoped
|
||||||
|
/// CLI per ADR-023: one binary covers every component behind this
|
||||||
|
/// flag, rather than a binary per component.
|
||||||
|
#[derive(Copy, Clone, Debug, ValueEnum)]
|
||||||
|
enum Component {
|
||||||
|
Operator,
|
||||||
|
/// Reserved — agent release pipeline not wired up yet.
|
||||||
|
Agent,
|
||||||
|
/// Reserved — nats-callout release pipeline not wired up yet.
|
||||||
|
#[value(name = "nats-callout")]
|
||||||
|
NatsCallout,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Per-component release recipe: where the image's Dockerfile lives
|
||||||
|
/// (relative to the workspace root) and what to name the published
|
||||||
|
/// image. Chart hydration is component-specific too; see
|
||||||
|
/// [`hydrate_chart`].
|
||||||
|
struct ComponentSpec {
|
||||||
|
image_name: &'static str,
|
||||||
|
dockerfile: &'static str,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Component {
|
||||||
|
fn spec(self) -> Result<ComponentSpec> {
|
||||||
|
match self {
|
||||||
|
Component::Operator => Ok(ComponentSpec {
|
||||||
|
image_name: "harmony-fleet-operator",
|
||||||
|
dockerfile: "fleet/harmony-fleet-operator/Dockerfile",
|
||||||
|
}),
|
||||||
|
Component::Agent => bail!(
|
||||||
|
"agent release pipeline is not wired up yet — see \
|
||||||
|
ROADMAP/fleet_platform/ci_cd_setup.md"
|
||||||
|
),
|
||||||
|
Component::NatsCallout => bail!(
|
||||||
|
"nats-callout release pipeline is not wired up yet — see \
|
||||||
|
ROADMAP/fleet_platform/ci_cd_setup.md"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main() -> Result<()> {
|
||||||
|
// Default to info so the release progress shows up without
|
||||||
|
// requiring RUST_LOG to be set; explicit RUST_LOG overrides.
|
||||||
|
tracing_subscriber::fmt()
|
||||||
|
.with_env_filter(
|
||||||
|
tracing_subscriber::EnvFilter::try_from_default_env()
|
||||||
|
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")),
|
||||||
|
)
|
||||||
|
.with_writer(std::io::stderr)
|
||||||
|
.init();
|
||||||
|
|
||||||
|
let cli = Cli::parse();
|
||||||
|
let spec = cli.component.spec()?;
|
||||||
|
|
||||||
|
let workspace = workspace_root();
|
||||||
|
let image_ref = format!(
|
||||||
|
"{}/{}/{}:{}",
|
||||||
|
cli.registry, cli.project, spec.image_name, cli.version
|
||||||
|
);
|
||||||
|
let oci_repo = format!("oci://{}/{}", cli.registry, cli.project);
|
||||||
|
|
||||||
|
info!("docker build {image_ref}");
|
||||||
|
docker_build(&workspace, spec.dockerfile, &image_ref)?;
|
||||||
|
|
||||||
|
if cli.no_push {
|
||||||
|
info!("skipping docker push (--no-push)");
|
||||||
|
} else {
|
||||||
|
info!("docker push {image_ref}");
|
||||||
|
docker_push(&image_ref)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
info!("generate chart (image={image_ref})");
|
||||||
|
// Keep the tempdir alive for the lifetime of main so the tgz path
|
||||||
|
// is still on disk when --no-push prints it for the caller.
|
||||||
|
let tmp = tempfile::tempdir().context("creating chart tempdir")?;
|
||||||
|
let chart_dir = hydrate_chart(cli.component, &image_ref, &cli.version, tmp.path())?;
|
||||||
|
|
||||||
|
info!("helm package {}", chart_dir.display());
|
||||||
|
let tgz = helm_package(&chart_dir, tmp.path())?;
|
||||||
|
|
||||||
|
if cli.no_push {
|
||||||
|
// Move the tgz out of the tempdir so it survives this process
|
||||||
|
// — otherwise the tempdir drop deletes it before the caller
|
||||||
|
// can `helm install` from it.
|
||||||
|
let dst = std::env::current_dir()?
|
||||||
|
.join(tgz.file_name().context("packaged chart has no filename")?);
|
||||||
|
std::fs::copy(&tgz, &dst).context("copying packaged chart to CWD")?;
|
||||||
|
info!(image = %image_ref, chart = %dst.display(), "built (no push)");
|
||||||
|
} else {
|
||||||
|
info!("helm push {} {oci_repo}", tgz.display());
|
||||||
|
helm_push(&tgz, &oci_repo)?;
|
||||||
|
info!(
|
||||||
|
image = %image_ref,
|
||||||
|
chart = %format!("{oci_repo}/{}:{}", spec.image_name, chart_version(&cli.version)),
|
||||||
|
"released"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Component-specific chart hydration. Each component owns its own
|
||||||
|
/// `build_chart`-style entry point in `harmony_fleet_deploy`; this
|
||||||
|
/// match keeps the release binary the only place that needs to know
|
||||||
|
/// "which component → which chart builder".
|
||||||
|
fn hydrate_chart(
|
||||||
|
component: Component,
|
||||||
|
image_ref: &str,
|
||||||
|
version: &str,
|
||||||
|
output_dir: &Path,
|
||||||
|
) -> Result<PathBuf> {
|
||||||
|
match component {
|
||||||
|
Component::Operator => build_chart(&ChartOptions {
|
||||||
|
output_dir: output_dir.to_path_buf(),
|
||||||
|
image: image_ref.to_string(),
|
||||||
|
image_pull_policy: "IfNotPresent".to_string(),
|
||||||
|
chart_version: Some(chart_version(version)),
|
||||||
|
..ChartOptions::default()
|
||||||
|
})
|
||||||
|
.context("building operator chart"),
|
||||||
|
// Agent and nats-callout already bailed in Component::spec();
|
||||||
|
// reaching here would mean a bug.
|
||||||
|
Component::Agent | Component::NatsCallout => {
|
||||||
|
unreachable!("Component::spec() returns Err for unwired components")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn docker_build(workspace: &Path, dockerfile: &str, image_ref: &str) -> Result<()> {
|
||||||
|
run(Command::new("docker")
|
||||||
|
.args(["build", "-f", dockerfile, "-t", image_ref, "."])
|
||||||
|
.current_dir(workspace))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn docker_push(image_ref: &str) -> Result<()> {
|
||||||
|
run(Command::new("docker").args(["push", image_ref]))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn helm_package(chart_dir: &Path, out_dir: &Path) -> Result<PathBuf> {
|
||||||
|
let output = Command::new("helm")
|
||||||
|
.args([
|
||||||
|
"package",
|
||||||
|
chart_dir.to_str().context("chart_dir path not utf-8")?,
|
||||||
|
"-d",
|
||||||
|
out_dir.to_str().context("out_dir path not utf-8")?,
|
||||||
|
])
|
||||||
|
.stderr(Stdio::inherit())
|
||||||
|
.output()
|
||||||
|
.context("spawning helm package")?;
|
||||||
|
if !output.status.success() {
|
||||||
|
bail!("helm package failed (status {})", output.status);
|
||||||
|
}
|
||||||
|
// helm prints the absolute path of the produced tgz on stdout
|
||||||
|
// ("Successfully packaged chart and saved it to: /path/to.tgz").
|
||||||
|
// The final whitespace-separated token is that path.
|
||||||
|
let stdout = String::from_utf8(output.stdout).context("helm package stdout not utf-8")?;
|
||||||
|
let tgz = stdout
|
||||||
|
.split_whitespace()
|
||||||
|
.last()
|
||||||
|
.map(PathBuf::from)
|
||||||
|
.context("helm package produced no path on stdout")?;
|
||||||
|
Ok(tgz)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn helm_push(tgz: &Path, oci_repo: &str) -> Result<()> {
|
||||||
|
run(Command::new("helm").args([
|
||||||
|
"push",
|
||||||
|
tgz.to_str().context("tgz path not utf-8")?,
|
||||||
|
oci_repo,
|
||||||
|
]))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run(cmd: &mut Command) -> Result<()> {
|
||||||
|
let status = cmd
|
||||||
|
.stdout(Stdio::inherit())
|
||||||
|
.stderr(Stdio::inherit())
|
||||||
|
.status()
|
||||||
|
.with_context(|| format!("spawning {:?}", cmd.get_program()))?;
|
||||||
|
if !status.success() {
|
||||||
|
bail!("{:?} failed (status {})", cmd.get_program(), status);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn workspace_root() -> PathBuf {
|
||||||
|
// CARGO_MANIFEST_DIR is fleet/harmony-fleet-deploy → workspace is two up.
|
||||||
|
let root = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||||
|
.join("..")
|
||||||
|
.join("..");
|
||||||
|
root.canonicalize().unwrap_or(root)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helm chart versions must be SemVer (no leading `v`). The OCI image
|
||||||
|
// tag is left untouched — Harbor accepts arbitrary tag strings.
|
||||||
|
fn chart_version(tag: &str) -> String {
|
||||||
|
tag.strip_prefix('v').unwrap_or(tag).to_string()
|
||||||
|
}
|
||||||
@@ -28,6 +28,7 @@
|
|||||||
//! the role of the smoke test for now.
|
//! the role of the smoke test for now.
|
||||||
|
|
||||||
pub mod agent;
|
pub mod agent;
|
||||||
|
pub mod argo;
|
||||||
pub mod companion;
|
pub mod companion;
|
||||||
pub mod nats;
|
pub mod nats;
|
||||||
pub mod operator;
|
pub mod operator;
|
||||||
|
|||||||
@@ -1,29 +1,16 @@
|
|||||||
//! `harmony-fleet-deploy` — deploy the fleet stack to a cluster.
|
//! `harmony-fleet-deploy` — deploy the fleet stack to a cluster.
|
||||||
//!
|
|
||||||
//! Built on `harmony_cli::run` like the rest of the workspace's
|
|
||||||
//! deploy binaries (`harmony_agent_deploy`, …). The CLI offers a
|
|
||||||
//! minimal env-driven config and hands off to `harmony_cli`, which
|
|
||||||
//! provides the standard `--filter` / `--all` / `--list` selection
|
|
||||||
//! surface; pick a single component by filter or run them all.
|
|
||||||
//!
|
|
||||||
//! Topology default is [`K8sAnywhereTopology::from_env`] — local k3d
|
|
||||||
//! when `HARMONY_USE_LOCAL_K3D` flips, otherwise whatever cluster
|
|
||||||
//! `KUBECONFIG` points at. Per ADR-023 the binary's full set of
|
|
||||||
//! topologies is compile-time; for the moment only `K8sAnywhere` is
|
|
||||||
//! wired in, with `K8sBareTopology` planned for the next iteration.
|
|
||||||
//!
|
|
||||||
//! What the binary owns: assembling the Scores from environment
|
|
||||||
//! input. What it does **not** own: any handrolled k8s manifests,
|
|
||||||
//! any imperative bring-up loops, any auth secret rendering — that
|
|
||||||
//! all sits inside the `*Score` impls in [`harmony_fleet_deploy`].
|
|
||||||
|
|
||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
use clap::Parser;
|
use clap::Parser;
|
||||||
use harmony::inventory::Inventory;
|
use harmony::inventory::Inventory;
|
||||||
|
use harmony::modules::application::features::ArgoHelmScore;
|
||||||
use harmony::score::Score;
|
use harmony::score::Score;
|
||||||
use harmony::topology::K8sAnywhereTopology;
|
use harmony::topology::K8sAnywhereTopology;
|
||||||
|
use harmony_cli::Args as HarmonyCliArgs;
|
||||||
use harmony_fleet_deploy::nats::UserPassCredentials;
|
use harmony_fleet_deploy::nats::UserPassCredentials;
|
||||||
use harmony_fleet_deploy::{FleetAgentScore, FleetNatsScore, FleetOperatorScore, agent::PodTarget};
|
use harmony_fleet_deploy::{
|
||||||
|
FleetAgentScore, FleetNatsScore, FleetOperatorScore, agent::PodTarget, argo,
|
||||||
|
};
|
||||||
|
|
||||||
#[derive(Parser, Debug)]
|
#[derive(Parser, Debug)]
|
||||||
#[command(
|
#[command(
|
||||||
@@ -88,6 +75,41 @@ struct CliConfig {
|
|||||||
/// NATS device password. Required.
|
/// NATS device password. Required.
|
||||||
#[arg(long, env = "HARMONY_FLEET_NATS_DEVICE_PASS")]
|
#[arg(long, env = "HARMONY_FLEET_NATS_DEVICE_PASS")]
|
||||||
nats_device_pass: Option<String>,
|
nats_device_pass: Option<String>,
|
||||||
|
|
||||||
|
/// Deploy the operator via Argo CD instead of harmony-direct
|
||||||
|
/// helm. Re-run with a different `--operator-chart-version` to
|
||||||
|
/// upgrade or roll back.
|
||||||
|
#[arg(long, env = "HARMONY_FLEET_USE_ARGO")]
|
||||||
|
use_argo: bool,
|
||||||
|
|
||||||
|
#[arg(
|
||||||
|
long,
|
||||||
|
env = "HARMONY_FLEET_OPERATOR_CHART_REGISTRY",
|
||||||
|
default_value = "hub.nationtech.io"
|
||||||
|
)]
|
||||||
|
operator_chart_registry: String,
|
||||||
|
|
||||||
|
#[arg(
|
||||||
|
long,
|
||||||
|
env = "HARMONY_FLEET_OPERATOR_CHART_PROJECT",
|
||||||
|
default_value = "harmony"
|
||||||
|
)]
|
||||||
|
operator_chart_project: String,
|
||||||
|
|
||||||
|
#[arg(
|
||||||
|
long,
|
||||||
|
env = "HARMONY_FLEET_OPERATOR_CHART_VERSION",
|
||||||
|
default_value = "0.0.1"
|
||||||
|
)]
|
||||||
|
operator_chart_version: String,
|
||||||
|
|
||||||
|
#[arg(long, env = "HARMONY_FLEET_ARGO_NAMESPACE", default_value = "argocd")]
|
||||||
|
argo_namespace: String,
|
||||||
|
|
||||||
|
// Flattened so a single argv parse covers both this CLI and
|
||||||
|
// harmony_cli's `--yes` / `--filter` / `--all` / etc.
|
||||||
|
#[command(flatten)]
|
||||||
|
harmony_cli: HarmonyCliArgs,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl CliConfig {
|
impl CliConfig {
|
||||||
@@ -124,10 +146,6 @@ async fn main() -> Result<()> {
|
|||||||
let device_user = creds.device_user.clone();
|
let device_user = creds.device_user.clone();
|
||||||
let device_pass = creds.device_pass.clone();
|
let device_pass = creds.device_pass.clone();
|
||||||
let nats = FleetNatsScore::user_pass(cli.namespace.clone(), cli.nats_node_port, creds);
|
let nats = FleetNatsScore::user_pass(cli.namespace.clone(), cli.nats_node_port, creds);
|
||||||
let operator = FleetOperatorScore::new()
|
|
||||||
.namespace(cli.namespace.clone())
|
|
||||||
.image(cli.operator_image.clone())
|
|
||||||
.nats_url(nats.in_cluster_url());
|
|
||||||
let agent = FleetAgentScore::pod(
|
let agent = FleetAgentScore::pod(
|
||||||
cli.namespace.clone(),
|
cli.namespace.clone(),
|
||||||
PodTarget::user_pass(
|
PodTarget::user_pass(
|
||||||
@@ -139,14 +157,34 @@ async fn main() -> Result<()> {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
let scores: Vec<Box<dyn Score<K8sAnywhereTopology>>> =
|
// `--use-argo` swaps the operator path only. NATS + agent stay
|
||||||
vec![Box::new(nats), Box::new(operator), Box::new(agent)];
|
// direct in v1.
|
||||||
|
let scores: Vec<Box<dyn Score<K8sAnywhereTopology>>> = if cli.use_argo {
|
||||||
|
let argo = ArgoHelmScore {
|
||||||
|
namespace: cli.argo_namespace.clone(),
|
||||||
|
openshift: false,
|
||||||
|
ingress_class_name: None,
|
||||||
|
argo_apps: vec![argo::operator_application(
|
||||||
|
&cli.namespace,
|
||||||
|
&cli.operator_chart_registry,
|
||||||
|
&cli.operator_chart_project,
|
||||||
|
&cli.operator_chart_version,
|
||||||
|
)],
|
||||||
|
};
|
||||||
|
vec![Box::new(nats), Box::new(argo), Box::new(agent)]
|
||||||
|
} else {
|
||||||
|
let operator = FleetOperatorScore::new()
|
||||||
|
.namespace(cli.namespace.clone())
|
||||||
|
.image(cli.operator_image.clone())
|
||||||
|
.nats_url(nats.in_cluster_url());
|
||||||
|
vec![Box::new(nats), Box::new(operator), Box::new(agent)]
|
||||||
|
};
|
||||||
|
|
||||||
harmony_cli::run(
|
harmony_cli::run(
|
||||||
Inventory::autoload(),
|
Inventory::autoload(),
|
||||||
K8sAnywhereTopology::from_env(),
|
K8sAnywhereTopology::from_env(),
|
||||||
scores,
|
scores,
|
||||||
None,
|
Some(cli.harmony_cli),
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| anyhow::anyhow!("{e}"))
|
.map_err(|e| anyhow::anyhow!("{e}"))
|
||||||
|
|||||||
@@ -51,11 +51,6 @@ pub struct ChartOptions {
|
|||||||
/// sideloaded k3d images, `Never` if the image must already be
|
/// sideloaded k3d images, `Never` if the image must already be
|
||||||
/// present.
|
/// present.
|
||||||
pub image_pull_policy: String,
|
pub image_pull_policy: String,
|
||||||
/// Namespace the operator Deployment runs in. `helm install
|
|
||||||
/// --create-namespace` creates it if absent; the chart itself
|
|
||||||
/// doesn't include a Namespace resource so the chart stays
|
|
||||||
/// reusable across namespaces.
|
|
||||||
pub namespace: String,
|
|
||||||
/// NATS URL the operator connects to. For in-cluster NATS at
|
/// NATS URL the operator connects to. For in-cluster NATS at
|
||||||
/// `fleet-nats.fleet-system` the default `nats://fleet-nats.fleet-system:4222`
|
/// `fleet-nats.fleet-system` the default `nats://fleet-nats.fleet-system:4222`
|
||||||
/// works with no config.
|
/// works with no config.
|
||||||
@@ -67,6 +62,12 @@ pub struct ChartOptions {
|
|||||||
/// Secret entirely and lets the operator connect to NATS without
|
/// Secret entirely and lets the operator connect to NATS without
|
||||||
/// auth — only sensible when there's no callout in front of NATS.
|
/// auth — only sensible when there's no callout in front of NATS.
|
||||||
pub credentials: Option<OperatorCredentials>,
|
pub credentials: Option<OperatorCredentials>,
|
||||||
|
/// Chart-level version written into `Chart.yaml`. `None` falls back
|
||||||
|
/// to the deploy crate's `CARGO_PKG_VERSION` — fine for in-process
|
||||||
|
/// uses (e2e harness, runtime operator Score). The release binary
|
||||||
|
/// sets this to the released tag so the OCI chart artifact lands
|
||||||
|
/// at `…/harmony-fleet-operator:<tag>` matching the image tag.
|
||||||
|
pub chart_version: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// What the operator pod needs to authenticate to NATS via the auth
|
/// What the operator pod needs to authenticate to NATS via the auth
|
||||||
@@ -107,10 +108,14 @@ impl Default for ChartOptions {
|
|||||||
output_dir: PathBuf::from("/tmp/fleet-load-test/chart"),
|
output_dir: PathBuf::from("/tmp/fleet-load-test/chart"),
|
||||||
image: "localhost/harmony-fleet-operator:latest".to_string(),
|
image: "localhost/harmony-fleet-operator:latest".to_string(),
|
||||||
image_pull_policy: "IfNotPresent".to_string(),
|
image_pull_policy: "IfNotPresent".to_string(),
|
||||||
namespace: "fleet-system".to_string(),
|
// Deliberately uses a non-fleet-specific in-cluster DNS
|
||||||
nats_url: "nats://fleet-nats.fleet-system:4222".to_string(),
|
// assuming NATS sits in the same namespace as the operator;
|
||||||
|
// the e2e harness and production overrides set this
|
||||||
|
// explicitly when their NATS lives elsewhere.
|
||||||
|
nats_url: "nats://fleet-nats:4222".to_string(),
|
||||||
log_level: "info,kube_runtime=warn".to_string(),
|
log_level: "info,kube_runtime=warn".to_string(),
|
||||||
credentials: None,
|
credentials: None,
|
||||||
|
chart_version: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -131,10 +136,17 @@ pub fn build_chart(opts: &ChartOptions) -> Result<PathBuf> {
|
|||||||
std::fs::create_dir_all(&opts.output_dir)
|
std::fs::create_dir_all(&opts.output_dir)
|
||||||
.with_context(|| format!("creating {:?}", opts.output_dir))?;
|
.with_context(|| format!("creating {:?}", opts.output_dir))?;
|
||||||
|
|
||||||
let mut chart = HelmChart::new(
|
// `HelmChart::new(name, app_version)` only sets appVersion — the
|
||||||
RELEASE_NAME.to_string(),
|
// chart-level `version` field defaults to `"0.1.0"` and has to be
|
||||||
env!("CARGO_PKG_VERSION").to_string(),
|
// assigned directly. For a release artifact we want both to track
|
||||||
);
|
// the released tag (one tag → one image + chart at the same
|
||||||
|
// version), so set both.
|
||||||
|
let chart_version = opts
|
||||||
|
.chart_version
|
||||||
|
.clone()
|
||||||
|
.unwrap_or_else(|| env!("CARGO_PKG_VERSION").to_string());
|
||||||
|
let mut chart = HelmChart::new(RELEASE_NAME.to_string(), chart_version.clone());
|
||||||
|
chart.version = chart_version;
|
||||||
chart.description = "IoT operator — Deployment CRD → NATS KV".to_string();
|
chart.description = "IoT operator — Deployment CRD → NATS KV".to_string();
|
||||||
|
|
||||||
chart.add_resource(HelmResourceKind::Crd(crd_with_keep_annotation(
|
chart.add_resource(HelmResourceKind::Crd(crd_with_keep_annotation(
|
||||||
@@ -144,12 +156,18 @@ pub fn build_chart(opts: &ChartOptions) -> Result<PathBuf> {
|
|||||||
Device::crd(),
|
Device::crd(),
|
||||||
)));
|
)));
|
||||||
|
|
||||||
chart.add_resource(HelmResourceKind::ServiceAccount(service_account(
|
chart.add_resource(HelmResourceKind::ServiceAccount(service_account()));
|
||||||
&opts.namespace,
|
|
||||||
)));
|
|
||||||
chart.add_resource(HelmResourceKind::ClusterRole(cluster_role()));
|
chart.add_resource(HelmResourceKind::ClusterRole(cluster_role()));
|
||||||
|
// The CRB's subject must reference the ServiceAccount's namespace.
|
||||||
|
// Since the chart itself is namespace-neutral (helm assigns the
|
||||||
|
// release namespace to the SA + Deployment at install time), we
|
||||||
|
// emit a literal helm template token so helm substitutes the
|
||||||
|
// release namespace at the same moment. This is the one chart
|
||||||
|
// resource that can't be made namespace-neutral by simply omitting
|
||||||
|
// the field — `subjects[].namespace` is part of the resource
|
||||||
|
// identity and must point somewhere concrete after rendering.
|
||||||
chart.add_resource(HelmResourceKind::ClusterRoleBinding(cluster_role_binding(
|
chart.add_resource(HelmResourceKind::ClusterRoleBinding(cluster_role_binding(
|
||||||
&opts.namespace,
|
"{{ .Release.Namespace }}",
|
||||||
)));
|
)));
|
||||||
// Secret intentionally NOT included in the on-disk helm chart —
|
// Secret intentionally NOT included in the on-disk helm chart —
|
||||||
// credentials are operator-environment-specific and out of scope
|
// credentials are operator-environment-specific and out of scope
|
||||||
@@ -175,10 +193,13 @@ pub fn operator_secret(opts: &ChartOptions) -> Option<Secret> {
|
|||||||
SECRET_KEY_CREDENTIALS_TOML.to_string(),
|
SECRET_KEY_CREDENTIALS_TOML.to_string(),
|
||||||
ByteString(creds.credentials_toml.as_bytes().to_vec()),
|
ByteString(creds.credentials_toml.as_bytes().to_vec()),
|
||||||
);
|
);
|
||||||
|
// Namespace deliberately omitted — the caller passes the target
|
||||||
|
// namespace to `K8sResourceScore::single`, which injects it at
|
||||||
|
// apply time. Keeps the Secret manifest reusable across
|
||||||
|
// environments without baking a namespace into source.
|
||||||
Some(Secret {
|
Some(Secret {
|
||||||
metadata: ObjectMeta {
|
metadata: ObjectMeta {
|
||||||
name: Some(SECRET_NAME.to_string()),
|
name: Some(SECRET_NAME.to_string()),
|
||||||
namespace: Some(opts.namespace.clone()),
|
|
||||||
..Default::default()
|
..Default::default()
|
||||||
},
|
},
|
||||||
data: Some(data),
|
data: Some(data),
|
||||||
@@ -201,11 +222,13 @@ fn crd_with_keep_annotation(mut crd: CustomResourceDefinition) -> CustomResource
|
|||||||
crd
|
crd
|
||||||
}
|
}
|
||||||
|
|
||||||
fn service_account(namespace: &str) -> ServiceAccount {
|
// Namespace-neutral: helm fills in the release namespace at install
|
||||||
|
// time, and the direct-apply path (`K8sResourceScore::single(sa,
|
||||||
|
// Some(ns))`) injects the namespace through its second argument.
|
||||||
|
fn service_account() -> ServiceAccount {
|
||||||
ServiceAccount {
|
ServiceAccount {
|
||||||
metadata: ObjectMeta {
|
metadata: ObjectMeta {
|
||||||
name: Some(SERVICE_ACCOUNT.to_string()),
|
name: Some(SERVICE_ACCOUNT.to_string()),
|
||||||
namespace: Some(namespace.to_string()),
|
|
||||||
..Default::default()
|
..Default::default()
|
||||||
},
|
},
|
||||||
..Default::default()
|
..Default::default()
|
||||||
@@ -325,10 +348,14 @@ fn operator_deployment(opts: &ChartOptions) -> K8sDeployment {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Namespace deliberately omitted — same rationale as the
|
||||||
|
// ServiceAccount: helm fills in the release namespace at install
|
||||||
|
// time, and the direct-apply path injects it via
|
||||||
|
// `K8sResourceScore::single(.., Some(ns))`. Keeps the chart
|
||||||
|
// reusable without baking a namespace into source.
|
||||||
K8sDeployment {
|
K8sDeployment {
|
||||||
metadata: ObjectMeta {
|
metadata: ObjectMeta {
|
||||||
name: Some(RELEASE_NAME.to_string()),
|
name: Some(RELEASE_NAME.to_string()),
|
||||||
namespace: Some(opts.namespace.clone()),
|
|
||||||
labels: Some(match_labels.clone()),
|
labels: Some(match_labels.clone()),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
},
|
},
|
||||||
@@ -364,14 +391,20 @@ fn operator_deployment(opts: &ChartOptions) -> K8sDeployment {
|
|||||||
|
|
||||||
// Re-export the manifest builders so the e2e bring-up can apply the
|
// Re-export the manifest builders so the e2e bring-up can apply the
|
||||||
// operator inline (Score-style) without re-implementing the manifests.
|
// operator inline (Score-style) without re-implementing the manifests.
|
||||||
pub fn build_service_account(opts: &ChartOptions) -> ServiceAccount {
|
//
|
||||||
service_account(&opts.namespace)
|
// The SA + Deployment helpers return namespace-neutral manifests;
|
||||||
|
// callers inject the target namespace through `K8sResourceScore::single`.
|
||||||
|
// The CRB takes the SA's namespace as an explicit argument because the
|
||||||
|
// CRB subject must reference a concrete namespace — there's no
|
||||||
|
// kube-side "current namespace" for cluster-scoped resources.
|
||||||
|
pub fn build_service_account() -> ServiceAccount {
|
||||||
|
service_account()
|
||||||
}
|
}
|
||||||
pub fn build_cluster_role() -> ClusterRole {
|
pub fn build_cluster_role() -> ClusterRole {
|
||||||
cluster_role()
|
cluster_role()
|
||||||
}
|
}
|
||||||
pub fn build_cluster_role_binding(opts: &ChartOptions) -> ClusterRoleBinding {
|
pub fn build_cluster_role_binding(subject_namespace: &str) -> ClusterRoleBinding {
|
||||||
cluster_role_binding(&opts.namespace)
|
cluster_role_binding(subject_namespace)
|
||||||
}
|
}
|
||||||
pub fn build_operator_deployment(opts: &ChartOptions) -> K8sDeployment {
|
pub fn build_operator_deployment(opts: &ChartOptions) -> K8sDeployment {
|
||||||
operator_deployment(opts)
|
operator_deployment(opts)
|
||||||
|
|||||||
@@ -65,7 +65,9 @@ impl FleetOperatorScore {
|
|||||||
pub fn new() -> Self {
|
pub fn new() -> Self {
|
||||||
let defaults = ChartOptions::default();
|
let defaults = ChartOptions::default();
|
||||||
Self {
|
Self {
|
||||||
namespace: defaults.namespace,
|
// FleetOperatorScore's own default; the chart itself is
|
||||||
|
// namespace-neutral. Callers override via `.namespace(..)`.
|
||||||
|
namespace: "fleet-system".to_string(),
|
||||||
release_name: "harmony-fleet-operator".to_string(),
|
release_name: "harmony-fleet-operator".to_string(),
|
||||||
image: defaults.image,
|
image: defaults.image,
|
||||||
image_pull_policy: defaults.image_pull_policy,
|
image_pull_policy: defaults.image_pull_policy,
|
||||||
@@ -146,10 +148,10 @@ impl<T: Topology + HelmCommand + K8sclient> Interpret<T> for FleetOperatorInterp
|
|||||||
output_dir: tmp.path().to_path_buf(),
|
output_dir: tmp.path().to_path_buf(),
|
||||||
image: self.score.image.clone(),
|
image: self.score.image.clone(),
|
||||||
image_pull_policy: self.score.image_pull_policy.clone(),
|
image_pull_policy: self.score.image_pull_policy.clone(),
|
||||||
namespace: self.score.namespace.clone(),
|
|
||||||
nats_url: self.score.nats_url.clone(),
|
nats_url: self.score.nats_url.clone(),
|
||||||
log_level: self.score.log_level.clone(),
|
log_level: self.score.log_level.clone(),
|
||||||
credentials: self.score.credentials.clone(),
|
credentials: self.score.credentials.clone(),
|
||||||
|
chart_version: None,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Apply the credentials Secret BEFORE the helm install. The
|
// Apply the credentials Secret BEFORE the helm install. The
|
||||||
|
|||||||
32
fleet/harmony-fleet-operator/release.sh
Executable file
32
fleet/harmony-fleet-operator/release.sh
Executable file
@@ -0,0 +1,32 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Build + push the harmony-fleet-operator image and helm chart at one
|
||||||
|
# matching version. Invoked locally and by .gitea CI.
|
||||||
|
#
|
||||||
|
# ./fleet/harmony-fleet-operator/release.sh <registry> <version>
|
||||||
|
# ./fleet/harmony-fleet-operator/release.sh hub.nationtech.io v0.1.0
|
||||||
|
#
|
||||||
|
# Expects `docker login <registry>` and `helm registry login <registry>`
|
||||||
|
# to have already been run; both are cheap one-liners and let CI use the
|
||||||
|
# same script unchanged.
|
||||||
|
#
|
||||||
|
# This is the operator-specific 1-line wrapper around the app-scoped
|
||||||
|
# `harmony-fleet-release` binary. The wrapper exists so a tag like
|
||||||
|
# `harmony-fleet-operator-v0.1.0` routes straight to the right
|
||||||
|
# `--component` without the caller having to remember the flag. Agent
|
||||||
|
# and nats-callout will get sibling `release.sh` scripts the same way.
|
||||||
|
#
|
||||||
|
# All heavy lifting (docker build/push, chart hydration, helm
|
||||||
|
# package/push) is in the binary; this script just selects the
|
||||||
|
# component.
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
REGISTRY="${1:?usage: release.sh <registry> <version>}"
|
||||||
|
VERSION="${2:?usage: release.sh <registry> <version>}"
|
||||||
|
|
||||||
|
cd "$(dirname "$0")/../.."
|
||||||
|
|
||||||
|
exec cargo run --release -p harmony-fleet-deploy \
|
||||||
|
--bin harmony-fleet-release -- \
|
||||||
|
--component operator \
|
||||||
|
--registry "$REGISTRY" --version "$VERSION"
|
||||||
@@ -80,6 +80,11 @@ pub struct ArgoApplication {
|
|||||||
pub source: Source,
|
pub source: Source,
|
||||||
pub sync_policy: SyncPolicy,
|
pub sync_policy: SyncPolicy,
|
||||||
pub revision_history_limit: u32,
|
pub revision_history_limit: u32,
|
||||||
|
/// Cluster namespace the synced resources land in. `None` collapses
|
||||||
|
/// to the same namespace as the Application CR — fine when both are
|
||||||
|
/// `argocd`, wrong when the Application controls a chart in a
|
||||||
|
/// different namespace.
|
||||||
|
pub destination_namespace: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for ArgoApplication {
|
impl Default for ArgoApplication {
|
||||||
@@ -127,6 +132,7 @@ impl Default for ArgoApplication {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
revision_history_limit: 10,
|
revision_history_limit: 10,
|
||||||
|
destination_namespace: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -186,6 +192,7 @@ impl ArgoApplication {
|
|||||||
let default_ns = "argocd".to_string();
|
let default_ns = "argocd".to_string();
|
||||||
let namespace: &str =
|
let namespace: &str =
|
||||||
target_namespace.unwrap_or(self.namespace.as_ref().unwrap_or(&default_ns));
|
target_namespace.unwrap_or(self.namespace.as_ref().unwrap_or(&default_ns));
|
||||||
|
let destination = self.destination_namespace.as_deref().unwrap_or(namespace);
|
||||||
let project = &self.project;
|
let project = &self.project;
|
||||||
|
|
||||||
let yaml_str = format!(
|
let yaml_str = format!(
|
||||||
@@ -194,21 +201,12 @@ apiVersion: argoproj.io/v1alpha1
|
|||||||
kind: Application
|
kind: Application
|
||||||
metadata:
|
metadata:
|
||||||
name: {name}
|
name: {name}
|
||||||
# You'll usually want to add your resources to the argocd namespace.
|
|
||||||
namespace: {namespace}
|
namespace: {namespace}
|
||||||
spec:
|
spec:
|
||||||
# The project the application belongs to.
|
|
||||||
project: {project}
|
project: {project}
|
||||||
|
|
||||||
# Destination cluster and namespace to deploy the application
|
|
||||||
destination:
|
destination:
|
||||||
# cluster API URL
|
|
||||||
server: https://kubernetes.default.svc
|
server: https://kubernetes.default.svc
|
||||||
# or cluster name
|
namespace: {destination}
|
||||||
# name: in-cluster
|
|
||||||
# The namespace will only be set for namespace-scoped resources that have not set a value for .metadata.namespace
|
|
||||||
namespace: {namespace}
|
|
||||||
|
|
||||||
"#
|
"#
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -305,6 +303,7 @@ mod tests {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
revision_history_limit: 10,
|
revision_history_limit: 10,
|
||||||
|
destination_namespace: None,
|
||||||
};
|
};
|
||||||
|
|
||||||
let expected_yaml_output = r#"apiVersion: argoproj.io/v1alpha1
|
let expected_yaml_output = r#"apiVersion: argoproj.io/v1alpha1
|
||||||
|
|||||||
@@ -26,6 +26,8 @@ pub struct ArgoHelmScore {
|
|||||||
pub namespace: String,
|
pub namespace: String,
|
||||||
// TODO: remove and rely on topology (it now knows the flavor)
|
// TODO: remove and rely on topology (it now knows the flavor)
|
||||||
pub openshift: bool,
|
pub openshift: bool,
|
||||||
|
/// IngressClass for Argo's server Ingress. `None` → cluster default.
|
||||||
|
pub ingress_class_name: Option<String>,
|
||||||
pub argo_apps: Vec<ArgoApplication>,
|
pub argo_apps: Vec<ArgoApplication>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -109,7 +111,12 @@ impl<T: Topology + K8sclient + HelmCommand + Ingress> Interpret<T> for ArgoInter
|
|||||||
info!("ArgoCD will be installed : {must_install} . Current argocd status : {current:?} ");
|
info!("ArgoCD will be installed : {must_install} . Current argocd status : {current:?} ");
|
||||||
|
|
||||||
if must_install {
|
if must_install {
|
||||||
let helm_score = argo_helm_chart_score(&desired_ns, self.score.openshift, &domain);
|
let helm_score = argo_helm_chart_score(
|
||||||
|
&desired_ns,
|
||||||
|
self.score.openshift,
|
||||||
|
&domain,
|
||||||
|
self.score.ingress_class_name.as_deref(),
|
||||||
|
);
|
||||||
info!(
|
info!(
|
||||||
"Installing Argo CD via Helm into namespace '{}' ...",
|
"Installing Argo CD via Helm into namespace '{}' ...",
|
||||||
desired_ns
|
desired_ns
|
||||||
@@ -167,7 +174,17 @@ impl<T: Topology + K8sclient + HelmCommand + Ingress> Interpret<T> for ArgoInter
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn argo_helm_chart_score(namespace: &str, openshift: bool, domain: &str) -> HelmChartScore {
|
pub fn argo_helm_chart_score(
|
||||||
|
namespace: &str,
|
||||||
|
openshift: bool,
|
||||||
|
domain: &str,
|
||||||
|
ingress_class_name: Option<&str>,
|
||||||
|
) -> HelmChartScore {
|
||||||
|
// Empty IngressClass → cluster default kicks in (k3d/traefik, etc).
|
||||||
|
let ingress_class_name = ingress_class_name.unwrap_or("");
|
||||||
|
// `runAsUser: null` is for OKD's restricted-v2 SCC; on vanilla k8s
|
||||||
|
// it makes redis CrashLoop ("image will run as root").
|
||||||
|
let security_context_override = if openshift { "runAsUser: null" } else { "{}" };
|
||||||
let values = format!(
|
let values = format!(
|
||||||
r#"
|
r#"
|
||||||
# -- Create aggregated roles that extend existing cluster roles to interact with argo-cd resources
|
# -- Create aggregated roles that extend existing cluster roles to interact with argo-cd resources
|
||||||
@@ -198,8 +215,7 @@ global:
|
|||||||
## Used for ingresses, certificates, SSO, notifications, etc.
|
## Used for ingresses, certificates, SSO, notifications, etc.
|
||||||
domain: {domain}
|
domain: {domain}
|
||||||
|
|
||||||
securityContext:
|
securityContext: {security_context_override}
|
||||||
runAsUser: null
|
|
||||||
|
|
||||||
# -- Runtime class name for all components
|
# -- Runtime class name for all components
|
||||||
runtimeClassName: ""
|
runtimeClassName: ""
|
||||||
@@ -515,8 +531,7 @@ redis:
|
|||||||
serviceAccount:
|
serviceAccount:
|
||||||
create: true
|
create: true
|
||||||
|
|
||||||
securityContext:
|
securityContext: {security_context_override}
|
||||||
runAsUser: null
|
|
||||||
|
|
||||||
|
|
||||||
## Redis image
|
## Redis image
|
||||||
@@ -752,7 +767,7 @@ server:
|
|||||||
# nginx.ingress.kubernetes.io/ssl-passthrough: "true"
|
# nginx.ingress.kubernetes.io/ssl-passthrough: "true"
|
||||||
|
|
||||||
# -- Defines which ingress controller will implement the resource
|
# -- Defines which ingress controller will implement the resource
|
||||||
ingressClassName: "openshift-default"
|
ingressClassName: "{ingress_class_name}"
|
||||||
|
|
||||||
# -- Argo CD server hostname
|
# -- Argo CD server hostname
|
||||||
# @default -- `""` (defaults to global.domain)
|
# @default -- `""` (defaults to global.domain)
|
||||||
|
|||||||
@@ -185,6 +185,10 @@ impl<
|
|||||||
let score = ArgoHelmScore {
|
let score = ArgoHelmScore {
|
||||||
namespace: self.application.name().to_string(),
|
namespace: self.application.name().to_string(),
|
||||||
openshift: true,
|
openshift: true,
|
||||||
|
// Pre-existing call site is OpenShift-only (see
|
||||||
|
// the surrounding match arm); keep the OKD
|
||||||
|
// ingress class wired in to preserve behavior.
|
||||||
|
ingress_class_name: Some("openshift-default".to_string()),
|
||||||
argo_apps: vec![ArgoApplication::from(CDApplicationConfig {
|
argo_apps: vec![ArgoApplication::from(CDApplicationConfig {
|
||||||
version: Version::from("0.2.1").unwrap(),
|
version: Version::from("0.2.1").unwrap(),
|
||||||
helm_chart_repo_url: "hub.nationtech.io/harmony".to_string(),
|
helm_chart_repo_url: "hub.nationtech.io/harmony".to_string(),
|
||||||
|
|||||||
Reference in New Issue
Block a user
should be done in rust, we don't want to depend on bash inlined in yaml it's not only locking-in and not reusable it is brittle.