Skip to main content

isb_core/stack/
migrate.rs

1//! Upgrading stored stacks whose definitions held secret values.
2//!
3//! Before secrets were references, a stack definition carried each value
4//! base64 under `secrets`. On start, the daemon moves every such value into
5//! the stack's org store as `<stack>_<key>` (a `local` secret the stack
6//! owns) and rewrites the definition with [`SecretBinding`]s. Running
7//! instances are relabelled with the new revision, since their secrets are
8//! the same values, so the upgrade rolls nothing. Running it again changes
9//! nothing: a definition without values is left alone, and a value already
10//! in the store keeps its version.
11
12use std::collections::BTreeMap;
13use std::path::Path;
14
15use serde_json::{Value, json};
16
17use super::secrets::{SecretBinding, owned_name};
18use super::{LABEL_REV, StackDef, Store};
19use crate::client::{Client, encode_segment};
20use crate::error::{Error, Result};
21use crate::org::OrgId;
22use crate::secrets::Secrets;
23
24/// What one stack's migration did.
25#[derive(Debug, Clone, PartialEq)]
26pub struct Migrated {
27    /// `org/stack`.
28    pub stack: String,
29    /// The store names the values went to.
30    pub secrets: Vec<String>,
31    /// Instances moved to the new revision label in place.
32    pub relabelled: usize,
33}
34
35impl std::fmt::Display for Migrated {
36    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
37        write!(
38            f,
39            "migrated stack {}: {} secret value(s) moved into the org's store ({}); {} instance(s) relabelled in place",
40            self.stack,
41            self.secrets.len(),
42            self.secrets.join(", "),
43            self.relabelled
44        )
45    }
46}
47
48/// The base64 values a stored definition still carries under `secrets`.
49fn legacy_values(v: &Value) -> BTreeMap<String, String> {
50    v.get("secrets")
51        .and_then(Value::as_object)
52        .map(|m| {
53            m.iter()
54                .filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string())))
55                .collect()
56        })
57        .unwrap_or_default()
58}
59
60fn has_legacy(v: &Value) -> bool {
61    !legacy_values(v).is_empty() || v.get("previous").is_some_and(has_legacy)
62}
63
64/// Migrate every stored stack. `client` relabels running instances; without
65/// one (tests), only state is rewritten. A stack that fails is reported and
66/// left as it was; the others carry on.
67pub fn run(
68    store: &Store,
69    secrets: &Secrets,
70    client: Option<&Client>,
71) -> Vec<std::result::Result<Migrated, String>> {
72    let files = match store.files() {
73        Ok(f) => f,
74        Err(e) => return vec![Err(format!("cannot list stacks: {e}"))],
75    };
76    let mut out = Vec::new();
77    for p in files {
78        match migrate_file(store, secrets, client, &p) {
79            Ok(Some(m)) => out.push(Ok(m)),
80            Ok(None) => {}
81            Err(e) => out.push(Err(format!("cannot migrate {}: {e}", p.display()))),
82        }
83    }
84    out
85}
86
87#[expect(
88    clippy::too_many_lines,
89    reason = "predates the lint ratchet; split it when next changed"
90)]
91fn migrate_file(
92    store: &Store,
93    secrets: &Secrets,
94    client: Option<&Client>,
95    path: &Path,
96) -> Result<Option<Migrated>> {
97    let text = std::fs::read_to_string(path)?;
98    let mut v: Value = serde_json::from_str(&text)
99        .map_err(|e| Error::invalid(format!("{}: {e}", path.display())))?;
100    if !has_legacy(&v) {
101        return Ok(None);
102    }
103    let name = v["name"]
104        .as_str()
105        .ok_or_else(|| Error::invalid("no stack name"))?
106        .to_string();
107    let org = match v.get("org").and_then(Value::as_str) {
108        Some(o) => OrgId::new(o)?,
109        None => OrgId::default_org(),
110    };
111    let legacy = legacy_values(&v);
112
113    // The revisions the running instances carry, from the values.
114    let mut old_view = v.clone();
115    old_view["secrets"] = json!({});
116    old_view["previous"] = Value::Null;
117    let old_def: StackDef = serde_json::from_value(old_view)?;
118    let mut old_revs = BTreeMap::new();
119    for svc in old_def.file.services.keys() {
120        let rev = old_def.revision_with(svc, &|k| {
121            legacy
122                .get(k)
123                .map(|s| s.as_bytes().to_vec())
124                .unwrap_or_default()
125        })?;
126        old_revs.insert(svc.clone(), rev);
127    }
128
129    let mut stored = Vec::new();
130    let mut current: BTreeMap<String, (Vec<u8>, SecretBinding)> = BTreeMap::new();
131    for (key, b64) in &legacy {
132        let value = crate::rpc::b64_decode(b64)
133            .map_err(|_| Error::invalid(format!("secret {key:?}: not base64")))?;
134        let n = owned_name(&name, key)?;
135        let m = secrets.put(&org, &n, &value)?;
136        stored.push(n.clone());
137        current.insert(
138            key.clone(),
139            (
140                value,
141                SecretBinding {
142                    name: n,
143                    driver: m.driver,
144                    version: m.version,
145                    owned: true,
146                },
147            ),
148        );
149    }
150    v["secrets"] = bindings_json(&current);
151    if let Some(prev) = v.get_mut("previous").filter(|p| p.is_object()) {
152        // Only the current value of each secret is kept, so a rollback gets
153        // today's value; version 0 marks one that differed.
154        let pl = legacy_values(prev);
155        let mut pb = serde_json::Map::new();
156        for (key, b64) in &pl {
157            let same = current
158                .get(key)
159                .filter(|(cur, _)| crate::rpc::b64_decode(b64).is_ok_and(|x| x == *cur));
160            let b = match same {
161                Some((_, b)) => b.clone(),
162                None => SecretBinding {
163                    name: owned_name(&name, key)?,
164                    driver: crate::secrets::local::DRIVER.into(),
165                    version: 0,
166                    owned: true,
167                },
168            };
169            pb.insert(key.clone(), serde_json::to_value(b)?);
170        }
171        if let Some(obj) = prev.get("secrets").and_then(Value::as_object) {
172            for (k, x) in obj {
173                if !x.is_string() {
174                    pb.insert(k.clone(), x.clone());
175                }
176            }
177        }
178        prev["secrets"] = Value::Object(pb);
179        // Older still has been dropped on every save; never nests deeper.
180        prev["previous"] = Value::Null;
181    }
182    let def: StackDef = serde_json::from_value(v)?;
183
184    let mut relabelled = 0;
185    if let Some(c) = client {
186        let oc = crate::org::client(c, &org);
187        for (svc, old) in &old_revs {
188            let new = def.revision(svc)?;
189            if *old == new {
190                continue;
191            }
192            for i in super::controller::list_instances(&oc, &def.name, Some(svc))? {
193                if i.rev != *old {
194                    continue;
195                }
196                oc.mutate(
197                    "PATCH",
198                    &format!("/1.0/instances/{}", encode_segment(&i.name)),
199                    Some(&json!({"config": {format!("user.{LABEL_REV}"): new}})),
200                    &format!("relabel {} to rev {new}", i.name),
201                    oc.get_timeouts().other,
202                )?;
203                relabelled += 1;
204            }
205        }
206    }
207    store.save(&def)?;
208    Ok(Some(Migrated {
209        stack: def.qualified(),
210        secrets: stored,
211        relabelled,
212    }))
213}
214
215fn bindings_json(m: &BTreeMap<String, (Vec<u8>, SecretBinding)>) -> Value {
216    Value::Object(
217        m.iter()
218            .map(|(k, (_, b))| (k.clone(), serde_json::to_value(b).unwrap_or(Value::Null)))
219            .collect(),
220    )
221}
222
223#[cfg(test)]
224mod tests {
225    use super::*;
226
227    fn legacy_file(org: Option<&str>) -> Value {
228        let mut v = json!({
229            "name": "app",
230            "file": {
231                "secrets": {"tok": {"environment": "TOK"}, "pem": {"file": "./k.pem"}},
232                "services": {
233                    "web": {"image": "dev-base", "secrets": ["tok", "pem"]},
234                    "plain": {"image": "dev-base"}
235                }
236            },
237            "base_dir": "/srv",
238            "secrets": {
239                "tok": crate::rpc::b64_encode(b"t0ken"),
240                "pem": crate::rpc::b64_encode(b"PEM")
241            },
242            "deployed_at": 1,
243            "deployed_by": "local",
244            "previous": {
245                "name": "app",
246                "file": {"secrets": {"tok": {"environment": "TOK"}}, "services": {"web": {"image": "dev-base", "secrets": ["tok"]}}},
247                "base_dir": "/srv",
248                "secrets": {"tok": crate::rpc::b64_encode(b"older")},
249                "deployed_at": 0
250            }
251        });
252        if let Some(o) = org {
253            v["org"] = json!(o);
254        }
255        v
256    }
257
258    #[test]
259    fn moves_values_into_the_store_once() {
260        let dir = tempfile::tempdir().unwrap();
261        let (s, _) = super::super::secrets::tests_support::store(&dir.path().join("sec"));
262        let store = Store::open(dir.path().join("state")).unwrap();
263        let alpha = OrgId::new("alpha").unwrap();
264        // One stack per org, written as an older isb did.
265        std::fs::write(
266            dir.path().join("state/stacks/app.json"),
267            serde_json::to_string(&legacy_file(None)).unwrap(),
268        )
269        .unwrap();
270        let adir = alpha.dir(store.dir()).join("stacks");
271        std::fs::create_dir_all(&adir).unwrap();
272        std::fs::write(
273            adir.join("app.json"),
274            serde_json::to_string(&legacy_file(Some("alpha"))).unwrap(),
275        )
276        .unwrap();
277        // A current-format stack is not touched.
278        let current =
279            json!({"name": "new", "file": {"services": {}}, "base_dir": "/", "deployed_at": 5});
280        std::fs::write(
281            dir.path().join("state/stacks/new.json"),
282            serde_json::to_string(&current).unwrap(),
283        )
284        .unwrap();
285        // Old files are unreadable as current definitions until migrated.
286        assert_eq!(store.load_all().unwrap().len(), 1);
287
288        let old_rev = {
289            let v = legacy_file(None);
290            let mut view = v.clone();
291            view["secrets"] = json!({});
292            view["previous"] = Value::Null;
293            let d: StackDef = serde_json::from_value(view).unwrap();
294            let l = legacy_values(&v);
295            d.revision_with("web", &|k| l[k].as_bytes().to_vec())
296                .unwrap()
297        };
298
299        let r = run(&store, &s, None);
300        assert_eq!(r.len(), 2, "{r:?}");
301        let lines: Vec<String> = r.iter().map(|m| m.as_ref().unwrap().to_string()).collect();
302        assert!(
303            lines[0].starts_with("migrated stack alpha/app: 2 secret value(s)"),
304            "{lines:?}"
305        );
306        assert!(lines[1].contains("stack app:"), "{lines:?}");
307        for line in &lines {
308            assert!(!line.contains("t0ken"), "{line}");
309        }
310
311        let all = store.load_all().unwrap();
312        assert_eq!(all.len(), 3);
313        let app = all.iter().find(|d| d.qualified() == "app").unwrap();
314        let org = OrgId::default_org();
315        assert_eq!(
316            app.secrets["tok"],
317            SecretBinding {
318                name: "app_tok".into(),
319                driver: "local".into(),
320                version: 1,
321                owned: true
322            }
323        );
324        assert_eq!(s.get(&org, "app_tok").unwrap().0, b"t0ken");
325        assert_eq!(s.get(&org, "app_pem").unwrap().0, b"PEM");
326        assert_eq!(s.get(&alpha, "app_tok").unwrap().0, b"t0ken");
327        // The previous deployment's differing value is gone: version 0.
328        let prev = app.previous.as_ref().unwrap();
329        assert_eq!(prev.secrets["tok"].version, 0);
330        // No value is left in the state file.
331        let text = std::fs::read_to_string(dir.path().join("state/stacks/app.json")).unwrap();
332        assert!(!text.contains(&crate::rpc::b64_encode(b"t0ken")), "{text}");
333        assert!(!text.contains(&crate::rpc::b64_encode(b"older")), "{text}");
334        // The revision moved (it is relabelled on a live host).
335        assert_ne!(app.revision("web").unwrap(), old_rev);
336
337        // Idempotent: nothing to do, nothing bumped.
338        assert!(run(&store, &s, None).is_empty());
339        assert_eq!(s.version(&org, "app_tok").unwrap(), 1);
340        // A crash after the values were stored but before the rewrite: the
341        // rerun stores the same values (no new version) and rewrites.
342        std::fs::write(
343            dir.path().join("state/stacks/app.json"),
344            serde_json::to_string(&legacy_file(None)).unwrap(),
345        )
346        .unwrap();
347        let r = run(&store, &s, None);
348        assert_eq!(r.len(), 1);
349        assert_eq!(s.version(&org, "app_tok").unwrap(), 1);
350    }
351
352    #[test]
353    fn a_bad_stack_is_reported_and_left() {
354        let dir = tempfile::tempdir().unwrap();
355        let (s, _) = super::super::secrets::tests_support::store(&dir.path().join("sec"));
356        let store = Store::open(dir.path().join("state")).unwrap();
357        let mut v = legacy_file(None);
358        v["secrets"]["tok"] = json!("!!not base64!!");
359        std::fs::write(
360            dir.path().join("state/stacks/app.json"),
361            serde_json::to_string(&v).unwrap(),
362        )
363        .unwrap();
364        let r = run(&store, &s, None);
365        assert_eq!(r.len(), 1);
366        let e = r[0].as_ref().unwrap_err();
367        assert!(e.contains("not base64"), "{e}");
368        assert!(!e.contains("not base64!!"), "the value is not echoed: {e}");
369    }
370}