1use 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#[derive(Debug, Clone, PartialEq)]
26pub struct Migrated {
27 pub stack: String,
29 pub secrets: Vec<String>,
31 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
48fn 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
64pub 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 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(¤t);
151 if let Some(prev) = v.get_mut("previous").filter(|p| p.is_object()) {
152 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 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 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 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(¤t).unwrap(),
283 )
284 .unwrap();
285 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 let prev = app.previous.as_ref().unwrap();
329 assert_eq!(prev.secrets["tok"].version, 0);
330 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 assert_ne!(app.revision("web").unwrap(), old_rev);
336
337 assert!(run(&store, &s, None).is_empty());
339 assert_eq!(s.version(&org, "app_tok").unwrap(), 1);
340 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}