From 7ab5fd70416eb2e4094c2460015458003b3eb349 Mon Sep 17 00:00:00 2001 From: Jean-Gabriel Gill-Couture Date: Tue, 26 May 2026 13:59:29 -0400 Subject: [PATCH 1/6] ci: per-tag release pipeline for harmony-fleet-operator (image + chart) One git tag `harmony-fleet-operator-v*` now produces both the container image and a hydrated helm chart at the same version, pushed to hub.nationtech.io. release.sh is a 5-line wrapper around a new `harmony-fleet-operator-release` binary in harmony-fleet-deploy that orchestrates docker build/push, chart hydration via the existing `build_chart()`, and `helm package`/`helm push`. CI is reduced to a thin trigger calling the same script developers run locally. - chart.rs: ChartOptions gains an optional chart_version (None preserves the previous CARGO_PKG_VERSION behavior). - operator_release.rs: new binary. - release.sh: thin wrapper. - .gitea/workflows/harmony-fleet-operator.yaml: rewritten to fire on `harmony-fleet-operator-v*` tags (and workflow_dispatch with a manual version input). --- .gitea/workflows/harmony-fleet-operator.yaml | 82 ++++++--- examples/fleet_e2e_demo/src/lib.rs | 1 + fleet/harmony-fleet-deploy/Cargo.toml | 7 + .../src/bin/operator_release.rs | 167 ++++++++++++++++++ .../src/operator/chart.rs | 16 +- .../src/operator/score.rs | 1 + fleet/harmony-fleet-operator/release.sh | 25 +++ 7 files changed, 273 insertions(+), 26 deletions(-) create mode 100644 fleet/harmony-fleet-deploy/src/bin/operator_release.rs create mode 100755 fleet/harmony-fleet-operator/release.sh diff --git a/.gitea/workflows/harmony-fleet-operator.yaml b/.gitea/workflows/harmony-fleet-operator.yaml index 140d8c83..b287e8f5 100644 --- a/.gitea/workflows/harmony-fleet-operator.yaml +++ b/.gitea/workflows/harmony-fleet-operator.yaml @@ -1,12 +1,19 @@ -name: Build and push harmony-fleet-operator image +name: Release harmony-fleet-operator (image + chart) on: push: - branches: - - master + tags: + # 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: + inputs: + version: + description: 'Version tag to release (e.g. v0.1.0). Required for manual runs.' + required: true + type: string jobs: - build_and_push: + release: container: image: hub.nationtech.io/harmony/harmony_composer:latest runs-on: dind @@ -14,7 +21,7 @@ jobs: - name: Checkout code uses: actions/checkout@v4 - - name: Log in to hub.nationtech.io + - name: Log in to hub.nationtech.io (docker) uses: docker/login-action@v3 with: registry: hub.nationtech.io @@ -24,21 +31,52 @@ jobs: - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 - # Build context is the workspace root because the operator's - # Cargo.toml has `path = "../../harmony"` deps. The multi-stage - # Dockerfile runs `cargo build` itself inside a pinned rust - # image, so no host-side cargo step is needed. + # helm is not in harmony_composer:latest at time of writing; pull + # the official installer. One-shot, no apt source needed. # - # TODO: add buildx layer caching. Each run currently recompiles - # the whole `harmony` workspace from scratch in the builder - # stage. Add `cache-from: type=gha` + `cache-to: type=gha,mode=max` - # below once build time becomes the bottleneck. If layer cache - # alone isn't enough, consider splitting the Dockerfile with - # cargo-chef (no other crate in this repo does that yet). - - name: Build and push - uses: docker/build-push-action@v6 - with: - context: . - file: fleet/harmony-fleet-operator/Dockerfile - push: true - tags: hub.nationtech.io/harmony/harmony-fleet-operator:latest + # TODO: bake helm into harmony_composer so this step disappears. + - name: Install helm + run: | + curl -fsSL https://raw.githubusercontent.com/helm/helm/main/scripts/get-helm-3 | bash + + - name: Log in to hub.nationtech.io (helm OCI) + run: | + echo "${{ secrets.HUB_BOT_PASSWORD }}" \ + | helm registry login hub.nationtech.io \ + --username "${{ secrets.HUB_BOT_USER }}" \ + --password-stdin + + # On tag-triggered runs, GITHUB_REF_NAME = the tag name. Strip + # the per-crate prefix to get the version the release binary + # wants (e.g. `harmony-fleet-operator-v0.1.0` → `v0.1.0`). On + # manual workflow_dispatch the operator passes `version` + # directly. + - name: Resolve version + id: ver + run: | + if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then + VERSION="${{ inputs.version }}" + else + VERSION="${GITHUB_REF_NAME#harmony-fleet-operator-}" + fi + if [ -z "$VERSION" ] || [ "$VERSION" = "$GITHUB_REF_NAME" ]; then + echo "could not resolve version from ref '$GITHUB_REF_NAME'" + exit 1 + fi + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + echo "Releasing harmony-fleet-operator $VERSION" + + # 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 }}" diff --git a/examples/fleet_e2e_demo/src/lib.rs b/examples/fleet_e2e_demo/src/lib.rs index 81b4199c..569563a3 100644 --- a/examples/fleet_e2e_demo/src/lib.rs +++ b/examples/fleet_e2e_demo/src/lib.rs @@ -679,6 +679,7 @@ key_json = """ nats_url: format!("nats://{NATS_RELEASE}.{NATS_NAMESPACE}.svc.cluster.local:4222"), log_level: "info,kube_runtime=warn".to_string(), credentials: Some(OperatorCredentials { credentials_toml }), + chart_version: None, }; // CRDs first — the operator watches them on startup. diff --git a/fleet/harmony-fleet-deploy/Cargo.toml b/fleet/harmony-fleet-deploy/Cargo.toml index da44b0c6..d8f94a0e 100644 --- a/fleet/harmony-fleet-deploy/Cargo.toml +++ b/fleet/harmony-fleet-deploy/Cargo.toml @@ -16,6 +16,13 @@ path = "src/lib.rs" name = "harmony-fleet-deploy" path = "src/main.rs" +# Release tool: builds and pushes the operator image + hydrated helm +# chart at a single matching version. Driven by +# fleet/harmony-fleet-operator/release.sh and the .gitea CI workflow. +[[bin]] +name = "harmony-fleet-operator-release" +path = "src/bin/operator_release.rs" + [dependencies] harmony = { path = "../../harmony", features = ["podman"] } harmony_cli = { path = "../../harmony_cli" } diff --git a/fleet/harmony-fleet-deploy/src/bin/operator_release.rs b/fleet/harmony-fleet-deploy/src/bin/operator_release.rs new file mode 100644 index 00000000..9c81c4f2 --- /dev/null +++ b/fleet/harmony-fleet-deploy/src/bin/operator_release.rs @@ -0,0 +1,167 @@ +//! `harmony-fleet-operator-release` — build + push the operator +//! container image and helm chart at one matching version. +//! +//! Invoked by `fleet/harmony-fleet-operator/release.sh` and by the +//! `.gitea/workflows/harmony-fleet-operator.yaml` CI job. The same +//! binary is the developer-laptop fallback during outages. +//! +//! Steps, in order: +//! +//! 1. `docker build` the canonical multi-stage +//! `fleet/harmony-fleet-operator/Dockerfile` against the workspace +//! root, tagged `//harmony-fleet-operator:`. +//! 2. `docker push` that image. +//! 3. Hydrate the helm chart via +//! `harmony_fleet_deploy::operator::chart::build_chart`, with the +//! pushed image reference baked into the Deployment and +//! `chart_version` set to `` so the OCI artifact lands at +//! the matching tag. +//! 4. `helm package` the chart directory into a tgz. +//! 5. `helm push` the tgz to `oci:///`. +//! +//! `docker` (not `podman`) because the canonical `build_docker.sh` +//! and the gitea `dind` runner both use it. Helm-OCI login + docker +//! login are expected to be already established by the caller +//! (`docker login`, `helm registry login`). + +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; + +use anyhow::{Context, Result, bail}; +use clap::Parser; +use harmony_fleet_deploy::operator::chart::{ChartOptions, build_chart}; + +#[derive(Parser, Debug)] +#[command( + name = "harmony-fleet-operator-release", + about = "Build and push the harmony-fleet-operator image + chart for a tagged release" +)] +struct Cli { + /// 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, +} + +const IMAGE_NAME: &str = "harmony-fleet-operator"; +const DOCKERFILE: &str = "fleet/harmony-fleet-operator/Dockerfile"; + +fn main() -> Result<()> { + let cli = Cli::parse(); + + let workspace = workspace_root(); + let image_ref = format!( + "{}/{}/{}:{}", + cli.registry, cli.project, IMAGE_NAME, cli.version + ); + let oci_repo = format!("oci://{}/{}", cli.registry, cli.project); + + eprintln!("==> docker build {image_ref}"); + docker_build(&workspace, &image_ref)?; + + eprintln!("==> docker push {image_ref}"); + docker_push(&image_ref)?; + + eprintln!("==> generate chart (image={image_ref})"); + let tmp = tempfile::tempdir().context("creating chart tempdir")?; + let chart_dir = build_chart(&ChartOptions { + output_dir: tmp.path().to_path_buf(), + image: image_ref.clone(), + image_pull_policy: "IfNotPresent".to_string(), + chart_version: Some(chart_version(&cli.version)), + ..ChartOptions::default() + }) + .context("building hydrated chart")?; + + eprintln!("==> helm package {}", chart_dir.display()); + let tgz = helm_package(&chart_dir, tmp.path())?; + + eprintln!("==> helm push {} {oci_repo}", tgz.display()); + helm_push(&tgz, &oci_repo)?; + + eprintln!( + "==> released:\n image: {image_ref}\n chart: {oci_repo}/{IMAGE_NAME}:{}", + chart_version(&cli.version) + ); + Ok(()) +} + +fn docker_build(workspace: &Path, 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 { + 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() +} diff --git a/fleet/harmony-fleet-deploy/src/operator/chart.rs b/fleet/harmony-fleet-deploy/src/operator/chart.rs index ca9cf73d..f45b770a 100644 --- a/fleet/harmony-fleet-deploy/src/operator/chart.rs +++ b/fleet/harmony-fleet-deploy/src/operator/chart.rs @@ -67,6 +67,12 @@ pub struct ChartOptions { /// Secret entirely and lets the operator connect to NATS without /// auth — only sensible when there's no callout in front of NATS. pub credentials: Option, + /// 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:` matching the image tag. + pub chart_version: Option, } /// What the operator pod needs to authenticate to NATS via the auth @@ -111,6 +117,7 @@ impl Default for ChartOptions { nats_url: "nats://fleet-nats.fleet-system:4222".to_string(), log_level: "info,kube_runtime=warn".to_string(), credentials: None, + chart_version: None, } } } @@ -131,10 +138,11 @@ pub fn build_chart(opts: &ChartOptions) -> Result { std::fs::create_dir_all(&opts.output_dir) .with_context(|| format!("creating {:?}", opts.output_dir))?; - let mut chart = HelmChart::new( - RELEASE_NAME.to_string(), - env!("CARGO_PKG_VERSION").to_string(), - ); + 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); chart.description = "IoT operator — Deployment CRD → NATS KV".to_string(); chart.add_resource(HelmResourceKind::Crd(crd_with_keep_annotation( diff --git a/fleet/harmony-fleet-deploy/src/operator/score.rs b/fleet/harmony-fleet-deploy/src/operator/score.rs index 44434e70..dfa7179c 100644 --- a/fleet/harmony-fleet-deploy/src/operator/score.rs +++ b/fleet/harmony-fleet-deploy/src/operator/score.rs @@ -150,6 +150,7 @@ impl Interpret for FleetOperatorInterp nats_url: self.score.nats_url.clone(), log_level: self.score.log_level.clone(), credentials: self.score.credentials.clone(), + chart_version: None, }; // Apply the credentials Secret BEFORE the helm install. The diff --git a/fleet/harmony-fleet-operator/release.sh b/fleet/harmony-fleet-operator/release.sh new file mode 100755 index 00000000..c7fe8593 --- /dev/null +++ b/fleet/harmony-fleet-operator/release.sh @@ -0,0 +1,25 @@ +#!/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 +# ./fleet/harmony-fleet-operator/release.sh hub.nationtech.io v0.1.0 +# +# Expects `docker login ` and `helm registry login ` +# to have already been run; both are cheap one-liners and let CI use the +# same script unchanged. +# +# All heavy lifting (docker build/push, chart hydration, helm +# package/push) is in the `harmony-fleet-operator-release` binary in +# `fleet/harmony-fleet-deploy`. This script's only job is to call it. + +set -euo pipefail + +REGISTRY="${1:?usage: release.sh }" +VERSION="${2:?usage: release.sh }" + +cd "$(dirname "$0")/../.." + +exec cargo run --release -p harmony-fleet-deploy \ + --bin harmony-fleet-operator-release -- \ + --registry "$REGISTRY" --version "$VERSION" -- 2.39.5 From 7c1ef1442959f0edfcd87aa9dc570b1e075b0d9a Mon Sep 17 00:00:00 2001 From: Jean-Gabriel Gill-Couture Date: Tue, 26 May 2026 14:15:43 -0400 Subject: [PATCH 2/6] fix: chart version + add --no-push for local smoke-tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two findings from the k3d smoke-test of the release pipeline: 1. `HelmChart::new(name, version)` is misleading — the second arg sets appVersion only, while the chart-level `version` is a separate pub field defaulting to "0.1.0". The first run produced `harmony-fleet-operator-0.1.0.tgz` instead of `harmony-fleet-operator-.tgz`. Set both fields to the released tag so one tag → one image + one chart at one matching version. 2. Add `--no-push` to the release binary so the same code path that CI exercises is usable locally for k3d smoke-tests without pushing to Harbor. The packaged chart tgz is copied into the caller's CWD so it survives the binary's tempdir cleanup, and the binary prints both artifact paths at the end. Verified end-to-end on k3d: helm install brings up CRDs, RBAC, and the operator Deployment; the operator pod reaches Running 1/1 and starts retrying NATS connect (expected — no NATS deployed in this smoke-test). --- .../src/bin/operator_release.rs | 45 +++++++++++++++---- .../src/operator/chart.rs | 8 +++- 2 files changed, 43 insertions(+), 10 deletions(-) diff --git a/fleet/harmony-fleet-deploy/src/bin/operator_release.rs b/fleet/harmony-fleet-deploy/src/bin/operator_release.rs index 9c81c4f2..4e3a5154 100644 --- a/fleet/harmony-fleet-deploy/src/bin/operator_release.rs +++ b/fleet/harmony-fleet-deploy/src/bin/operator_release.rs @@ -51,6 +51,13 @@ struct Cli { /// 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, } const IMAGE_NAME: &str = "harmony-fleet-operator"; @@ -69,10 +76,16 @@ fn main() -> Result<()> { eprintln!("==> docker build {image_ref}"); docker_build(&workspace, &image_ref)?; - eprintln!("==> docker push {image_ref}"); - docker_push(&image_ref)?; + if !cli.no_push { + eprintln!("==> docker push {image_ref}"); + docker_push(&image_ref)?; + } else { + eprintln!("==> skipping docker push (--no-push)"); + } eprintln!("==> 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 = build_chart(&ChartOptions { output_dir: tmp.path().to_path_buf(), @@ -86,13 +99,27 @@ fn main() -> Result<()> { eprintln!("==> helm package {}", chart_dir.display()); let tgz = helm_package(&chart_dir, tmp.path())?; - eprintln!("==> helm push {} {oci_repo}", tgz.display()); - helm_push(&tgz, &oci_repo)?; - - eprintln!( - "==> released:\n image: {image_ref}\n chart: {oci_repo}/{IMAGE_NAME}:{}", - chart_version(&cli.version) - ); + if !cli.no_push { + eprintln!("==> helm push {} {oci_repo}", tgz.display()); + helm_push(&tgz, &oci_repo)?; + eprintln!( + "==> released:\n image: {image_ref}\n chart: {oci_repo}/{IMAGE_NAME}:{}", + chart_version(&cli.version) + ); + } else { + // 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")?; + eprintln!( + "==> built (no push):\n image: {image_ref}\n chart: {}", + dst.display() + ); + } Ok(()) } diff --git a/fleet/harmony-fleet-deploy/src/operator/chart.rs b/fleet/harmony-fleet-deploy/src/operator/chart.rs index f45b770a..dce3799b 100644 --- a/fleet/harmony-fleet-deploy/src/operator/chart.rs +++ b/fleet/harmony-fleet-deploy/src/operator/chart.rs @@ -138,11 +138,17 @@ pub fn build_chart(opts: &ChartOptions) -> Result { std::fs::create_dir_all(&opts.output_dir) .with_context(|| format!("creating {:?}", opts.output_dir))?; + // `HelmChart::new(name, app_version)` only sets appVersion — the + // chart-level `version` field defaults to `"0.1.0"` and has to be + // 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); + 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.add_resource(HelmResourceKind::Crd(crd_with_keep_annotation( -- 2.39.5 From cc41f190d20ed2d4c33b8fe2aa43363ffece4a9a Mon Sep 17 00:00:00 2001 From: Jean-Gabriel Gill-Couture Date: Tue, 26 May 2026 14:58:50 -0400 Subject: [PATCH 3/6] refactor: chart is now namespace-neutral; add dashboard roadmap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The k3d smoke-test surfaced that the operator chart baked `fleet-system` into every namespaced manifest (Deployment, ServiceAccount, Secret) and into the ClusterRoleBinding subject. Installing into any other namespace failed with helm release-namespace mismatch. Fixed by making the chart genuinely namespace-neutral: - Removed `namespace` from `ChartOptions` entirely. - `service_account()` and `operator_deployment(opts)` no longer set `metadata.namespace`; helm assigns the release namespace at install time, and the direct-apply path injects the namespace through `K8sResourceScore::single(.., Some(ns))`. - `operator_secret(opts)` likewise drops `metadata.namespace`; the Secret is applied with an explicit namespace by its caller. - `cluster_role_binding(subject_namespace)` keeps a namespace argument because the CRB subject must point at a concrete namespace; the chart path passes the literal helm template `{{ .Release.Namespace }}` so helm substitutes the release namespace at install time. The direct-apply path passes the real namespace string. - `FleetOperatorScore::new()` defaults its own `namespace` field (no longer sourced from `ChartOptions::default()`); the chart itself carries no namespace default at all. Verified on k3d by installing the released chart into a deliberately non-default namespace (`my-fleet`): all resources land in `my-fleet`, ClusterRoleBinding subject resolves to `my-fleet`, operator pod runs. Also adds `ROADMAP/fleet_platform/dashboard_ingress.md` capturing the three-step dependency chain (build with web-frontend feature → implement real FleetService → add Service + Ingress to chart) that the k3d test surfaced when looking for the dashboard. Unnumbered file per project convention; numbered ones are versioned milestones. --- ROADMAP/fleet_platform/dashboard_ingress.md | 92 +++++++++++++++++++ examples/fleet_e2e_demo/src/lib.rs | 5 +- .../src/operator/chart.rs | 57 ++++++++---- .../src/operator/score.rs | 5 +- 4 files changed, 135 insertions(+), 24 deletions(-) create mode 100644 ROADMAP/fleet_platform/dashboard_ingress.md diff --git a/ROADMAP/fleet_platform/dashboard_ingress.md b/ROADMAP/fleet_platform/dashboard_ingress.md new file mode 100644 index 00000000..eb278e14 --- /dev/null +++ b/ROADMAP/fleet_platform/dashboard_ingress.md @@ -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` 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. diff --git a/examples/fleet_e2e_demo/src/lib.rs b/examples/fleet_e2e_demo/src/lib.rs index 569563a3..71790221 100644 --- a/examples/fleet_e2e_demo/src/lib.rs +++ b/examples/fleet_e2e_demo/src/lib.rs @@ -675,7 +675,6 @@ key_json = """ output_dir: PathBuf::new(), // unused on this code path image: OPERATOR_IMAGE_TAG.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"), log_level: "info,kube_runtime=warn".to_string(), credentials: Some(OperatorCredentials { credentials_toml }), @@ -694,7 +693,7 @@ key_json = """ // RBAC. K8sResourceScore::single( - build_service_account(&opts), + build_service_account(), Some(OPERATOR_NAMESPACE.to_string()), ) .interpret(&Inventory::autoload(), topology) @@ -706,7 +705,7 @@ key_json = """ .await .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) .await .context("operator ClusterRoleBinding apply")?; diff --git a/fleet/harmony-fleet-deploy/src/operator/chart.rs b/fleet/harmony-fleet-deploy/src/operator/chart.rs index dce3799b..779c246e 100644 --- a/fleet/harmony-fleet-deploy/src/operator/chart.rs +++ b/fleet/harmony-fleet-deploy/src/operator/chart.rs @@ -51,11 +51,6 @@ pub struct ChartOptions { /// sideloaded k3d images, `Never` if the image must already be /// present. 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 /// `fleet-nats.fleet-system` the default `nats://fleet-nats.fleet-system:4222` /// works with no config. @@ -113,8 +108,11 @@ impl Default for ChartOptions { output_dir: PathBuf::from("/tmp/fleet-load-test/chart"), image: "localhost/harmony-fleet-operator:latest".to_string(), image_pull_policy: "IfNotPresent".to_string(), - namespace: "fleet-system".to_string(), - nats_url: "nats://fleet-nats.fleet-system:4222".to_string(), + // Deliberately uses a non-fleet-specific in-cluster DNS + // 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(), credentials: None, chart_version: None, @@ -158,12 +156,18 @@ pub fn build_chart(opts: &ChartOptions) -> Result { Device::crd(), ))); - chart.add_resource(HelmResourceKind::ServiceAccount(service_account( - &opts.namespace, - ))); + chart.add_resource(HelmResourceKind::ServiceAccount(service_account())); 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( - &opts.namespace, + "{{ .Release.Namespace }}", ))); // Secret intentionally NOT included in the on-disk helm chart — // credentials are operator-environment-specific and out of scope @@ -189,10 +193,13 @@ pub fn operator_secret(opts: &ChartOptions) -> Option { SECRET_KEY_CREDENTIALS_TOML.to_string(), 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 { metadata: ObjectMeta { name: Some(SECRET_NAME.to_string()), - namespace: Some(opts.namespace.clone()), ..Default::default() }, data: Some(data), @@ -215,11 +222,13 @@ fn crd_with_keep_annotation(mut crd: CustomResourceDefinition) -> CustomResource 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 { metadata: ObjectMeta { name: Some(SERVICE_ACCOUNT.to_string()), - namespace: Some(namespace.to_string()), ..Default::default() }, ..Default::default() @@ -339,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 { metadata: ObjectMeta { name: Some(RELEASE_NAME.to_string()), - namespace: Some(opts.namespace.clone()), labels: Some(match_labels.clone()), ..Default::default() }, @@ -378,14 +391,20 @@ fn operator_deployment(opts: &ChartOptions) -> K8sDeployment { // Re-export the manifest builders so the e2e bring-up can apply the // 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 { cluster_role() } -pub fn build_cluster_role_binding(opts: &ChartOptions) -> ClusterRoleBinding { - cluster_role_binding(&opts.namespace) +pub fn build_cluster_role_binding(subject_namespace: &str) -> ClusterRoleBinding { + cluster_role_binding(subject_namespace) } pub fn build_operator_deployment(opts: &ChartOptions) -> K8sDeployment { operator_deployment(opts) diff --git a/fleet/harmony-fleet-deploy/src/operator/score.rs b/fleet/harmony-fleet-deploy/src/operator/score.rs index dfa7179c..c57da2fe 100644 --- a/fleet/harmony-fleet-deploy/src/operator/score.rs +++ b/fleet/harmony-fleet-deploy/src/operator/score.rs @@ -65,7 +65,9 @@ impl FleetOperatorScore { pub fn new() -> Self { let defaults = ChartOptions::default(); 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(), image: defaults.image, image_pull_policy: defaults.image_pull_policy, @@ -146,7 +148,6 @@ impl Interpret for FleetOperatorInterp output_dir: tmp.path().to_path_buf(), image: self.score.image.clone(), image_pull_policy: self.score.image_pull_policy.clone(), - namespace: self.score.namespace.clone(), nats_url: self.score.nats_url.clone(), log_level: self.score.log_level.clone(), credentials: self.score.credentials.clone(), -- 2.39.5 From 87e142c73d95e71533d6c076d7ba35025a55b66d Mon Sep 17 00:00:00 2001 From: Jean-Gabriel Gill-Couture Date: Tue, 26 May 2026 15:16:14 -0400 Subject: [PATCH 4/6] style: cargo fmt the operator-release binary Collapses a chained `current_dir()?.join(..)` per rustfmt's preferred layout. Caught by ./build/check.sh's fmt step; no behavior change. --- fleet/harmony-fleet-deploy/src/bin/operator_release.rs | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/fleet/harmony-fleet-deploy/src/bin/operator_release.rs b/fleet/harmony-fleet-deploy/src/bin/operator_release.rs index 4e3a5154..3eb22e66 100644 --- a/fleet/harmony-fleet-deploy/src/bin/operator_release.rs +++ b/fleet/harmony-fleet-deploy/src/bin/operator_release.rs @@ -110,10 +110,8 @@ fn main() -> Result<()> { // 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")?, - ); + 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")?; eprintln!( "==> built (no push):\n image: {image_ref}\n chart: {}", -- 2.39.5 From b29d4662406d1e24abd93499247ace3e26af1dbd Mon Sep 17 00:00:00 2001 From: Jean-Gabriel Gill-Couture Date: Tue, 26 May 2026 15:39:42 -0400 Subject: [PATCH 5/6] refactor: use tracing instead of eprintln in operator-release eprintln! was the wrong tool for a long-running CLI invoked from shell scripts and CI. The rest of the codebase (operator, fleet-e2e harness, etc.) uses tracing::info!, and a release tool should honor RUST_LOG. Initializes a stderr tracing subscriber defaulting to info so the progress lines show up without configuration, while still letting operators silence (`RUST_LOG=warn`) or expand (`RUST_LOG=debug`) the output. The final summary uses structured fields (image=, chart=) so downstream tools can grep for them deterministically. --- fleet/harmony-fleet-deploy/Cargo.toml | 1 + .../src/bin/operator_release.rs | 45 +++++++++++-------- 2 files changed, 28 insertions(+), 18 deletions(-) diff --git a/fleet/harmony-fleet-deploy/Cargo.toml b/fleet/harmony-fleet-deploy/Cargo.toml index d8f94a0e..46644489 100644 --- a/fleet/harmony-fleet-deploy/Cargo.toml +++ b/fleet/harmony-fleet-deploy/Cargo.toml @@ -47,3 +47,4 @@ thiserror = { workspace = true } tokio = { workspace = true, features = ["full"] } toml = { workspace = true } tracing = { workspace = true } +tracing-subscriber = { workspace = true } diff --git a/fleet/harmony-fleet-deploy/src/bin/operator_release.rs b/fleet/harmony-fleet-deploy/src/bin/operator_release.rs index 3eb22e66..575d2c18 100644 --- a/fleet/harmony-fleet-deploy/src/bin/operator_release.rs +++ b/fleet/harmony-fleet-deploy/src/bin/operator_release.rs @@ -30,6 +30,7 @@ use std::process::{Command, Stdio}; use anyhow::{Context, Result, bail}; use clap::Parser; use harmony_fleet_deploy::operator::chart::{ChartOptions, build_chart}; +use tracing::info; #[derive(Parser, Debug)] #[command( @@ -64,6 +65,16 @@ const IMAGE_NAME: &str = "harmony-fleet-operator"; const DOCKERFILE: &str = "fleet/harmony-fleet-operator/Dockerfile"; 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 workspace = workspace_root(); @@ -73,17 +84,17 @@ fn main() -> Result<()> { ); let oci_repo = format!("oci://{}/{}", cli.registry, cli.project); - eprintln!("==> docker build {image_ref}"); + info!("docker build {image_ref}"); docker_build(&workspace, &image_ref)?; - if !cli.no_push { - eprintln!("==> docker push {image_ref}"); - docker_push(&image_ref)?; + if cli.no_push { + info!("skipping docker push (--no-push)"); } else { - eprintln!("==> skipping docker push (--no-push)"); + info!("docker push {image_ref}"); + docker_push(&image_ref)?; } - eprintln!("==> generate chart (image={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")?; @@ -96,26 +107,24 @@ fn main() -> Result<()> { }) .context("building hydrated chart")?; - eprintln!("==> helm package {}", chart_dir.display()); + info!("helm package {}", chart_dir.display()); let tgz = helm_package(&chart_dir, tmp.path())?; - if !cli.no_push { - eprintln!("==> helm push {} {oci_repo}", tgz.display()); - helm_push(&tgz, &oci_repo)?; - eprintln!( - "==> released:\n image: {image_ref}\n chart: {oci_repo}/{IMAGE_NAME}:{}", - chart_version(&cli.version) - ); - } else { + 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")?; - eprintln!( - "==> built (no push):\n image: {image_ref}\n chart: {}", - dst.display() + 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}/{IMAGE_NAME}:{}", chart_version(&cli.version)), + "released" ); } Ok(()) -- 2.39.5 From 81bf8a92570121fe0f0ec9cd3bcbee8d05f27ddf Mon Sep 17 00:00:00 2001 From: Jean-Gabriel Gill-Couture Date: Tue, 26 May 2026 16:21:50 -0400 Subject: [PATCH 6/6] refactor: app-scoped release binary harmony-fleet-release Renames `harmony-fleet-operator-release` to `harmony-fleet-release` and adds a `--component ` flag. The binary's surface is now app-scoped (one binary per app, all fleet components behind a flag) rather than component-scoped (one binary per component), matching ADR-023's "deploy binary statically lists its supported components" guidance. Why: adding the agent and nats-callout release pipelines later would otherwise mean three near-identical binaries with copy-pasted docker/helm orchestration. Folding them under one binary keeps the shared 90% in one place and reduces each new component to: - a new `Component` enum variant - a `Component::spec` arm naming the Dockerfile + image - a `hydrate_chart` arm pointing at the component's `build_chart` `agent` and `nats-callout` variants exist today but bail with an actionable error pointing at the roadmap; this keeps `--help` honest about what's coming without lying about what works. The per-component `release.sh` wrapper pattern stays: each component's script (today `fleet/harmony-fleet-operator/release.sh`, tomorrow agent's and callout's) is a 1-line wrapper that pre-fills `--component`. This lets a tag like `harmony-fleet-operator-v0.1.0` route to the right component via the existing CI workflow without the operator having to remember a flag. File renamed via `git mv` so blame history is preserved. Verified on k3d with `--component operator --no-push`: produces the same image + chart pair as before. --- Cargo.lock | 1 + fleet/harmony-fleet-deploy/Cargo.toml | 13 +- .../{operator_release.rs => fleet_release.rs} | 141 +++++++++++++----- fleet/harmony-fleet-operator/release.sh | 13 +- 4 files changed, 124 insertions(+), 44 deletions(-) rename fleet/harmony-fleet-deploy/src/bin/{operator_release.rs => fleet_release.rs} (53%) diff --git a/Cargo.lock b/Cargo.lock index b27c5d2b..97d0345a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4107,6 +4107,7 @@ dependencies = [ "tokio", "toml", "tracing", + "tracing-subscriber", ] [[package]] diff --git a/fleet/harmony-fleet-deploy/Cargo.toml b/fleet/harmony-fleet-deploy/Cargo.toml index 46644489..2ed8ffe8 100644 --- a/fleet/harmony-fleet-deploy/Cargo.toml +++ b/fleet/harmony-fleet-deploy/Cargo.toml @@ -16,12 +16,15 @@ path = "src/lib.rs" name = "harmony-fleet-deploy" path = "src/main.rs" -# Release tool: builds and pushes the operator image + hydrated helm -# chart at a single matching version. Driven by -# fleet/harmony-fleet-operator/release.sh and the .gitea CI workflow. +# 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-operator-release" -path = "src/bin/operator_release.rs" +name = "harmony-fleet-release" +path = "src/bin/fleet_release.rs" [dependencies] harmony = { path = "../../harmony", features = ["podman"] } diff --git a/fleet/harmony-fleet-deploy/src/bin/operator_release.rs b/fleet/harmony-fleet-deploy/src/bin/fleet_release.rs similarity index 53% rename from fleet/harmony-fleet-deploy/src/bin/operator_release.rs rename to fleet/harmony-fleet-deploy/src/bin/fleet_release.rs index 575d2c18..fa8ef3e0 100644 --- a/fleet/harmony-fleet-deploy/src/bin/operator_release.rs +++ b/fleet/harmony-fleet-deploy/src/bin/fleet_release.rs @@ -1,43 +1,53 @@ -//! `harmony-fleet-operator-release` — build + push the operator -//! container image and helm chart at one matching version. +//! `harmony-fleet-release` — build + push the image + helm chart for +//! one fleet component at a tagged version. //! -//! Invoked by `fleet/harmony-fleet-operator/release.sh` and by the -//! `.gitea/workflows/harmony-fleet-operator.yaml` CI job. The same -//! binary is the developer-laptop fallback during outages. +//! 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: +//! Steps, in order, for the selected component: //! -//! 1. `docker build` the canonical multi-stage -//! `fleet/harmony-fleet-operator/Dockerfile` against the workspace -//! root, tagged `//harmony-fleet-operator:`. +//! 1. `docker build` the canonical multi-stage Dockerfile against the +//! workspace root, tagged +//! `//:`. //! 2. `docker push` that image. -//! 3. Hydrate the helm chart via -//! `harmony_fleet_deploy::operator::chart::build_chart`, with the -//! pushed image reference baked into the Deployment and -//! `chart_version` set to `` so the OCI artifact lands at -//! the matching tag. +//! 3. Hydrate the helm chart for the component, with the pushed image +//! reference baked into the manifest and `chart_version` set to +//! `` 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:///`. //! -//! `docker` (not `podman`) because the canonical `build_docker.sh` -//! and the gitea `dind` runner both use it. Helm-OCI login + docker -//! login are expected to be already established by the caller -//! (`docker login`, `helm registry login`). +//! `docker` (not `podman`) because the existing build scripts and the +//! gitea `dind` runner both use it. `docker login ` and +//! `helm registry login ` 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; +use clap::{Parser, ValueEnum}; use harmony_fleet_deploy::operator::chart::{ChartOptions, build_chart}; use tracing::info; #[derive(Parser, Debug)] #[command( - name = "harmony-fleet-operator-release", - about = "Build and push the harmony-fleet-operator image + chart for a tagged release" + name = "harmony-fleet-release", + about = "Build and push a fleet component's image + helm chart for a tagged release" )] struct Cli { + /// 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, @@ -61,8 +71,46 @@ struct Cli { no_push: bool, } -const IMAGE_NAME: &str = "harmony-fleet-operator"; -const DOCKERFILE: &str = "fleet/harmony-fleet-operator/Dockerfile"; +/// 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 { + 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 @@ -76,16 +124,17 @@ fn main() -> Result<()> { .init(); let cli = Cli::parse(); + let spec = cli.component.spec()?; let workspace = workspace_root(); let image_ref = format!( "{}/{}/{}:{}", - cli.registry, cli.project, IMAGE_NAME, cli.version + 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, &image_ref)?; + docker_build(&workspace, spec.dockerfile, &image_ref)?; if cli.no_push { info!("skipping docker push (--no-push)"); @@ -98,14 +147,7 @@ fn main() -> Result<()> { // 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 = build_chart(&ChartOptions { - output_dir: tmp.path().to_path_buf(), - image: image_ref.clone(), - image_pull_policy: "IfNotPresent".to_string(), - chart_version: Some(chart_version(&cli.version)), - ..ChartOptions::default() - }) - .context("building hydrated chart")?; + 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())?; @@ -123,16 +165,43 @@ fn main() -> Result<()> { helm_push(&tgz, &oci_repo)?; info!( image = %image_ref, - chart = %format!("{oci_repo}/{IMAGE_NAME}:{}", chart_version(&cli.version)), + chart = %format!("{oci_repo}/{}:{}", spec.image_name, chart_version(&cli.version)), "released" ); } Ok(()) } -fn docker_build(workspace: &Path, image_ref: &str) -> Result<()> { +/// 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 { + 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, "."]) + .args(["build", "-f", dockerfile, "-t", image_ref, "."]) .current_dir(workspace)) } diff --git a/fleet/harmony-fleet-operator/release.sh b/fleet/harmony-fleet-operator/release.sh index c7fe8593..ddffa459 100755 --- a/fleet/harmony-fleet-operator/release.sh +++ b/fleet/harmony-fleet-operator/release.sh @@ -9,9 +9,15 @@ # 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 `harmony-fleet-operator-release` binary in -# `fleet/harmony-fleet-deploy`. This script's only job is to call it. +# package/push) is in the binary; this script just selects the +# component. set -euo pipefail @@ -21,5 +27,6 @@ VERSION="${2:?usage: release.sh }" cd "$(dirname "$0")/../.." exec cargo run --release -p harmony-fleet-deploy \ - --bin harmony-fleet-operator-release -- \ + --bin harmony-fleet-release -- \ + --component operator \ --registry "$REGISTRY" --version "$VERSION" -- 2.39.5