From 5ab1a149fcc0ae6672f1834f5c67d5a4c5dc0803 Mon Sep 17 00:00:00 2001 From: Jean-Gabriel Gill-Couture Date: Thu, 28 May 2026 19:38:54 -0400 Subject: [PATCH] feat(fleet): release the operator from a tag (minimal) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `harmony-fleet-release --from-tag harmony-fleet-operator-v0.1.0` builds + pushes the operator image and helm chart at the parsed version (version_from_tag handles the prefix + leading v, replacing the interim resolve-release-version.sh). Plain function + anyhow at the binary boundary — no Score / AppSpec / Topology ceremony for a single component; introduce a spec when a second component needs releasing. Slim variant of feat/fleet-release-score (which models release as a ReleaseScore + AppSpec driven by harmony_cli). --- fleet/harmony-fleet-deploy/Cargo.toml | 6 + .../src/bin/harmony-fleet-release.rs | 36 ++++++ fleet/harmony-fleet-deploy/src/lib.rs | 2 + fleet/harmony-fleet-deploy/src/release.rs | 119 ++++++++++++++++++ 4 files changed, 163 insertions(+) create mode 100644 fleet/harmony-fleet-deploy/src/bin/harmony-fleet-release.rs create mode 100644 fleet/harmony-fleet-deploy/src/release.rs diff --git a/fleet/harmony-fleet-deploy/Cargo.toml b/fleet/harmony-fleet-deploy/Cargo.toml index da44b0c6..0cabf7f0 100644 --- a/fleet/harmony-fleet-deploy/Cargo.toml +++ b/fleet/harmony-fleet-deploy/Cargo.toml @@ -16,6 +16,12 @@ path = "src/lib.rs" name = "harmony-fleet-deploy" path = "src/main.rs" +# `harmony-fleet-release --from-tag ` builds + publishes the +# operator's image + chart for a release. +[[bin]] +name = "harmony-fleet-release" +path = "src/bin/harmony-fleet-release.rs" + [dependencies] harmony = { path = "../../harmony", features = ["podman"] } harmony_cli = { path = "../../harmony_cli" } diff --git a/fleet/harmony-fleet-deploy/src/bin/harmony-fleet-release.rs b/fleet/harmony-fleet-deploy/src/bin/harmony-fleet-release.rs new file mode 100644 index 00000000..a49b7a7c --- /dev/null +++ b/fleet/harmony-fleet-deploy/src/bin/harmony-fleet-release.rs @@ -0,0 +1,36 @@ +//! `harmony-fleet-release` — build + publish the operator image + chart +//! for a tagged release. `docker` / `helm` must be on PATH and logged in +//! to the registry (CI's login actions; dev's manual login). + +use anyhow::Result; +use clap::Parser; +use harmony_fleet_deploy::release::{release_operator, version_from_tag}; + +#[derive(Parser, Debug)] +#[command( + name = "harmony-fleet-release", + about = "Build + publish the operator image + chart for a tagged release" +)] +struct Cli { + /// Git tag, e.g. `harmony-fleet-operator-v0.1.0`. Defaults to + /// `$GITHUB_REF_NAME` so CI passes nothing. + #[arg(long, env = "GITHUB_REF_NAME")] + from_tag: String, + + #[arg(long, default_value = "hub.nationtech.io")] + registry: String, + + #[arg(long, default_value = "harmony")] + project: String, + + /// Build + package only; skip both pushes (local k3d smoke-test). + #[arg(long)] + no_push: bool, +} + +fn main() -> Result<()> { + env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init(); + let cli = Cli::parse(); + let version = version_from_tag(&cli.from_tag)?; + release_operator(&version, &cli.registry, &cli.project, !cli.no_push) +} diff --git a/fleet/harmony-fleet-deploy/src/lib.rs b/fleet/harmony-fleet-deploy/src/lib.rs index 67735a4e..943cdd5e 100644 --- a/fleet/harmony-fleet-deploy/src/lib.rs +++ b/fleet/harmony-fleet-deploy/src/lib.rs @@ -31,10 +31,12 @@ pub mod agent; pub mod companion; pub mod nats; pub mod operator; +pub mod release; pub mod server; pub use agent::{FleetAgentScore, PodTarget}; pub use companion::AgentObservation; pub use nats::{FleetNatsScore, UserPassCredentials}; pub use operator::{FleetOperatorScore, OperatorCredentials, PublishedChart}; +pub use release::{release_operator, version_from_tag}; pub use server::FleetServerScore; diff --git a/fleet/harmony-fleet-deploy/src/release.rs b/fleet/harmony-fleet-deploy/src/release.rs new file mode 100644 index 00000000..a7fabcaa --- /dev/null +++ b/fleet/harmony-fleet-deploy/src/release.rs @@ -0,0 +1,119 @@ +//! Build + publish the operator's image + chart for a tagged release. +//! +//! A release is five commands — docker build/push, hydrate the chart, +//! helm package/push — plus parsing the version out of the git tag. +//! Kept as a plain function with `anyhow` (this is binary glue, not +//! library API). When a second component needs releasing, lift the +//! per-component constants below into a small spec then — not before. + +use std::path::{Path, PathBuf}; +use std::process::Command; + +use anyhow::{Context, Result, bail}; + +use crate::operator::chart::{ChartOptions, build_chart}; + +const TAG_PREFIX: &str = "harmony-fleet-operator-"; +const IMAGE_NAME: &str = "harmony-fleet-operator"; +const DOCKERFILE: &str = "fleet/harmony-fleet-operator/Dockerfile"; + +/// Version from a release tag: `harmony-fleet-operator-v0.1.0` → `0.1.0`. +/// One version drives both the image tag and the chart version. +pub fn version_from_tag(tag: &str) -> Result { + let rest = tag + .strip_prefix(TAG_PREFIX) + .with_context(|| format!("tag '{tag}' is not a '{TAG_PREFIX}*' release tag"))?; + let version = rest.strip_prefix('v').unwrap_or(rest); + if version.is_empty() { + bail!("tag '{tag}' has no version after the prefix"); + } + Ok(version.to_string()) +} + +/// Build + push the operator image and helm chart at `version`. With +/// `push = false`, builds + packages only (a local k3d smoke-test). +/// `docker` and `helm` must be on PATH and logged in to the registry. +pub fn release_operator(version: &str, registry: &str, project: &str, push: bool) -> Result<()> { + let image = format!("{registry}/{project}/{IMAGE_NAME}:{version}"); + let oci_repo = format!("oci://{registry}/{project}"); + + log::info!("docker build {image}"); + run("docker", &["build", "-f", DOCKERFILE, "-t", &image, "."])?; + if push { + log::info!("docker push {image}"); + run("docker", &["push", &image])?; + } + + let tmp = tempfile::tempdir().context("chart tempdir")?; + let chart_dir = build_chart(&ChartOptions { + output_dir: tmp.path().to_path_buf(), + image: image.clone(), + chart_version: Some(version.to_string()), + ..ChartOptions::default() + }) + .map_err(|e| anyhow::anyhow!("hydrate operator chart: {e}"))?; + + let tgz = helm_package(&chart_dir, tmp.path())?; + if push { + log::info!("helm push {} {oci_repo}", tgz.display()); + run("helm", &["push", path_str(&tgz)?, &oci_repo])?; + } + + log::info!("released image={image} chart={oci_repo}/{IMAGE_NAME}:{version} pushed={push}"); + Ok(()) +} + +fn run(bin: &str, args: &[&str]) -> Result<()> { + let status = Command::new(bin) + .args(args) + .status() + .with_context(|| format!("spawn {bin} (is it installed and in PATH?)"))?; + if !status.success() { + bail!("`{bin} {}` failed ({status})", args.join(" ")); + } + Ok(()) +} + +fn helm_package(chart_dir: &Path, out_dir: &Path) -> Result { + let output = Command::new("helm") + .args(["package", path_str(chart_dir)?, "-d", path_str(out_dir)?]) + .output() + .context("spawn helm package")?; + if !output.status.success() { + bail!("helm package failed ({})", output.status); + } + // helm prints "…saved it to: "; the last token is the path. + String::from_utf8_lossy(&output.stdout) + .split_whitespace() + .last() + .map(PathBuf::from) + .context("helm package printed no path") +} + +fn path_str(p: &Path) -> Result<&str> { + p.to_str() + .with_context(|| format!("path not utf-8: {}", p.display())) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn version_strips_prefix_and_leading_v() { + assert_eq!( + version_from_tag("harmony-fleet-operator-v0.1.0").unwrap(), + "0.1.0" + ); + assert_eq!( + version_from_tag("harmony-fleet-operator-2.3.4").unwrap(), + "2.3.4" + ); + } + + #[test] + fn rejects_foreign_or_empty_tags() { + assert!(version_from_tag("harmony-fleet-agent-v0.1.0").is_err()); + assert!(version_from_tag("harmony-fleet-operator-").is_err()); + } +} -- 2.39.5