Skip to main content

workload_spec/
version.rs

1use serde::{Deserialize, Serialize};
2use ts_rs::TS;
3
4/// Wire-format schema version envelope.
5///
6/// Single variant today. When a breaking field rename or removal requires a
7/// migration path, a new variant is added. The `schema_version` field on
8/// `WorkloadSpec` uses this as a tag so rolling clusters can decode multiple
9/// versions simultaneously. See arch doc §Evolution for the versioning rules.
10/// **Reads liberally, writes canonically (R546-B7).** Serialization always
11/// emits the variant name (`"V1"`), but deserialization also accepts the bare
12/// integer `1` and lowercase `"v1"`. Most on-disk `workload.toml` files in the
13/// camp — every `mesofact-static` and `container` component, plus the
14/// `cloudflare-worker` ones whose reconciler-local struct types this field as a
15/// plain integer — were authored as `schema_version = 1`. Rejecting that form
16/// meant `workload_spec::Workload` could not load them even once the envelope's
17/// tagging was fixed, and `mesofact_static::read_mesofact_build` had to
18/// hand-extract raw `toml::Value` subtrees to work around it (R438-T6's gotcha).
19/// A version envelope is exactly the field that should tolerate both spellings.
20#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, TS)]
21#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
22pub enum SchemaVersion {
23    V1,
24}
25
26impl<'de> Deserialize<'de> for SchemaVersion {
27    fn deserialize<D>(de: D) -> Result<Self, D::Error>
28    where
29        D: serde::Deserializer<'de>,
30    {
31        // Untagged needs `deserialize_any`, which postcard refuses — and this
32        // type rides the kamaji wire inside `WorkloadSpec`. Same branch as
33        // `Workload` and `ImageRef`: convenience in text, plain variant index
34        // in binary.
35        if de.is_human_readable() {
36            #[derive(Deserialize)]
37            #[serde(untagged)]
38            enum Repr {
39                Num(u64),
40                Name(String),
41            }
42            match Repr::deserialize(de)? {
43                Repr::Num(1) => Ok(SchemaVersion::V1),
44                Repr::Num(n) => Err(serde::de::Error::custom(format!(
45                    "unknown schema_version {n} (known versions: 1)"
46                ))),
47                Repr::Name(s) if s.eq_ignore_ascii_case("v1") => Ok(SchemaVersion::V1),
48                Repr::Name(s) => Err(serde::de::Error::custom(format!(
49                    "unknown schema_version {s:?} (known versions: \"V1\")"
50                ))),
51            }
52        } else {
53            #[derive(Deserialize)]
54            enum Wire {
55                V1,
56            }
57            match Wire::deserialize(de)? {
58                Wire::V1 => Ok(SchemaVersion::V1),
59            }
60        }
61    }
62}
63
64impl Default for SchemaVersion {
65    fn default() -> Self {
66        Self::V1
67    }
68}
69
70#[cfg(test)]
71mod tests {
72    use super::*;
73
74    /// R546-B7: both spellings load; output stays canonical.
75    #[test]
76    fn accepts_integer_and_string_forms_and_writes_the_name() {
77        #[derive(Serialize, Deserialize, Debug, PartialEq)]
78        struct Doc {
79            schema_version: SchemaVersion,
80        }
81
82        for src in [
83            "schema_version = 1",
84            r#"schema_version = "V1""#,
85            r#"schema_version = "v1""#,
86        ] {
87            let doc: Doc = toml::from_str(src).unwrap_or_else(|e| panic!("{src}: {e}"));
88            assert_eq!(doc.schema_version, SchemaVersion::V1);
89        }
90
91        let out = toml::to_string(&Doc {
92            schema_version: SchemaVersion::V1,
93        })
94        .expect("serialize");
95        assert!(out.contains("\"V1\""), "canonical output, got {out}");
96    }
97
98    #[test]
99    fn rejects_unknown_versions() {
100        #[derive(Deserialize, Debug)]
101        struct Doc {
102            #[allow(dead_code)]
103            schema_version: SchemaVersion,
104        }
105        assert!(toml::from_str::<Doc>("schema_version = 2").is_err());
106        assert!(toml::from_str::<Doc>(r#"schema_version = "V2""#).is_err());
107    }
108
109    /// The binary branch must not reach `deserialize_any` — postcard refuses
110    /// it, and this type rides the kamaji UDS inside every `WorkloadSpec`.
111    #[test]
112    fn round_trips_through_postcard() {
113        let bytes = postcard::to_allocvec(&SchemaVersion::V1).expect("encode");
114        assert_eq!(
115            postcard::from_bytes::<SchemaVersion>(&bytes).expect("decode"),
116            SchemaVersion::V1
117        );
118    }
119}