feat(fleet): release the operator from a tag (minimal) #307

Merged
johnride merged 1 commits from feat/fleet-release-min into master 2026-05-29 01:08:55 +00:00
4 changed files with 163 additions and 0 deletions

View File

@@ -16,6 +16,12 @@ path = "src/lib.rs"
name = "harmony-fleet-deploy" name = "harmony-fleet-deploy"
path = "src/main.rs" path = "src/main.rs"
# `harmony-fleet-release --from-tag <tag>` builds + publishes the
# operator's image + chart for a release.
[[bin]]
name = "harmony-fleet-release"
path = "src/bin/harmony-fleet-release.rs"
[dependencies] [dependencies]
harmony = { path = "../../harmony", features = ["podman"] } harmony = { path = "../../harmony", features = ["podman"] }
harmony_cli = { path = "../../harmony_cli" } harmony_cli = { path = "../../harmony_cli" }

View File

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

View File

@@ -31,10 +31,12 @@ pub mod agent;
pub mod companion; pub mod companion;
pub mod nats; pub mod nats;
pub mod operator; pub mod operator;
pub mod release;
pub mod server; pub mod server;
pub use agent::{FleetAgentScore, PodTarget}; pub use agent::{FleetAgentScore, PodTarget};
pub use companion::AgentObservation; pub use companion::AgentObservation;
pub use nats::{FleetNatsScore, UserPassCredentials}; pub use nats::{FleetNatsScore, UserPassCredentials};
pub use operator::{FleetOperatorScore, OperatorCredentials, PublishedChart}; pub use operator::{FleetOperatorScore, OperatorCredentials, PublishedChart};
pub use release::{release_operator, version_from_tag};
pub use server::FleetServerScore; pub use server::FleetServerScore;

View File

@@ -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<String> {
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<PathBuf> {
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: <path>"; 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());
}
}