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
37pub 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_mvm_baseline(dir)?;
242
243 Ok(())
244}
245
246fn 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}