feat(fleet): release the operator from a tag (minimal) #307
@@ -16,6 +16,12 @@ path = "src/lib.rs"
|
||||
name = "harmony-fleet-deploy"
|
||||
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]
|
||||
harmony = { path = "../../harmony", features = ["podman"] }
|
||||
harmony_cli = { path = "../../harmony_cli" }
|
||||
|
||||
36
fleet/harmony-fleet-deploy/src/bin/harmony-fleet-release.rs
Normal file
36
fleet/harmony-fleet-deploy/src/bin/harmony-fleet-release.rs
Normal 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)
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
119
fleet/harmony-fleet-deploy/src/release.rs
Normal file
119
fleet/harmony-fleet-deploy/src/release.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user