Skip to main content

mj_controller/
checkpoint_transfer.rs

1//! Controller-side checkpoint transport and verified teardown gates.
2use 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};
8/// Export by streaming the spec to the worker's standard input.
9///
10/// Every wrapper this builds keeps the target's stdin attached: the container
11/// engines are invoked with `exec -i` and `ssh` forwards stdin by default.
12pub 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/// Unforgeable outside this module: proof that a controller-local archive has
92/// the exact digest reported by the target after its atomic install.
93#[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        // The bytes were already checksum-verified in this same directory and
170        // the rename is atomic, so installation only has to make the copy
171        // private and durable; re-reading it would hash the same archive again.
172        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
197/// SSH container transfers use a host-side copy because `scp` cannot address
198/// a path inside the container. The copy is disposable and must be removed as
199/// soon as it has been downloaded; the in-container archive remains gated by
200/// [`CheckpointTransfer::cleanup_plan`] until the local copy is verified.
201fn 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;