Skip to main content

soma_cli/
self_update.rs

1//! `soma self-update` — operator-driven binary self-update (CLI infrastructure).
2//!
3//! Thin adapter over the shared `soma-self-update` transaction crate:
4//! `run` downloads, stages, validates, and installs a new binary over the
5//! running executable; `recover` reconciles pending transaction state after a
6//! restart; `confirm` finalizes an update once the restarted service is
7//! healthy. Like `doctor` and `watch`, this is process infrastructure, not a
8//! service action — it has no MCP or REST parity requirement.
9//!
10//! The operator supplies the directive (version, artifact URL, SHA-256) and is
11//! responsible for authenticating it — for example against a release page or a
12//! signed manifest — before typing it here. A digest fetched from the same
13//! server as the artifact proves transit integrity, not publisher identity.
14
15use std::path::{Path, PathBuf};
16
17use anyhow::{Context, Result, anyhow, bail};
18use soma_self_update::{
19    ArtifactTransportPolicy, ConfirmationOutcome, InstallOutcome, RecoveryAction, UpdateDirective,
20    UpdateLayout, UpdatePolicy, Updater,
21};
22use url::Url;
23
24/// Parsed `soma self-update` subcommand.
25#[derive(Debug, PartialEq, Eq)]
26pub enum SelfUpdateCommand {
27    /// Download, stage, validate, and install a new binary.
28    Run {
29        version: String,
30        url: String,
31        sha256: String,
32        allow_http_loopback: bool,
33        state_file: Option<String>,
34    },
35    /// Reconcile pending update state; call before entering normal service.
36    Recover { state_file: Option<String> },
37    /// Confirm a pending update after the restarted binary is healthy.
38    Confirm { state_file: Option<String> },
39}
40
41/// Dispatch a parsed self-update subcommand against the running executable.
42pub async fn run_self_update(command: SelfUpdateCommand, running_version: &str) -> Result<()> {
43    match command {
44        SelfUpdateCommand::Run {
45            version,
46            url,
47            sha256,
48            allow_http_loopback,
49            state_file,
50        } => {
51            let transport = transport_policy(allow_http_loopback);
52            let updater = build_updater(state_file, transport)?;
53            run_update(&updater, version, url, sha256, transport, running_version).await
54        }
55        SelfUpdateCommand::Recover { state_file } => {
56            let updater = build_updater(state_file, ArtifactTransportPolicy::HttpsOnly)?;
57            match updater.recover_on_startup(running_version).await? {
58                RecoveryAction::NoPendingUpdate => println!("no pending update"),
59                RecoveryAction::PendingUpdate {
60                    target,
61                    attempts,
62                    max_attempts,
63                } => println!(
64                    "pending update to {target} (unconfirmed startup {attempts}/{max_attempts}); \
65                     run `soma self-update confirm` once the service is healthy"
66                ),
67                RecoveryAction::RollbackInstalled {
68                    executable,
69                    restored_version,
70                } => println!(
71                    "rolled back to {restored_version}; restart {}",
72                    executable.display()
73                ),
74            }
75            Ok(())
76        }
77        SelfUpdateCommand::Confirm { state_file } => {
78            let updater = build_updater(state_file, ArtifactTransportPolicy::HttpsOnly)?;
79            // A failed confirmation repeats on every attempt (for example when
80            // the rollback backup is missing) and needs operator attention —
81            // surface it as a hard error, never retry silently.
82            match updater.confirm_success(running_version).await? {
83                ConfirmationOutcome::NoPendingUpdate => println!("no pending update to confirm"),
84                ConfirmationOutcome::Confirmed { version } => {
85                    println!("update to {version} confirmed; rollback backup removed");
86                }
87            }
88            Ok(())
89        }
90    }
91}
92
93fn transport_policy(allow_http_loopback: bool) -> ArtifactTransportPolicy {
94    if allow_http_loopback {
95        ArtifactTransportPolicy::HttpsOrLoopbackHttp
96    } else {
97        ArtifactTransportPolicy::HttpsOnly
98    }
99}
100
101fn build_updater(
102    state_file: Option<String>,
103    transport: ArtifactTransportPolicy,
104) -> Result<Updater> {
105    let executable =
106        std::env::current_exe().context("cannot resolve the running executable path")?;
107    // The transaction crate rejects symlinked executable leaves; canonicalize
108    // so an installation reached through a symlinked path still targets the
109    // real file.
110    let executable = std::fs::canonicalize(&executable)
111        .with_context(|| format!("cannot canonicalize {}", executable.display()))?;
112    let state_file = match state_file {
113        Some(path) => PathBuf::from(path),
114        None => default_state_file(&executable)?,
115    };
116    let policy = UpdatePolicy::default().with_transport(transport);
117    Ok(Updater::new(
118        UpdateLayout::new(executable, state_file),
119        policy,
120    ))
121}
122
123/// Default durable transaction marker path: a hidden sibling of the
124/// executable, so update state shares the directory (and durability domain)
125/// the installer already requires to be trusted and writable.
126fn default_state_file(executable: &Path) -> Result<PathBuf> {
127    let name = executable
128        .file_name()
129        .and_then(|name| name.to_str())
130        .ok_or_else(|| anyhow!("executable name must be valid UTF-8"))?;
131    Ok(executable.with_file_name(format!(".{name}.update-state.json")))
132}
133
134async fn run_update(
135    updater: &Updater,
136    version: String,
137    url: String,
138    sha256: String,
139    transport: ArtifactTransportPolicy,
140    running_version: &str,
141) -> Result<()> {
142    let directive = UpdateDirective::new(version, url, sha256)?;
143    // The operator supplies one absolute artifact URL, so it serves as its own
144    // same-origin endpoint for resolution and redirect validation.
145    let endpoint = Url::parse(directive.artifact_url())
146        .map_err(|error| anyhow!("--url must be an absolute URL: {error}"))?;
147    let artifact = directive.resolve_artifact_url(&endpoint, transport)?;
148    updater.preflight_stage()?;
149    let body = download(
150        &directive,
151        &endpoint,
152        &artifact,
153        transport,
154        updater.policy(),
155    )
156    .await?;
157    let staged = updater.stage(&body[..], &directive).await?;
158    let validated = updater.validate(staged).await?;
159    match updater.install(validated, running_version).await? {
160        InstallOutcome::RestartRequired {
161            executable,
162            from,
163            to,
164        } => {
165            println!(
166                "installed {to} over {from}; restart {} and run `soma self-update confirm` \
167                 once the service is healthy",
168                executable.display()
169            );
170        }
171        InstallOutcome::RestartRequiredIndeterminate {
172            executable,
173            from,
174            to,
175            error,
176        } => {
177            eprintln!("warning: install durability is indeterminate: {error}");
178            println!(
179                "installed {to} over {from}; restart {} — startup recovery will reconcile \
180                 the pending marker",
181                executable.display()
182            );
183        }
184    }
185    Ok(())
186}
187
188/// Download the artifact with redirects disabled, validating the final
189/// response URL and enforcing the policy size cap while streaming.
190async fn download(
191    directive: &UpdateDirective,
192    endpoint: &Url,
193    artifact: &Url,
194    transport: ArtifactTransportPolicy,
195    policy: &UpdatePolicy,
196) -> Result<Vec<u8>> {
197    let client = reqwest::Client::builder()
198        .redirect(reqwest::redirect::Policy::none())
199        .connect_timeout(std::time::Duration::from_secs(10))
200        .build()
201        .context("cannot build HTTP client")?;
202    let mut response = client
203        .get(artifact.clone())
204        .send()
205        .await
206        .with_context(|| format!("artifact request to {artifact} failed"))?;
207    if response.status().is_redirection() {
208        bail!(
209            "artifact URL redirected (HTTP {}); redirects are refused — pass the final URL to --url",
210            response.status()
211        );
212    }
213    if !response.status().is_success() {
214        bail!("artifact request returned HTTP {}", response.status());
215    }
216    directive.validate_artifact_response_url(endpoint, response.url(), transport)?;
217    let limit = policy.max_artifact_bytes();
218    let mut body = Vec::new();
219    while let Some(chunk) = response
220        .chunk()
221        .await
222        .with_context(|| format!("artifact download from {artifact} failed"))?
223    {
224        if body.len() as u64 + chunk.len() as u64 > limit {
225            bail!("artifact exceeds the {limit} byte policy limit");
226        }
227        body.extend_from_slice(&chunk);
228    }
229    Ok(body)
230}
231
232#[cfg(test)]
233#[path = "self_update_tests.rs"]
234mod tests;