Skip to main content

choir_cli/
upgrade.rs

1//! `choir node upgrade` — newer binaries into the place the unit runs
2//! them from, a restart, and the running node's own word that it took.
3//!
4//! Deploying used to be five commands retyped over ssh, and the product
5//! half of them is the half that happens *on the node host*: get the
6//! binaries, put them where the service manager's unit points, restart,
7//! and read the build stamp back from the daemon rather than trusting
8//! that the restart did anything. That half is this command. The ssh
9//! hop that types it stays outside `choir`, because a hostname and an
10//! identity are the operator's private layout, and nothing in this
11//! binary ever runs `ssh`.
12//!
13//! # Two sources
14//!
15//! | you have | run | what happens |
16//! |:--|:--|:--|
17//! | a node with a shelf | `choir node upgrade --from https://<node>` | its `install.sh`, into the directory this `choir` lives in |
18//! | a checkout | `choir node upgrade --source <dir>` | `cargo build --release` there, then the four binaries copied over |
19//!
20//! Either way the copy is a rename over the old file, so a running
21//! daemon keeps the inode it was started from until the restart, and a
22//! build that fails leaves the old binaries where they were.
23//!
24//! # Examples
25//!
26//! ```
27//! use choir_cli::upgrade::stamp_of;
28//!
29//! // `choir --version` prints the build line; the stamp is its commit.
30//! assert_eq!(
31//!     stamp_of("choir build 0123456789ab (stamp source: git)"),
32//!     Some("0123456789ab".to_string())
33//! );
34//! assert_eq!(stamp_of("choir build unknown (stamp source: none)"), None);
35//! ```
36
37use std::path::{Path, PathBuf};
38
39/// The binaries a release ships, and the ones a checkout builds.
40pub const BINARIES: &[&str] = &["choir", "choir-node", "choir-mcp", "choir-ssh"];
41
42/// Where the newer binaries come from.
43#[derive(Debug, Clone, Eq, PartialEq)]
44pub enum Source {
45    /// A node's `/download/` shelf: its installer, run.
46    Shelf(String),
47    /// A checkout of this workspace: `cargo build --release` there.
48    Checkout(PathBuf),
49}
50
51/// What `choir node upgrade` was asked to do.
52#[derive(Debug, Clone, Eq, PartialEq)]
53pub struct Options {
54    /// Where from.
55    pub source: Source,
56    /// Where to: the directory the unit's `choir` lives in, unless
57    /// `--into` names another.
58    pub into: Option<PathBuf>,
59    /// The state directory, for the node's address and credential.
60    pub state: PathBuf,
61    /// Print the plan and do nothing.
62    pub dry_run: bool,
63}
64
65/// Parses `choir node upgrade`'s arguments.
66///
67/// # Errors
68///
69/// Returns a usage sentence when no source is named, both are, or an
70/// option is unknown.
71pub fn parse(rest: &[&str], default_state: PathBuf) -> Result<Options, String> {
72    let mut from: Option<String> = None;
73    let mut source: Option<PathBuf> = None;
74    let mut into: Option<PathBuf> = None;
75    let mut state: Option<PathBuf> = None;
76    let mut dry_run = false;
77    let mut i = 0;
78    while i < rest.len() {
79        let arg = rest[i];
80        let value = || -> Result<String, String> {
81            rest.get(i + 1)
82                .map(|v| (*v).to_string())
83                .ok_or_else(|| format!("{arg} needs a value"))
84        };
85        match arg {
86            "--dry-run" => {
87                dry_run = true;
88                i += 1;
89                continue;
90            }
91            "--from" => from = Some(value()?),
92            "--source" => source = Some(PathBuf::from(value()?)),
93            "--into" => into = Some(PathBuf::from(value()?)),
94            "--state" => state = Some(PathBuf::from(value()?)),
95            other => return Err(format!("unknown option {other:?}")),
96        }
97        i += 2;
98    }
99    let source = match (from, source) {
100        (Some(_), Some(_)) => {
101            return Err(
102                "--from and --source are two answers to one question. Give one.".to_string(),
103            )
104        }
105        (Some(url), None) => {
106            if !(url.starts_with("http://") || url.starts_with("https://")) {
107                return Err(format!(
108                    "--from is a node URL, http:// or https://, not {url:?}"
109                ));
110            }
111            Source::Shelf(url.trim_end_matches('/').to_string())
112        }
113        (None, Some(dir)) => Source::Checkout(absolute(dir)),
114        (None, None) => {
115            return Err("where do the newer binaries come from?\n\n    \
116                        choir node upgrade --from https://<node>     its shelf\n    \
117                        choir node upgrade --source <checkout>      built there"
118                .to_string())
119        }
120    };
121    Ok(Options {
122        source,
123        into: into.map(absolute),
124        state: absolute(state.unwrap_or(default_state)),
125        dry_run,
126    })
127}
128
129fn absolute(path: PathBuf) -> PathBuf {
130    std::path::absolute(&path).unwrap_or(path)
131}
132
133/// The commit a `choir --version` line names, or `None` for an
134/// unstamped build.
135#[must_use]
136pub fn stamp_of(version_line: &str) -> Option<String> {
137    let word = version_line
138        .split_whitespace()
139        .skip_while(|w| *w != "build")
140        .nth(1)?;
141    let word = word.trim_end_matches("+dirty");
142    (word.len() >= 7 && word.chars().all(|c| c.is_ascii_hexdigit())).then(|| word.to_string())
143}
144
145/// What a `choir` binary says it is: the whole line, and its commit.
146#[must_use]
147pub fn version_of(choir: &Path) -> Option<(String, Option<String>)> {
148    let out = std::process::Command::new(choir)
149        .arg("--version")
150        .output()
151        .ok()?;
152    if !out.status.success() {
153        return None;
154    }
155    let line = String::from_utf8_lossy(&out.stdout).trim().to_string();
156    let stamp = stamp_of(&line);
157    Some((line, stamp))
158}
159
160/// The directory the installer will fill, for a shelf source.
161///
162/// The installer writes `$CARGO_HOME/bin`, so the target has to be a
163/// directory *called* `bin`; its parent is what the installer is told.
164///
165/// # Errors
166///
167/// Names the directory when it is not a `bin`.
168pub fn cargo_home_for(into: &Path) -> Result<PathBuf, String> {
169    match (into.file_name(), into.parent()) {
170        (Some(name), Some(parent)) if name == "bin" => Ok(parent.to_path_buf()),
171        _ => Err(format!(
172            "the installer places binaries in a directory called `bin`, and this \
173             choir lives in {}\n\n  \
174             build from a checkout instead: choir node upgrade --source <dir>\n  \
175             or name a bin directory:        choir node upgrade --from <node> --into <dir>/bin",
176            into.display()
177        )),
178    }
179}
180
181/// Copies each binary a build produced over the installed one, by
182/// rename, so a running process keeps its inode and a copy cut short
183/// leaves the old binary in place.
184///
185/// Returns the names placed.
186///
187/// # Errors
188///
189/// Returns a description when nothing was built, or a copy failed.
190pub fn place(built: &Path, into: &Path) -> Result<Vec<String>, String> {
191    std::fs::create_dir_all(into).map_err(|e| format!("create {}: {e}", into.display()))?;
192    let mut placed = Vec::new();
193    for name in BINARIES {
194        let from = built.join(name);
195        if !from.is_file() {
196            continue;
197        }
198        let staged = into.join(format!(".{name}.new"));
199        std::fs::copy(&from, &staged)
200            .map_err(|e| format!("copy {} to {}: {e}", from.display(), staged.display()))?;
201        std::fs::rename(&staged, into.join(name))
202            .map_err(|e| format!("place {}: {e}", into.join(name).display()))?;
203        placed.push((*name).to_string());
204    }
205    if placed.is_empty() {
206        return Err(format!(
207            "nothing to place: none of {} is in {}",
208            BINARIES.join(", "),
209            built.display()
210        ));
211    }
212    Ok(placed)
213}
214
215/// Builds the two packages in a checkout with the stamp set from its
216/// HEAD, and returns the release directory.
217///
218/// `cd` rather than `--manifest-path`: rustup resolves
219/// `rust-toolchain.toml` from the working directory, and the other form
220/// fails on a box whose account has no default toolchain, with an error
221/// naming rustup rather than the flag that caused it.
222///
223/// # Errors
224///
225/// Returns cargo's or git's own words when either refuses.
226pub fn build(checkout: &Path) -> Result<(PathBuf, String), String> {
227    let head = std::process::Command::new("git")
228        .args(["-C", &checkout.display().to_string(), "rev-parse", "HEAD"])
229        .output()
230        .map_err(|e| format!("could not run git: {e}"))?;
231    if !head.status.success() {
232        return Err(format!(
233            "{} is not a git checkout: {}",
234            checkout.display(),
235            String::from_utf8_lossy(&head.stderr).trim()
236        ));
237    }
238    let head = String::from_utf8_lossy(&head.stdout).trim().to_string();
239    let status = std::process::Command::new("cargo")
240        .args(["build", "--release", "-p", "choir-node", "-p", "choir-cli"])
241        .current_dir(checkout)
242        .env("CHOIR_GIT_HEAD", &head)
243        .status()
244        .map_err(|e| format!("could not run cargo: {e}"))?;
245    if !status.success() {
246        return Err("cargo build failed; the old binaries are untouched".to_string());
247    }
248    Ok((checkout.join("target").join("release"), head))
249}
250
251/// Runs a node's installer for both packages, into `cargo_home/bin`.
252///
253/// The script is fetched to a file and run from it rather than piped,
254/// so what ran is on disk to read afterwards.
255///
256/// # Errors
257///
258/// Returns the installer's own words when it refuses.
259pub fn install_from_shelf(node: &str, cargo_home: &Path) -> Result<String, String> {
260    let script = std::env::temp_dir().join(format!("choir-install-{}.sh", std::process::id()));
261    let fetched = std::process::Command::new("curl")
262        .args([
263            "-fsSL",
264            &format!("{node}/download/install.sh"),
265            "-o",
266            &script.display().to_string(),
267        ])
268        .output()
269        .map_err(|e| format!("could not run curl: {e}"))?;
270    if !fetched.status.success() {
271        return Err(format!(
272            "could not fetch {node}/download/install.sh: {}",
273            String::from_utf8_lossy(&fetched.stderr).trim()
274        ));
275    }
276    let out = std::process::Command::new("sh")
277        .arg(&script)
278        .args(["choir-cli", "choir-node"])
279        .env("CARGO_HOME", cargo_home)
280        .output()
281        .map_err(|e| format!("could not run sh: {e}"))?;
282    std::fs::remove_file(&script).ok();
283    let text = format!(
284        "{}{}",
285        String::from_utf8_lossy(&out.stdout),
286        String::from_utf8_lossy(&out.stderr)
287    );
288    if !out.status.success() {
289        return Err(format!("the installer refused:\n{}", text.trim()));
290    }
291    Ok(text.trim().to_string())
292}