Skip to main content

mvm_cli/
template_cmd.rs

1use anyhow::Result;
2use chrono::Utc;
3use mvm_core::template::{TemplateConfig, TemplateSpec, template_dir, templates_base_dir};
4use mvm_runtime::vm::template::lifecycle as tmpl;
5use std::fs;
6use std::fs::read_dir;
7use std::path::Path;
8
9fn now_iso() -> String {
10    Utc::now().to_rfc3339()
11}
12
13pub fn create_single(
14    name: &str,
15    flake: &str,
16    profile: &str,
17    role: &str,
18    cpus: u8,
19    mem: u32,
20    data_disk: u32,
21) -> Result<()> {
22    let ts = now_iso();
23    let spec = TemplateSpec {
24        template_id: name.to_string(),
25        flake_ref: flake.to_string(),
26        profile: profile.to_string(),
27        role: role.to_string(),
28        vcpus: cpus,
29        mem_mib: mem,
30        data_disk_mib: data_disk,
31        created_at: ts.clone(),
32        updated_at: ts,
33    };
34    tmpl::template_create(&spec)
35}
36
37/// Initialize an empty template directory layout (idempotent).
38pub fn init(name: &str, local: bool, base_dir: &str) -> Result<()> {
39    if local {
40        let dir = std::path::Path::new(base_dir).join(name);
41        scaffold_template_files(&dir, name)?;
42        return Ok(());
43    }
44    tmpl::template_init(name)
45}
46
47pub fn create_multi(
48    base: &str,
49    flake: &str,
50    profile: &str,
51    roles: &[String],
52    cpus: u8,
53    mem: u32,
54    data_disk: u32,
55) -> Result<()> {
56    for role in roles {
57        let name = format!("{base}-{role}");
58        create_single(&name, flake, profile, role, cpus, mem, data_disk)?;
59    }
60    Ok(())
61}
62
63pub fn list(json: bool) -> Result<()> {
64    let vm_items = tmpl::template_list()?;
65    let local_items = local_templates(Path::new("."))?;
66
67    let base = templates_base_dir();
68
69    if json {
70        #[derive(serde::Serialize)]
71        struct Out {
72            vm_base: String,
73            vm: Vec<String>,
74            local_base: String,
75            local: Vec<String>,
76        }
77        let out = Out {
78            vm_base: base,
79            vm: vm_items,
80            local_base: std::env::current_dir()
81                .unwrap_or_else(|_| Path::new(".").to_path_buf())
82                .display()
83                .to_string(),
84            local: local_items,
85        };
86        println!("{}", serde_json::to_string_pretty(&out)?);
87        return Ok(());
88    }
89
90    println!("Templates ({base}):");
91    if vm_items.is_empty() {
92        println!("  (none)");
93    } else {
94        for t in &vm_items {
95            println!("  {}", t);
96        }
97    }
98
99    println!("\nLocal templates (base: ./):");
100    if local_items.is_empty() {
101        println!("  (none)");
102    } else {
103        for t in &local_items {
104            println!("  {}", t);
105        }
106    }
107
108    Ok(())
109}
110
111pub fn info(name: &str, json: bool) -> Result<()> {
112    let spec = tmpl::template_load(name)?;
113    if json {
114        println!("{}", serde_json::to_string_pretty(&spec)?);
115    } else {
116        println!("Template: {}", spec.template_id);
117        println!(" Flake:   {}", spec.flake_ref);
118        println!(" Profile: {}", spec.profile);
119        println!(" Role:    {}", spec.role);
120        println!(" vCPUs:   {}", spec.vcpus);
121        println!(" MemMiB:  {}", spec.mem_mib);
122        println!(" DataMiB: {}", spec.data_disk_mib);
123        println!(" Created: {}", spec.created_at);
124        println!(" Updated: {}", spec.updated_at);
125        println!(" Path:    {}", template_dir(name));
126    }
127    Ok(())
128}
129
130pub fn delete(name: &str, force: bool) -> Result<()> {
131    tmpl::template_delete(name, force)
132}
133
134pub fn build(name: &str, force: bool, config: Option<&str>) -> Result<()> {
135    if let Some(cfg_path) = config {
136        let cfg = load_config(cfg_path)?;
137        for variant in &cfg.variants {
138            let base = if !cfg.template_id.is_empty() {
139                cfg.template_id.clone()
140            } else {
141                name.to_string()
142            };
143            let template_name = if !variant.name.is_empty() {
144                variant.name.clone()
145            } else {
146                format!("{base}-{}", variant.role)
147            };
148
149            let ts = now_iso();
150            let spec = TemplateSpec {
151                template_id: template_name.clone(),
152                flake_ref: cfg.flake_ref.clone(),
153                profile: if variant.profile.is_empty() {
154                    cfg.profile.clone()
155                } else {
156                    variant.profile.clone()
157                },
158                role: variant.role.clone(),
159                vcpus: variant.vcpus,
160                mem_mib: variant.mem_mib,
161                data_disk_mib: variant.data_disk_mib,
162                created_at: ts.clone(),
163                updated_at: ts,
164            };
165            tmpl::template_create(&spec)?;
166            tmpl::template_build(&template_name, force)?;
167        }
168        Ok(())
169    } else {
170        tmpl::template_build(name, force)
171    }
172}
173
174pub fn push(name: &str, revision: Option<&str>) -> Result<()> {
175    tmpl::template_push(name, revision)
176}
177
178pub fn pull(name: &str, revision: Option<&str>) -> Result<()> {
179    tmpl::template_pull(name, revision)
180}
181
182pub fn verify(name: &str, revision: Option<&str>) -> Result<()> {
183    tmpl::template_verify(name, revision)
184}
185
186fn load_config(path: &str) -> Result<TemplateConfig> {
187    let data = fs::read_to_string(Path::new(path))
188        .map_err(|e| anyhow::anyhow!("Failed to read template config {}: {}", path, e))?;
189    let cfg: TemplateConfig = toml::from_str(&data)
190        .map_err(|e| anyhow::anyhow!("Failed to parse template config {}: {}", path, e))?;
191    Ok(cfg)
192}
193
194fn local_templates(base: &Path) -> Result<Vec<String>> {
195    let mut names = Vec::new();
196    if let Ok(entries) = read_dir(base) {
197        for entry in entries.flatten() {
198            let path = entry.path();
199            if path.is_dir() {
200                let artifacts = path.join("artifacts").join("revisions");
201                if artifacts.exists()
202                    && let Some(name) = path.file_name().and_then(|s| s.to_str())
203                {
204                    names.push(name.to_string());
205                }
206            }
207        }
208    }
209    names.sort();
210    Ok(names)
211}
212
213fn scaffold_template_files(dir: &Path, name: &str) -> Result<()> {
214    fs::create_dir_all(dir)?;
215
216    let gitignore = dir.join(".gitignore");
217    if !gitignore.exists() {
218        fs::write(
219            &gitignore,
220            include_str!("../resources/template_scaffold/.gitignore"),
221        )?;
222    }
223
224    let flake_path = dir.join("flake.nix");
225    if !flake_path.exists() {
226        fs::write(
227            &flake_path,
228            include_str!("../resources/template_scaffold/flake.nix"),
229        )?;
230    }
231
232    let readme_path = dir.join("README.md");
233    if !readme_path.exists() {
234        let content =
235            include_str!("../resources/template_scaffold/README.md").replace("{{name}}", name);
236        fs::write(&readme_path, content)?;
237    }
238
239    // Scaffold the baseline NixOS guest config. The guest agent modules
240    // come from the mvm-src flake input automatically.
241    scaffold_mvm_baseline(dir)?;
242
243    Ok(())
244}
245
246/// Write the mvm baseline NixOS config into the scaffold directory.
247///
248/// The guest agent modules come from the `mvm-src` flake input,
249/// but the baseline guest config is scaffolded locally so users can customize it.
250fn scaffold_mvm_baseline(dir: &Path) -> Result<()> {
251    let baseline_path = dir.join("baseline.nix");
252    if !baseline_path.exists() {
253        fs::write(&baseline_path, include_str!("../resources/baseline.nix"))?;
254    }
255    Ok(())
256}