//! `bento-release-macos` — drive one macOS release through the in-session //! `ops-agent` and pull the signed DMG back. //! //! Usage: `bento-release-macos --config driver.toml [--version 0.4.1]` //! //! This is the thin driver (launchplan §A decision (a)); the full bentod/TUI is //! deferred. It is transport-agnostic via `ops-exec` — in production it points //! at the Mac's `ops-agent` (`AgentRpc`), the only context where codesign can //! use the Developer ID key. use anyhow::{Context, Result}; use bento_driver::{ReleasePlan, StdoutSink, run_release}; use ops_exec::{AgentRpc, CapabilitySet, Executor}; use serde::Deserialize; use std::path::PathBuf; #[derive(Debug, Deserialize)] struct DriverConfig { agent: AgentSection, plan: PlanSection, /// Where on THIS host (fw13) to pull the signed DMG. local: LocalSection, } #[derive(Debug, Deserialize)] struct AgentSection { /// e.g. `http://mbp.tailnet:8765` base_url: String, /// Audit label, e.g. `mbp`. host_label: String, } #[derive(Debug, Deserialize)] struct PlanSection { app: String, version: String, repo_path: String, env_file: String, dist_root: String, /// Optional; defaults to `__aarch64.dmg`. dmg_name: Option, /// Product name used for the default DMG name, e.g. `GoingsOn`. product_name: String, } #[derive(Debug, Deserialize)] struct LocalSection { /// `~/...` is expanded here (against this machine's HOME) — unlike the /// `[plan]` paths, which are on the build host and are rejected outright. /// Without this, a `PathBuf` deserialized straight from `"~/Dist/goingson"` /// stays literal and `create_dir_all` cheerfully makes a directory *named* /// `~` under the cwd, quietly dropping the signed DMG somewhere the /// operator will never look. #[serde(deserialize_with = "de_local_path")] dest_dir: PathBuf, } fn de_local_path<'de, D>(d: D) -> Result where D: serde::Deserializer<'de>, { let s = String::deserialize(d)?; bento_driver::expand_local_tilde(&s).map_err(serde::de::Error::custom) } #[tokio::main] async fn main() -> Result<()> { tracing_subscriber::fmt() .with_env_filter( tracing_subscriber::EnvFilter::try_from_default_env() .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")), ) .init(); let args = Args::parse()?; let raw = std::fs::read_to_string(&args.config) .with_context(|| format!("reading {}", args.config))?; let mut cfg: DriverConfig = toml::from_str(&raw).context("parsing driver config")?; if let Some(v) = args.version { cfg.plan.version = v; } let dmg_name = cfg.plan.dmg_name.clone().unwrap_or_else(|| { ReleasePlan::default_dmg_name(&cfg.plan.product_name, &cfg.plan.version) }); let plan = ReleasePlan { app: cfg.plan.app.clone(), version: cfg.plan.version.clone(), repo_path: cfg.plan.repo_path.clone(), env_file: cfg.plan.env_file.clone(), dist_root: cfg.plan.dist_root.clone(), dmg_name: dmg_name.clone(), }; // Before touching the network: a bad path only surfaces after the agent is // contacted and a step has already run on the build host. plan.validate().context("driver config")?; // The driver's caller-side grant: it may build/sign/notarize/staple, observe // the gatekeeper verdict, and pull the artifact it just had built (`artifact` // — the last step of the flow below). The agent re-checks against its own // grant. Note `gatekeeper` is also implied by `sign` in `from_tokens`; it is // spelled out here because this list documents what the driver needs. let exec = AgentRpc::new( cfg.agent.base_url.clone(), cfg.agent.host_label.clone(), CapabilitySet::from_tokens( ["build", "sign", "notarize", "staple"], ["gatekeeper", "artifact"], ), ); // Fail fast if the agent isn't reachable / in-session. let health = exec.health().await.context( "agent /health failed — is ops-agent running in the Aqua session on the build host?", )?; tracing::info!(actuate = ?health.actuate, "agent reachable"); println!( "==> releasing {} {} via {}", plan.app, plan.version, cfg.agent.host_label ); let mut sink = StdoutSink; let outcome = run_release(&exec, &plan, &mut sink).await?; if !outcome.gatekeeper_accepted { anyhow::bail!( "Gatekeeper did NOT accept {} — not pulling. Check the notarization log.", outcome.dmg_remote ); } println!("\n==> Gatekeeper accepted; pulling DMG"); tokio::fs::create_dir_all(&cfg.local.dest_dir) .await .with_context(|| format!("creating dest dir {}", cfg.local.dest_dir.display()))?; let local_dmg = cfg.local.dest_dir.join(&dmg_name); // One file, and an already-compressed one: `precompressed` skips rsync's -z // if this ever runs over SshExec (AgentRpc streams and ignores the opts). exec.pull_file( std::path::Path::new(&outcome.dmg_remote), &local_dmg, &ops_exec::SyncOpts::precompressed(), ) .await .context("pulling the signed DMG back")?; println!("==> done: {}", local_dmg.display()); Ok(()) } struct Args { config: String, version: Option, } impl Args { fn parse() -> Result { let mut config = None; let mut version = None; let mut it = std::env::args().skip(1); while let Some(a) = it.next() { match a.as_str() { "--config" | "-c" => config = it.next(), "--version" | "-v" => version = it.next(), other => anyhow::bail!( "unexpected arg `{other}` (usage: --config [--version ])" ), } } Ok(Self { config: config.context("--config is required")?, version, }) } }