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
12fn 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
29fn 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
89pub 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
99pub fn template_build(id: &str, force: bool) -> Result<()> {
102 let spec = template_load(id)?;
103 let env = RuntimeBuildEnv;
104
105 let result = if force {
107 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 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 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 let current_link = template_current_symlink(id);
158 shell::run_in_vm(&format!("ln -snf revisions/{rev} {current_link}"))?;
159
160 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 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 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 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 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 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 ®istry.key_revision_file(id, &rev, "checksums.json"),
301 checksums_json,
302 )?;
303 registry.put_text(®istry.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(®istry.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 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 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 std::fs::write(tmp_dir.join("checksums.json"), &sums_bytes)?;
361
362 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 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}