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/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/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 81b4199c..71790221 100644 --- a/examples/fleet_e2e_demo/src/lib.rs +++ b/examples/fleet_e2e_demo/src/lib.rs @@ -675,10 +675,10 @@ 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 }), + chart_version: None, }; // CRDs first — the operator watches them on startup. @@ -693,7 +693,7 @@ key_json = """ // RBAC. K8sResourceScore::single( - build_service_account(&opts), + build_service_account(), Some(OPERATOR_NAMESPACE.to_string()), ) .interpret(&Inventory::autoload(), topology) @@ -705,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/Cargo.toml b/fleet/harmony-fleet-deploy/Cargo.toml index da44b0c6..2ed8ffe8 100644 --- a/fleet/harmony-fleet-deploy/Cargo.toml +++ b/fleet/harmony-fleet-deploy/Cargo.toml @@ -16,6 +16,16 @@ path = "src/lib.rs" name = "harmony-fleet-deploy" 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] harmony = { path = "../../harmony", features = ["podman"] } harmony_cli = { path = "../../harmony_cli" } @@ -40,3 +50,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/fleet_release.rs b/fleet/harmony-fleet-deploy/src/bin/fleet_release.rs new file mode 100644 index 00000000..fa8ef3e0 --- /dev/null +++ b/fleet/harmony-fleet-deploy/src/bin/fleet_release.rs @@ -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 +//! `//:`. +//! 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 +//! `` 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 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, 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 { + /// 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 { + 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 { + 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 { + 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..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. @@ -67,6 +62,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 @@ -107,10 +108,14 @@ 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, } } } @@ -131,10 +136,17 @@ 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(), - ); + // `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.clone()); + chart.version = chart_version; chart.description = "IoT operator — Deployment CRD → NATS KV".to_string(); chart.add_resource(HelmResourceKind::Crd(crd_with_keep_annotation( @@ -144,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 @@ -175,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), @@ -201,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() @@ -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 { metadata: ObjectMeta { name: Some(RELEASE_NAME.to_string()), - namespace: Some(opts.namespace.clone()), labels: Some(match_labels.clone()), ..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 // 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 44434e70..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,10 +148,10 @@ 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(), + 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..ddffa459 --- /dev/null +++ b/fleet/harmony-fleet-operator/release.sh @@ -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 +# ./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. +# +# 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 }" +VERSION="${2:?usage: release.sh }" + +cd "$(dirname "$0")/../.." + +exec cargo run --release -p harmony-fleet-deploy \ + --bin harmony-fleet-release -- \ + --component operator \ + --registry "$REGISTRY" --version "$VERSION"