1use 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#[derive(Debug, PartialEq, Eq)]
26pub enum SelfUpdateCommand {
27 Run {
29 version: String,
30 url: String,
31 sha256: String,
32 allow_http_loopback: bool,
33 state_file: Option<String>,
34 },
35 Recover { state_file: Option<String> },
37 Confirm { state_file: Option<String> },
39}
40
41pub 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 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 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
123fn 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 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
188async 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;