Skip to main content

max / makenotwork

6.0 KB · 175 lines History Blame Raw
1 //! `bento-release-macos` — drive one macOS release through the in-session
2 //! `ops-agent` and pull the signed DMG back.
3 //!
4 //! Usage: `bento-release-macos --config driver.toml [--version 0.4.1]`
5 //!
6 //! This is the thin driver (launchplan §A decision (a)); the full bentod/TUI is
7 //! deferred. It is transport-agnostic via `ops-exec` — in production it points
8 //! at the Mac's `ops-agent` (`AgentRpc`), the only context where codesign can
9 //! use the Developer ID key.
10
11 use anyhow::{Context, Result};
12 use bento_driver::{ReleasePlan, StdoutSink, run_release};
13 use ops_exec::{AgentRpc, CapabilitySet, Executor};
14 use serde::Deserialize;
15 use std::path::PathBuf;
16
17 #[derive(Debug, Deserialize)]
18 struct DriverConfig {
19 agent: AgentSection,
20 plan: PlanSection,
21 /// Where on THIS host (fw13) to pull the signed DMG.
22 local: LocalSection,
23 }
24
25 #[derive(Debug, Deserialize)]
26 struct AgentSection {
27 /// e.g. `http://mbp.tailnet:8765`
28 base_url: String,
29 /// Audit label, e.g. `mbp`.
30 host_label: String,
31 }
32
33 #[derive(Debug, Deserialize)]
34 struct PlanSection {
35 app: String,
36 version: String,
37 repo_path: String,
38 env_file: String,
39 dist_root: String,
40 /// Optional; defaults to `<product>_<version>_aarch64.dmg`.
41 dmg_name: Option<String>,
42 /// Product name used for the default DMG name, e.g. `GoingsOn`.
43 product_name: String,
44 }
45
46 #[derive(Debug, Deserialize)]
47 struct LocalSection {
48 /// `~/...` is expanded here (against this machine's HOME) — unlike the
49 /// `[plan]` paths, which are on the build host and are rejected outright.
50 /// Without this, a `PathBuf` deserialized straight from `"~/Dist/goingson"`
51 /// stays literal and `create_dir_all` cheerfully makes a directory *named*
52 /// `~` under the cwd, quietly dropping the signed DMG somewhere the
53 /// operator will never look.
54 #[serde(deserialize_with = "de_local_path")]
55 dest_dir: PathBuf,
56 }
57
58 fn de_local_path<'de, D>(d: D) -> Result<PathBuf, D::Error>
59 where
60 D: serde::Deserializer<'de>,
61 {
62 let s = String::deserialize(d)?;
63 bento_driver::expand_local_tilde(&s).map_err(serde::de::Error::custom)
64 }
65
66 #[tokio::main]
67 async fn main() -> Result<()> {
68 tracing_subscriber::fmt()
69 .with_env_filter(
70 tracing_subscriber::EnvFilter::try_from_default_env()
71 .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")),
72 )
73 .init();
74
75 let args = Args::parse()?;
76 let raw = std::fs::read_to_string(&args.config)
77 .with_context(|| format!("reading {}", args.config))?;
78 let mut cfg: DriverConfig = toml::from_str(&raw).context("parsing driver config")?;
79 if let Some(v) = args.version {
80 cfg.plan.version = v;
81 }
82
83 let dmg_name = cfg.plan.dmg_name.clone().unwrap_or_else(|| {
84 ReleasePlan::default_dmg_name(&cfg.plan.product_name, &cfg.plan.version)
85 });
86 let plan = ReleasePlan {
87 app: cfg.plan.app.clone(),
88 version: cfg.plan.version.clone(),
89 repo_path: cfg.plan.repo_path.clone(),
90 env_file: cfg.plan.env_file.clone(),
91 dist_root: cfg.plan.dist_root.clone(),
92 dmg_name: dmg_name.clone(),
93 };
94 // Before touching the network: a bad path only surfaces after the agent is
95 // contacted and a step has already run on the build host.
96 plan.validate().context("driver config")?;
97
98 // The driver's caller-side grant: it may build/sign/notarize/staple, observe
99 // the gatekeeper verdict, and pull the artifact it just had built (`artifact`
100 // — the last step of the flow below). The agent re-checks against its own
101 // grant. Note `gatekeeper` is also implied by `sign` in `from_tokens`; it is
102 // spelled out here because this list documents what the driver needs.
103 let exec = AgentRpc::new(
104 cfg.agent.base_url.clone(),
105 cfg.agent.host_label.clone(),
106 CapabilitySet::from_tokens(
107 ["build", "sign", "notarize", "staple"],
108 ["gatekeeper", "artifact"],
109 ),
110 );
111
112 // Fail fast if the agent isn't reachable / in-session.
113 let health = exec.health().await.context(
114 "agent /health failed — is ops-agent running in the Aqua session on the build host?",
115 )?;
116 tracing::info!(actuate = ?health.actuate, "agent reachable");
117
118 println!(
119 "==> releasing {} {} via {}",
120 plan.app, plan.version, cfg.agent.host_label
121 );
122 let mut sink = StdoutSink;
123 let outcome = run_release(&exec, &plan, &mut sink).await?;
124 if !outcome.gatekeeper_accepted {
125 anyhow::bail!(
126 "Gatekeeper did NOT accept {} — not pulling. Check the notarization log.",
127 outcome.dmg_remote
128 );
129 }
130 println!("\n==> Gatekeeper accepted; pulling DMG");
131
132 tokio::fs::create_dir_all(&cfg.local.dest_dir)
133 .await
134 .with_context(|| format!("creating dest dir {}", cfg.local.dest_dir.display()))?;
135 let local_dmg = cfg.local.dest_dir.join(&dmg_name);
136 // One file, and an already-compressed one: `precompressed` skips rsync's -z
137 // if this ever runs over SshExec (AgentRpc streams and ignores the opts).
138 exec.pull_file(
139 std::path::Path::new(&outcome.dmg_remote),
140 &local_dmg,
141 &ops_exec::SyncOpts::precompressed(),
142 )
143 .await
144 .context("pulling the signed DMG back")?;
145
146 println!("==> done: {}", local_dmg.display());
147 Ok(())
148 }
149
150 struct Args {
151 config: String,
152 version: Option<String>,
153 }
154
155 impl Args {
156 fn parse() -> Result<Self> {
157 let mut config = None;
158 let mut version = None;
159 let mut it = std::env::args().skip(1);
160 while let Some(a) = it.next() {
161 match a.as_str() {
162 "--config" | "-c" => config = it.next(),
163 "--version" | "-v" => version = it.next(),
164 other => anyhow::bail!(
165 "unexpected arg `{other}` (usage: --config <toml> [--version <ver>])"
166 ),
167 }
168 }
169 Ok(Self {
170 config: config.context("--config <driver.toml> is required")?,
171 version,
172 })
173 }
174 }
175