ci/fleet-operator-release-pipeline #300

Open
johnride wants to merge 6 commits from ci/fleet-operator-release-pipeline into master
9 changed files with 529 additions and 50 deletions

View File

@@ -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 }}"

1
Cargo.lock generated
View File

@@ -4107,6 +4107,7 @@ dependencies = [
"tokio",
"toml",
"tracing",
"tracing-subscriber",
]
[[package]]

View 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.

View File

@@ -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")?;

View File

@@ -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 }

View 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 {
/// 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()
}

View File

@@ -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<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
@@ -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<PathBuf> {
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<PathBuf> {
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> {
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)

View File

@@ -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<T: Topology + HelmCommand + K8sclient> Interpret<T> 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

View 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"