Skip to main content

mvm_core/
template.rs

1use sha2::Digest;
2
3use serde::{Deserialize, Serialize};
4
5/// Complete template configuration that can define multiple variants/roles.
6/// Typically loaded from a TOML file.
7#[derive(Debug, Clone, Serialize, Deserialize)]
8pub struct TemplateConfig {
9    /// Optional base name used when a variant omits `name`.
10    #[serde(default)]
11    pub template_id: String,
12    pub flake_ref: String,
13    /// Default profile if a variant omits it.
14    #[serde(default = "default_profile")]
15    pub profile: String,
16    pub variants: Vec<TemplateVariant>,
17}
18
19#[derive(Debug, Clone, Serialize, Deserialize)]
20pub struct TemplateVariant {
21    /// Template name for this variant; if empty, falls back to `<template_id>-<role>`.
22    #[serde(default)]
23    pub name: String,
24    pub role: String,
25    #[serde(default = "default_profile")]
26    pub profile: String,
27    pub vcpus: u8,
28    pub mem_mib: u32,
29    #[serde(default)]
30    pub data_disk_mib: u32,
31}
32
33fn default_profile() -> String {
34    "minimal".to_string()
35}
36
37/// Global template definition (tenant-agnostic base image).
38#[derive(Debug, Clone, Serialize, Deserialize)]
39pub struct TemplateSpec {
40    pub template_id: String,
41    pub flake_ref: String,
42    pub profile: String,
43    pub role: String,
44    pub vcpus: u8,
45    pub mem_mib: u32,
46    pub data_disk_mib: u32,
47    pub created_at: String,
48    pub updated_at: String,
49}
50
51/// Path helpers
52pub fn templates_base_dir() -> String {
53    format!("{}/templates", crate::config::mvm_data_dir())
54}
55
56pub fn template_dir(template_id: &str) -> String {
57    format!("{}/{}", templates_base_dir(), template_id)
58}
59
60pub fn template_spec_path(template_id: &str) -> String {
61    format!("{}/template.json", template_dir(template_id))
62}
63
64/// Artifacts base dir for a template.
65pub fn template_artifacts_dir(template_id: &str) -> String {
66    format!("{}/artifacts", template_dir(template_id))
67}
68
69/// Specific revision dir for a template.
70pub fn template_revision_dir(template_id: &str, revision: &str) -> String {
71    format!("{}/{}", template_artifacts_dir(template_id), revision)
72}
73
74/// Symlink to current revision.
75pub fn template_current_symlink(template_id: &str) -> String {
76    format!("{}/current", template_dir(template_id))
77}
78
79#[derive(Debug, Clone, Serialize, Deserialize)]
80pub struct TemplateRevision {
81    pub revision_hash: String,
82    pub flake_ref: String,
83    pub flake_lock_hash: String,
84    pub artifact_paths: crate::pool::ArtifactPaths,
85    pub built_at: String,
86    pub profile: String,
87    pub role: String,
88    pub vcpus: u8,
89    pub mem_mib: u32,
90    pub data_disk_mib: u32,
91}
92
93impl TemplateRevision {
94    /// Composite cache key from the three dimensions that define a unique build
95    /// output: flake.lock content, Nix profile, and workload role.
96    pub fn cache_key(&self) -> String {
97        let mut hasher = sha2::Sha256::new();
98        hasher.update(self.flake_lock_hash.as_bytes());
99        hasher.update(b":");
100        hasher.update(self.profile.as_bytes());
101        hasher.update(b":");
102        hasher.update(self.role.as_bytes());
103        format!("{:x}", hasher.finalize())
104    }
105}
106
107#[cfg(test)]
108mod tests {
109    use super::*;
110    use crate::pool::ArtifactPaths;
111
112    fn make_revision(flake_lock_hash: &str, profile: &str, role: &str) -> TemplateRevision {
113        TemplateRevision {
114            revision_hash: "abc123".to_string(),
115            flake_ref: ".".to_string(),
116            flake_lock_hash: flake_lock_hash.to_string(),
117            artifact_paths: ArtifactPaths {
118                vmlinux: "vmlinux".to_string(),
119                rootfs: "rootfs.ext4".to_string(),
120                fc_base_config: "fc-base.json".to_string(),
121            },
122            built_at: "2025-01-01T00:00:00Z".to_string(),
123            profile: profile.to_string(),
124            role: role.to_string(),
125            vcpus: 2,
126            mem_mib: 1024,
127            data_disk_mib: 0,
128        }
129    }
130
131    #[test]
132    fn same_inputs_same_cache_key() {
133        let a = make_revision("lock1", "minimal", "worker");
134        let b = make_revision("lock1", "minimal", "worker");
135        assert_eq!(a.cache_key(), b.cache_key());
136    }
137
138    #[test]
139    fn different_profile_different_cache_key() {
140        let a = make_revision("lock1", "minimal", "worker");
141        let b = make_revision("lock1", "full", "worker");
142        assert_ne!(a.cache_key(), b.cache_key());
143    }
144
145    #[test]
146    fn different_role_different_cache_key() {
147        let a = make_revision("lock1", "minimal", "worker");
148        let b = make_revision("lock1", "minimal", "gateway");
149        assert_ne!(a.cache_key(), b.cache_key());
150    }
151
152    #[test]
153    fn different_flake_different_cache_key() {
154        let a = make_revision("lock1", "minimal", "worker");
155        let b = make_revision("lock2", "minimal", "worker");
156        assert_ne!(a.cache_key(), b.cache_key());
157    }
158
159    #[test]
160    fn cache_key_depends_on_flake_lock_not_revision_hash() {
161        let mut a = make_revision("same-lock", "minimal", "worker");
162        a.revision_hash = "rev-aaa".to_string();
163        let mut b = make_revision("same-lock", "minimal", "worker");
164        b.revision_hash = "rev-zzz".to_string();
165        // Different revision hashes but same flake_lock/profile/role → same cache key
166        assert_eq!(a.cache_key(), b.cache_key());
167    }
168}