1use crate::targets::{CommandExecutor, CommandPlan, CommandSpec, TargetLocator, worker_root};
3use anyhow::{Context, Result, bail, ensure};
4use mj_checkpoint::archive::validate_component;
5use mj_checkpoint::checkpoint::*;
6use std::fs;
7use std::path::{Path, PathBuf};
8pub fn export_stdin_command(locator: &TargetLocator, session_id: &str) -> Result<CommandSpec> {
13 export_command(locator, session_id, EXPORT_SPEC_STDIN)
14}
15
16pub fn capture_stdin_command(locator: &TargetLocator, session_id: &str) -> Result<CommandSpec> {
17 checkpoint_stdin_command(
18 locator,
19 session_id,
20 "capture-checkpoint",
21 "capture target checkpoint",
22 )
23}
24
25pub fn pack_stdin_command(locator: &TargetLocator, session_id: &str) -> Result<CommandSpec> {
26 checkpoint_stdin_command(
27 locator,
28 session_id,
29 "pack-checkpoint",
30 "pack target checkpoint",
31 )
32}
33
34fn checkpoint_stdin_command(
35 locator: &TargetLocator,
36 session_id: &str,
37 subcommand: &str,
38 purpose: &str,
39) -> Result<CommandSpec> {
40 let root = worker_root(locator, session_id)?;
41 let args = vec![format!("{root}/hel"), "worker".into(), subcommand.into()];
42 crate::targets::command_on_locator(locator, session_id, args, purpose)
43}
44
45pub fn export_command(
46 locator: &TargetLocator,
47 session_id: &str,
48 spec_path: &str,
49) -> Result<CommandSpec> {
50 validate_remote_path(spec_path)?;
51 let root = worker_root(locator, session_id)?;
52 let args = vec![
53 format!("{root}/hel"),
54 "worker".into(),
55 "export-checkpoint".into(),
56 "--spec".into(),
57 spec_path.into(),
58 ];
59 crate::targets::command_on_locator(locator, session_id, args, "export target checkpoint")
60}
61
62pub fn restore_command(
63 locator: &TargetLocator,
64 session_id: &str,
65 spec_path: &str,
66) -> Result<CommandSpec> {
67 validate_remote_path(spec_path)?;
68 let root = worker_root(locator, session_id)?;
69 let args = vec![
70 format!("{root}/hel"),
71 "worker".into(),
72 "restore-checkpoint".into(),
73 "--spec".into(),
74 spec_path.into(),
75 ];
76 crate::targets::command_on_locator(locator, session_id, args, "restore target checkpoint")
77}
78
79#[derive(Debug, Clone)]
80pub struct CheckpointTransfer<'a> {
81 pub locator: &'a TargetLocator,
82 pub session_id: &'a str,
83 pub operation_id: &'a str,
84 pub remote_archive: &'a str,
85 pub destination: &'a Path,
86 pub expected_sha256: &'a str,
87 pub expected_event_frontier: u64,
88 pub expected_event_frontier_digest: &'a str,
89}
90
91#[derive(Debug, Clone, PartialEq, Eq)]
94pub struct VerifiedCheckpoint {
95 session_id: String,
96 archive_path: PathBuf,
97 sha256: String,
98 event_frontier: u64,
99 event_frontier_digest: String,
100}
101
102impl VerifiedCheckpoint {
103 pub fn archive_path(&self) -> &Path {
104 &self.archive_path
105 }
106 pub fn sha256(&self) -> &str {
107 &self.sha256
108 }
109 pub fn event_frontier(&self) -> u64 {
110 self.event_frontier
111 }
112 pub fn event_frontier_digest(&self) -> &str {
113 &self.event_frontier_digest
114 }
115 pub const fn teardown_allowed(&self) -> bool {
116 true
117 }
118}
119
120impl CheckpointTransfer<'_> {
121 pub fn execute(&self, executor: &impl CommandExecutor) -> Result<VerifiedCheckpoint> {
122 validate_remote_path(self.remote_archive)?;
123 let parent = self.destination.parent().unwrap_or_else(|| Path::new("."));
124 fs::create_dir_all(parent)?;
125 let temporary = tempfile::Builder::new()
126 .prefix(".hel-checkpoint-")
127 .tempfile_in(parent)?;
128 let path = temporary.path().to_path_buf();
129 let staging = remote_staging_path(self.session_id, self.operation_id)?;
130 let transfer_result = transfer_plan(
131 self.locator,
132 self.session_id,
133 self.remote_archive,
134 &path,
135 &staging,
136 )?
137 .execute(executor)
138 .context("download target checkpoint");
139 let staging_cleanup_result = cleanup_transfer_staging(self.locator, &staging, executor);
140 if let Err(error) = transfer_result {
141 return match staging_cleanup_result {
142 Ok(()) => Err(error),
143 Err(cleanup) => Err(error.context(format!(
144 "clean target checkpoint host staging also failed: {cleanup:#}"
145 ))),
146 };
147 }
148 staging_cleanup_result.context("clean target checkpoint host staging")?;
149 let sha256 = checkpoint_sha256(&path).context("hash downloaded checkpoint")?;
150 ensure!(
151 sha256 == self.expected_sha256,
152 "target and controller checkpoint checksums differ for complete checkpoint archive: \
153 session={}, operation={}, expected_sha256={}, downloaded_sha256={}, downloaded_bytes={}; \
154 target archive retained at {}. The previous verified checkpoint was not replaced and \
155 the source workspace was not removed. Preserve the source and retry a fresh export; \
156 if this repeats, inspect the retained archive and its transfer path",
157 self.session_id,
158 self.operation_id,
159 self.expected_sha256,
160 sha256,
161 fs::metadata(&path)
162 .context("stat downloaded checkpoint")?
163 .len(),
164 self.remote_archive,
165 );
166 temporary
167 .persist(self.destination)
168 .map_err(|error| error.error)?;
169 let post_install = (|| -> Result<()> {
173 restrict_permissions(self.destination)?;
174 sync_directory(parent)
175 })();
176 if let Err(error) = post_install {
177 return Err(remove_failed_checkpoint_install(self.destination, error));
178 }
179 Ok(VerifiedCheckpoint {
180 session_id: self.session_id.to_owned(),
181 archive_path: self.destination.to_path_buf(),
182 sha256,
183 event_frontier: self.expected_event_frontier,
184 event_frontier_digest: self.expected_event_frontier_digest.to_owned(),
185 })
186 }
187
188 pub fn cleanup_plan(&self, gate: &VerifiedCheckpoint) -> Result<CommandPlan> {
189 ensure!(
190 gate.session_id == self.session_id,
191 "checkpoint gate belongs to another session"
192 );
193 cleanup_plan(self.locator, self.session_id, self.remote_archive)
194 }
195}
196
197fn cleanup_transfer_staging(
202 locator: &TargetLocator,
203 staging: &str,
204 executor: &impl CommandExecutor,
205) -> Result<()> {
206 validate_remote_path(staging)?;
207 let command = match locator {
208 TargetLocator::SshPodman { ssh, .. } | TargetLocator::SshDocker { ssh, .. } => Some(
209 crate::targets::ssh_command(ssh, ["rm", "-f", "--", staging])
210 .purpose("remove remote checkpoint staging"),
211 ),
212 _ => None,
213 };
214 if let Some(command) = command {
215 let output = executor.execute(&command)?;
216 if output.status != 0 {
217 bail!(
218 "{} failed with status {}: {}",
219 command.purpose,
220 output.status,
221 String::from_utf8_lossy(&output.stderr)
222 );
223 }
224 }
225 Ok(())
226}
227
228pub fn transfer_plan(
229 locator: &TargetLocator,
230 session_id: &str,
231 remote_archive: &str,
232 local_temporary: &Path,
233 staging: &str,
234) -> Result<CommandPlan> {
235 validate_remote_path(remote_archive)?;
236 validate_remote_path(staging)?;
237 ensure!(
238 local_temporary.is_absolute(),
239 "local temporary path must be absolute"
240 );
241 worker_root(locator, session_id)?;
242 let local = local_temporary.to_string_lossy().into_owned();
243 let mut commands = match locator {
244 TargetLocator::LocalBare { .. } => vec![
245 CommandSpec::new("cp", [remote_archive, local.as_str()])
246 .purpose("copy local bare checkpoint"),
247 ],
248 TargetLocator::LocalPodman { container_id, .. } => vec![
249 CommandSpec::new(
250 "podman",
251 ["cp", &format!("{container_id}:{remote_archive}"), &local],
252 )
253 .purpose("download checkpoint from local Podman"),
254 ],
255 TargetLocator::LocalDocker { container_id, .. } => vec![
256 CommandSpec::new(
257 "docker",
258 ["cp", &format!("{container_id}:{remote_archive}"), &local],
259 )
260 .purpose("download checkpoint from local Docker"),
261 ],
262 TargetLocator::AppleContainer { container_id, .. } => vec![
263 CommandSpec::new(
264 "container",
265 ["cp", &format!("{container_id}:{remote_archive}"), &local],
266 )
267 .purpose("download checkpoint from Apple container"),
268 ],
269 TargetLocator::AwsEc2 { ssh, .. } | TargetLocator::SshBare { ssh, .. } => {
270 vec![
271 crate::targets::scp_download(ssh, remote_archive, &local)
272 .purpose("download checkpoint over SSH"),
273 ]
274 }
275 TargetLocator::SshPodman {
276 ssh, container_id, ..
277 }
278 | TargetLocator::SshDocker {
279 ssh, container_id, ..
280 } => {
281 vec![
282 crate::targets::ssh_command(ssh, ["mkdir", "-p", ".local/share/hel/transfers"])
283 .purpose("create remote checkpoint staging directory"),
284 crate::targets::ssh_command(
285 ssh,
286 [
287 locator.container_engine().expect("remote container"),
288 "cp",
289 &format!("{container_id}:{remote_archive}"),
290 staging,
291 ],
292 )
293 .purpose("stage remote container checkpoint"),
294 ]
295 }
296 };
297 if let TargetLocator::SshPodman { ssh, .. } | TargetLocator::SshDocker { ssh, .. } = locator {
298 commands.push(
299 crate::targets::scp_download(ssh, staging, &local)
300 .purpose("download remote container checkpoint over SSH"),
301 );
302 }
303 Ok(CommandPlan {
304 description: format!("download checkpoint for {session_id}"),
305 commands,
306 })
307}
308
309fn cleanup_plan(locator: &TargetLocator, session_id: &str, remote: &str) -> Result<CommandPlan> {
310 validate_remote_path(remote)?;
311 worker_root(locator, session_id)?;
312 let commands = vec![
313 crate::targets::locator_command(
314 locator,
315 ["rm", "-f", "--", remote].map(str::to_owned).to_vec(),
316 )
317 .purpose("remove checkpoint staging"),
318 ];
319 Ok(CommandPlan {
320 description: format!("clean checkpoint for {session_id}"),
321 commands,
322 })
323}
324
325fn remote_staging_path(session_id: &str, operation_id: &str) -> Result<String> {
326 validate_component(session_id, "session ID")?;
327 validate_component(operation_id, "checkpoint operation ID")?;
328 Ok(format!(
329 ".local/share/hel/transfers/{session_id}-{operation_id}.hel.zip"
330 ))
331}
332
333fn validate_remote_path(path: &str) -> Result<()> {
334 ensure!(!path.is_empty());
335 ensure!(
336 path.bytes()
337 .all(|byte| byte.is_ascii_alphanumeric()
338 || matches!(byte, b'/' | b'~' | b'.' | b'-' | b'_')),
339 "unsafe remote path"
340 );
341 ensure!(
342 !path.split('/').any(|component| component == ".."),
343 "remote path traverses parent"
344 );
345 Ok(())
346}
347
348#[cfg(test)]
349mod tests;