Skip to main content

mvm_runtime/vm/template/
lifecycle.rs

1use anyhow::{Context, Result, anyhow};
2use mvm_core::template::{TemplateSpec, template_dir, template_spec_path};
3
4use crate::build_env::RuntimeBuildEnv;
5use crate::shell;
6use mvm_core::pool::ArtifactPaths;
7use mvm_core::template::{TemplateRevision, template_current_symlink, template_revision_dir};
8use mvm_core::time::utc_now;
9
10use super::registry::TemplateRegistry;
11
12/// Run a shell command in the VM and check its exit code.
13/// Returns an error with stderr context if the command fails.
14fn vm_exec(script: &str) -> Result<()> {
15    let out = shell::run_in_vm(script)?;
16    if !out.status.success() {
17        let stderr = String::from_utf8_lossy(&out.stderr).trim().to_string();
18        let first_line = script.lines().next().unwrap_or(script);
19        return Err(anyhow!(
20            "Command failed (exit {}): {}\n  command: {}",
21            out.status.code().unwrap_or(-1),
22            stderr,
23            first_line,
24        ));
25    }
26    Ok(())
27}
28
29/// Run a shell command in the VM, check exit code, and return stdout.
30fn vm_exec_stdout(script: &str) -> Result<String> {
31    let out = shell::run_in_vm(script)?;
32    if !out.status.success() {
33        let stderr = String::from_utf8_lossy(&out.stderr).trim().to_string();
34        let first_line = script.lines().next().unwrap_or(script);
35        return Err(anyhow!(
36            "Command failed (exit {}): {}\n  command: {}",
37            out.status.code().unwrap_or(-1),
38            stderr,
39            first_line,
40        ));
41    }
42    Ok(String::from_utf8_lossy(&out.stdout).trim().to_string())
43}
44
45pub fn template_create(spec: &TemplateSpec) -> Result<()> {
46    let dir = template_dir(&spec.template_id);
47    vm_exec(&format!("mkdir -p {dir}"))
48        .with_context(|| format!("Failed to create template directory {}", dir))?;
49    let path = template_spec_path(&spec.template_id);
50    let json = serde_json::to_string_pretty(spec)?;
51    vm_exec(&format!("cat > {path} << 'MVMEOF'\n{json}\nMVMEOF"))
52        .with_context(|| format!("Failed to write template spec {}", path))?;
53    Ok(())
54}
55
56pub fn template_load(id: &str) -> Result<TemplateSpec> {
57    let path = template_spec_path(id);
58    let data = vm_exec_stdout(&format!("cat {path}")).with_context(|| {
59        format!(
60            "Failed to load template {} (does it exist? try `mvm template list`)",
61            id
62        )
63    })?;
64    let spec: TemplateSpec =
65        serde_json::from_str(&data).with_context(|| format!("Corrupt template {}", id))?;
66    Ok(spec)
67}
68
69pub fn template_list() -> Result<Vec<String>> {
70    let base = mvm_core::template::templates_base_dir();
71    let out = shell::run_in_vm_stdout(&format!("ls -1 {base} 2>/dev/null || true"))?
72        .trim()
73        .to_string();
74    Ok(out
75        .lines()
76        .filter(|l| !l.is_empty())
77        .map(|s| s.to_string())
78        .collect())
79}
80
81pub fn template_delete(id: &str, force: bool) -> Result<()> {
82    let dir = template_dir(id);
83    let flag = if force { "-rf" } else { "-r" };
84    vm_exec(&format!("rm {flag} {dir}"))
85        .with_context(|| format!("Failed to delete template {}", id))?;
86    Ok(())
87}
88
89/// Initialize an on-disk template directory layout (empty artifacts, no spec).
90/// Safe to call multiple times; existing contents are preserved.
91pub fn template_init(id: &str) -> Result<()> {
92    let dir = template_dir(id);
93    let artifacts = format!("{}/artifacts/revisions", dir);
94    vm_exec(&format!("mkdir -p {dir} {artifacts}"))
95        .with_context(|| format!("Failed to initialize template directory {}", dir))?;
96    Ok(())
97}
98
99/// Build a template using the dev build pipeline (local Nix in Lima).
100/// Artifacts are stored in ~/.mvm/templates/<id>/artifacts and the current symlink is updated.
101pub fn template_build(id: &str, force: bool) -> Result<()> {
102    let spec = template_load(id)?;
103    let env = RuntimeBuildEnv;
104
105    // Use dev_build to produce artifacts via Nix in Lima
106    let result = if force {
107        // Force: remove any cached artifacts to trigger a fresh build
108        let data_dir = mvm_core::config::mvm_data_dir();
109        let cache_dir = format!("{}/dev/builds/{}", data_dir, spec.flake_ref);
110        let _ = shell::run_in_vm(&format!("rm -rf {cache_dir}"));
111        mvm_build::dev_build::dev_build(&env, &spec.flake_ref, Some(&spec.profile))?
112    } else {
113        mvm_build::dev_build::dev_build(&env, &spec.flake_ref, Some(&spec.profile))?
114    };
115    mvm_build::dev_build::ensure_guest_agent_if_needed(&env, &result)?;
116
117    // Store artifacts in template revision directory
118    let rev = &result.revision_hash;
119    let rev_dst = template_revision_dir(id, rev);
120    shell::run_in_vm(&format!("mkdir -p {rev_dst}"))?;
121    shell::run_in_vm(&format!("cp -a {} {rev_dst}/vmlinux", result.vmlinux_path))?;
122    if let Some(initrd) = &result.initrd_path {
123        shell::run_in_vm(&format!("cp -a {} {rev_dst}/initrd", initrd))?;
124    }
125    shell::run_in_vm(&format!(
126        "cp -a {} {rev_dst}/rootfs.ext4",
127        result.rootfs_path
128    ))?;
129
130    // Generate a minimal fc-base.json config for reference
131    let mut boot_source = serde_json::json!({
132        "kernel_image_path": "vmlinux",
133        "boot_args": "console=ttyS0 reboot=k panic=1 net.ifnames=0"
134    });
135    if result.initrd_path.is_some() {
136        boot_source["initrd_path"] = serde_json::json!("initrd");
137    }
138    let fc_config = serde_json::json!({
139        "boot-source": boot_source,
140        "drives": [{
141            "drive_id": "rootfs",
142            "path_on_host": "rootfs.ext4",
143            "is_root_device": true,
144            "is_read_only": false
145        }],
146        "machine-config": {
147            "vcpu_count": spec.vcpus,
148            "mem_size_mib": spec.mem_mib
149        }
150    });
151    let fc_json = serde_json::to_string_pretty(&fc_config)?;
152    shell::run_in_vm(&format!(
153        "cat > {rev_dst}/fc-base.json << 'MVMEOF'\n{fc_json}\nMVMEOF"
154    ))?;
155
156    // Update template current symlink
157    let current_link = template_current_symlink(id);
158    shell::run_in_vm(&format!("ln -snf revisions/{rev} {current_link}"))?;
159
160    // Compute actual flake.lock hash for accurate cache keys.
161    // Pool builds do this via the backend; template builds use dev_build directly,
162    // so we compute it here. Falls back to revision hash for remote flakes.
163    let flake_lock_hash = shell::run_in_vm_stdout(&format!(
164        "if [ -f {flake}/flake.lock ]; then nix hash path {flake}/flake.lock; else echo ''; fi",
165        flake = spec.flake_ref
166    ))
167    .unwrap_or_default()
168    .trim()
169    .to_string();
170    let flake_lock_hash = if flake_lock_hash.is_empty() {
171        rev.clone()
172    } else {
173        flake_lock_hash
174    };
175
176    // Record template revision metadata
177    let revision = TemplateRevision {
178        revision_hash: rev.clone(),
179        flake_ref: spec.flake_ref.clone(),
180        flake_lock_hash,
181        artifact_paths: ArtifactPaths {
182            vmlinux: "vmlinux".to_string(),
183            rootfs: "rootfs.ext4".to_string(),
184            fc_base_config: "fc-base.json".to_string(),
185        },
186        built_at: utc_now(),
187        profile: spec.profile.clone(),
188        role: spec.role.clone(),
189        vcpus: spec.vcpus,
190        mem_mib: spec.mem_mib,
191        data_disk_mib: spec.data_disk_mib,
192    };
193    let rev_json = serde_json::to_string_pretty(&revision)?;
194    let rev_meta_path = format!("{rev_dst}/revision.json");
195    shell::run_in_vm(&format!(
196        "cat > {rev_meta_path} << 'MVMEOF'\n{rev_json}\nMVMEOF"
197    ))?;
198
199    Ok(())
200}
201
202#[derive(Debug, serde::Serialize, serde::Deserialize)]
203struct Checksums {
204    template_id: String,
205    revision_hash: String,
206    files: std::collections::BTreeMap<String, String>,
207}
208
209fn require_local_template_fs() -> Result<()> {
210    // Registry push/pull needs direct file access to ~/.mvm/templates.
211    // On macOS, templates live inside Lima; run these commands inside the VM.
212    if mvm_core::platform::current().needs_lima() && !crate::shell::inside_lima() {
213        anyhow::bail!(
214            "template push/pull/verify must be run inside the Linux VM (try `mvm shell`, then rerun)"
215        );
216    }
217    Ok(())
218}
219
220fn current_revision_id(template_id: &str) -> Result<String> {
221    use std::os::unix::ffi::OsStrExt;
222
223    let link = template_current_symlink(template_id);
224    let target = std::fs::read_link(&link)
225        .with_context(|| format!("Template has no current revision: {}", template_id))?;
226    let raw = target.as_os_str().as_bytes();
227    let raw = std::str::from_utf8(raw)
228        .unwrap_or_default()
229        .trim()
230        .to_string();
231    let rev = raw.strip_prefix("revisions/").unwrap_or(&raw).to_string();
232    if rev.is_empty() {
233        anyhow::bail!("Template current symlink is empty: {}", link);
234    }
235    Ok(rev)
236}
237
238fn sha256_hex(path: &std::path::Path) -> Result<String> {
239    use sha2::Digest;
240
241    let bytes =
242        std::fs::read(path).with_context(|| format!("Failed to read {}", path.display()))?;
243    let mut hasher = sha2::Sha256::new();
244    hasher.update(&bytes);
245    Ok(format!("{:x}", hasher.finalize()))
246}
247
248pub fn template_push(id: &str, revision: Option<&str>) -> Result<()> {
249    require_local_template_fs()?;
250    let registry = TemplateRegistry::from_env()?.context("Template registry not configured")?;
251    registry.require_configured()?;
252
253    let rev = match revision {
254        Some(r) => r.to_string(),
255        None => current_revision_id(id)?,
256    };
257
258    let template_dir = template_dir(id);
259    let rev_dir = std::path::PathBuf::from(template_revision_dir(id, &rev));
260
261    let files = [
262        (
263            "template.json",
264            std::path::PathBuf::from(format!("{}/template.json", template_dir)),
265        ),
266        ("revision.json", rev_dir.join("revision.json")),
267        ("vmlinux", rev_dir.join("vmlinux")),
268        ("rootfs.ext4", rev_dir.join("rootfs.ext4")),
269        ("fc-base.json", rev_dir.join("fc-base.json")),
270    ];
271
272    // Compute checksums for integrity.
273    let mut sums = std::collections::BTreeMap::new();
274    for (name, path) in &files {
275        let hex = sha256_hex(path)?;
276        sums.insert(name.to_string(), hex);
277    }
278    let checksums = Checksums {
279        template_id: id.to_string(),
280        revision_hash: rev.clone(),
281        files: sums,
282    };
283    let checksums_json = serde_json::to_vec_pretty(&checksums)?;
284    // Store checksums locally alongside the revision so `template verify` works offline.
285    std::fs::write(rev_dir.join("checksums.json"), &checksums_json).with_context(|| {
286        format!(
287            "Failed to write checksums.json for template {} revision {}",
288            id, rev
289        )
290    })?;
291
292    // Upload revision objects first, then current pointer.
293    for (name, path) in &files {
294        let key = registry.key_revision_file(id, &rev, name);
295        let data =
296            std::fs::read(path).with_context(|| format!("Failed to read {}", path.display()))?;
297        registry.put_bytes(&key, data)?;
298    }
299    registry.put_bytes(
300        &registry.key_revision_file(id, &rev, "checksums.json"),
301        checksums_json,
302    )?;
303    registry.put_text(&registry.key_current(id), &format!("{}\n", rev))?;
304
305    tracing::info!(template = %id, revision = %rev, "Pushed template revision to registry");
306    Ok(())
307}
308
309pub fn template_pull(id: &str, revision: Option<&str>) -> Result<()> {
310    require_local_template_fs()?;
311    let registry = TemplateRegistry::from_env()?.context("Template registry not configured")?;
312    registry.require_configured()?;
313
314    let rev = match revision {
315        Some(r) => r.to_string(),
316        None => registry
317            .get_text(&registry.key_current(id))?
318            .trim()
319            .to_string(),
320    };
321    if rev.is_empty() {
322        anyhow::bail!("Registry current revision is empty for template {}", id);
323    }
324
325    // Download checksums first.
326    let sums_key = registry.key_revision_file(id, &rev, "checksums.json");
327    let sums_bytes = registry.get_bytes(&sums_key)?;
328    let checksums: Checksums = serde_json::from_slice(&sums_bytes)
329        .with_context(|| format!("Invalid checksums.json for {}/{}", id, rev))?;
330
331    let base_dir = std::path::PathBuf::from(template_dir(id));
332    std::fs::create_dir_all(&base_dir)?;
333    let tmp_dir = base_dir.join(format!("tmp-pull-{}", rev));
334    if tmp_dir.exists() {
335        std::fs::remove_dir_all(&tmp_dir).ok();
336    }
337    std::fs::create_dir_all(&tmp_dir)?;
338
339    let rev_dir = std::path::PathBuf::from(template_revision_dir(id, &rev));
340    std::fs::create_dir_all(rev_dir.parent().unwrap_or(&base_dir))?;
341
342    // Download required files into tmp and verify.
343    for (name, expected_hex) in &checksums.files {
344        let key = registry.key_revision_file(id, &rev, name);
345        let data = registry.get_bytes(&key)?;
346        let tmp_path = tmp_dir.join(name);
347        std::fs::write(&tmp_path, &data)?;
348        let got = sha256_hex(&tmp_path)?;
349        if &got != expected_hex {
350            std::fs::remove_dir_all(&tmp_dir).ok();
351            anyhow::bail!(
352                "checksum mismatch for {} (expected {}, got {})",
353                name,
354                expected_hex,
355                got
356            );
357        }
358    }
359    // Keep checksums.json in the installed revision so `template verify` can run locally.
360    std::fs::write(tmp_dir.join("checksums.json"), &sums_bytes)?;
361
362    // Install into final revision dir.
363    if rev_dir.exists() {
364        std::fs::remove_dir_all(&rev_dir).ok();
365    }
366    std::fs::create_dir_all(&rev_dir)?;
367    for name in checksums.files.keys() {
368        std::fs::rename(tmp_dir.join(name), rev_dir.join(name))?;
369    }
370    std::fs::rename(
371        tmp_dir.join("checksums.json"),
372        rev_dir.join("checksums.json"),
373    )?;
374    std::fs::remove_dir_all(&tmp_dir).ok();
375
376    // Update current symlink (keep existing "revisions/<rev>" convention).
377    let link = template_current_symlink(id);
378    let _ = std::fs::remove_file(&link);
379    std::os::unix::fs::symlink(format!("revisions/{}", rev), &link)?;
380
381    tracing::info!(template = %id, revision = %rev, "Pulled template revision from registry");
382    Ok(())
383}
384
385pub fn template_verify(id: &str, revision: Option<&str>) -> Result<()> {
386    require_local_template_fs()?;
387
388    let rev = match revision {
389        Some(r) => r.to_string(),
390        None => current_revision_id(id)?,
391    };
392    let rev_dir = std::path::PathBuf::from(template_revision_dir(id, &rev));
393    let sums_path = rev_dir.join("checksums.json");
394    let sums_bytes =
395        std::fs::read(&sums_path).with_context(|| format!("Missing {}", sums_path.display()))?;
396    let checksums: Checksums = serde_json::from_slice(&sums_bytes)?;
397
398    for (name, expected_hex) in &checksums.files {
399        let p = rev_dir.join(name);
400        let got = sha256_hex(&p)?;
401        if &got != expected_hex {
402            anyhow::bail!(
403                "checksum mismatch for {} (expected {}, got {})",
404                name,
405                expected_hex,
406                got
407            );
408        }
409    }
410
411    Ok(())
412}