use std::collections::BTreeMap;
use std::path::Path;
use serde_json::{Value, json};
use super::secrets::{SecretBinding, owned_name};
use super::{LABEL_REV, StackDef, Store};
use crate::client::{Client, encode_segment};
use crate::error::{Error, Result};
use crate::org::OrgId;
use crate::secrets::Secrets;
#[derive(Debug, Clone, PartialEq)]
pub struct Migrated {
pub stack: String,
pub secrets: Vec<String>,
pub relabelled: usize,
}
impl std::fmt::Display for Migrated {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"migrated stack {}: {} secret value(s) moved into the org's store ({}); {} instance(s) relabelled in place",
self.stack,
self.secrets.len(),
self.secrets.join(", "),
self.relabelled
)
}
}
fn legacy_values(v: &Value) -> BTreeMap<String, String> {
v.get("secrets")
.and_then(Value::as_object)
.map(|m| {
m.iter()
.filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string())))
.collect()
})
.unwrap_or_default()
}
fn has_legacy(v: &Value) -> bool {
!legacy_values(v).is_empty() || v.get("previous").is_some_and(has_legacy)
}
pub fn run(
store: &Store,
secrets: &Secrets,
client: Option<&Client>,
) -> Vec<std::result::Result<Migrated, String>> {
let files = match store.files() {
Ok(f) => f,
Err(e) => return vec![Err(format!("cannot list stacks: {e}"))],
};
let mut out = Vec::new();
for p in files {
match migrate_file(store, secrets, client, &p) {
Ok(Some(m)) => out.push(Ok(m)),
Ok(None) => {}
Err(e) => out.push(Err(format!("cannot migrate {}: {e}", p.display()))),
}
}
out
}
#[expect(
clippy::too_many_lines,
reason = "predates the lint ratchet; split it when next changed"
)]
fn migrate_file(
store: &Store,
secrets: &Secrets,
client: Option<&Client>,
path: &Path,
) -> Result<Option<Migrated>> {
let text = std::fs::read_to_string(path)?;
let mut v: Value = serde_json::from_str(&text)
.map_err(|e| Error::invalid(format!("{}: {e}", path.display())))?;
if !has_legacy(&v) {
return Ok(None);
}
let name = v["name"]
.as_str()
.ok_or_else(|| Error::invalid("no stack name"))?
.to_string();
let org = match v.get("org").and_then(Value::as_str) {
Some(o) => OrgId::new(o)?,
None => OrgId::default_org(),
};
let legacy = legacy_values(&v);
let mut old_view = v.clone();
old_view["secrets"] = json!({});
old_view["previous"] = Value::Null;
let old_def: StackDef = serde_json::from_value(old_view)?;
let mut old_revs = BTreeMap::new();
for svc in old_def.file.services.keys() {
let rev = old_def.revision_with(svc, &|k| {
legacy
.get(k)
.map(|s| s.as_bytes().to_vec())
.unwrap_or_default()
})?;
old_revs.insert(svc.clone(), rev);
}
let mut stored = Vec::new();
let mut current: BTreeMap<String, (Vec<u8>, SecretBinding)> = BTreeMap::new();
for (key, b64) in &legacy {
let value = crate::rpc::b64_decode(b64)
.map_err(|_| Error::invalid(format!("secret {key:?}: not base64")))?;
let n = owned_name(&name, key)?;
let m = secrets.put(&org, &n, &value)?;
stored.push(n.clone());
current.insert(
key.clone(),
(
value,
SecretBinding {
name: n,
driver: m.driver,
version: m.version,
owned: true,
},
),
);
}
v["secrets"] = bindings_json(¤t);
if let Some(prev) = v.get_mut("previous").filter(|p| p.is_object()) {
let pl = legacy_values(prev);
let mut pb = serde_json::Map::new();
for (key, b64) in &pl {
let same = current
.get(key)
.filter(|(cur, _)| crate::rpc::b64_decode(b64).is_ok_and(|x| x == *cur));
let b = match same {
Some((_, b)) => b.clone(),
None => SecretBinding {
name: owned_name(&name, key)?,
driver: crate::secrets::local::DRIVER.into(),
version: 0,
owned: true,
},
};
pb.insert(key.clone(), serde_json::to_value(b)?);
}
if let Some(obj) = prev.get("secrets").and_then(Value::as_object) {
for (k, x) in obj {
if !x.is_string() {
pb.insert(k.clone(), x.clone());
}
}
}
prev["secrets"] = Value::Object(pb);
prev["previous"] = Value::Null;
}
let def: StackDef = serde_json::from_value(v)?;
let mut relabelled = 0;
if let Some(c) = client {
let oc = crate::org::client(c, &org);
for (svc, old) in &old_revs {
let new = def.revision(svc)?;
if *old == new {
continue;
}
for i in super::controller::list_instances(&oc, &def.name, Some(svc))? {
if i.rev != *old {
continue;
}
oc.mutate(
"PATCH",
&format!("/1.0/instances/{}", encode_segment(&i.name)),
Some(&json!({"config": {format!("user.{LABEL_REV}"): new}})),
&format!("relabel {} to rev {new}", i.name),
oc.get_timeouts().other,
)?;
relabelled += 1;
}
}
}
store.save(&def)?;
Ok(Some(Migrated {
stack: def.qualified(),
secrets: stored,
relabelled,
}))
}
fn bindings_json(m: &BTreeMap<String, (Vec<u8>, SecretBinding)>) -> Value {
Value::Object(
m.iter()
.map(|(k, (_, b))| (k.clone(), serde_json::to_value(b).unwrap_or(Value::Null)))
.collect(),
)
}
#[cfg(test)]
mod tests {
use super::*;
fn legacy_file(org: Option<&str>) -> Value {
let mut v = json!({
"name": "app",
"file": {
"secrets": {"tok": {"environment": "TOK"}, "pem": {"file": "./k.pem"}},
"services": {
"web": {"image": "dev-base", "secrets": ["tok", "pem"]},
"plain": {"image": "dev-base"}
}
},
"base_dir": "/srv",
"secrets": {
"tok": crate::rpc::b64_encode(b"t0ken"),
"pem": crate::rpc::b64_encode(b"PEM")
},
"deployed_at": 1,
"deployed_by": "local",
"previous": {
"name": "app",
"file": {"secrets": {"tok": {"environment": "TOK"}}, "services": {"web": {"image": "dev-base", "secrets": ["tok"]}}},
"base_dir": "/srv",
"secrets": {"tok": crate::rpc::b64_encode(b"older")},
"deployed_at": 0
}
});
if let Some(o) = org {
v["org"] = json!(o);
}
v
}
#[test]
fn moves_values_into_the_store_once() {
let dir = tempfile::tempdir().unwrap();
let (s, _) = super::super::secrets::tests_support::store(&dir.path().join("sec"));
let store = Store::open(dir.path().join("state")).unwrap();
let alpha = OrgId::new("alpha").unwrap();
std::fs::write(
dir.path().join("state/stacks/app.json"),
serde_json::to_string(&legacy_file(None)).unwrap(),
)
.unwrap();
let adir = alpha.dir(store.dir()).join("stacks");
std::fs::create_dir_all(&adir).unwrap();
std::fs::write(
adir.join("app.json"),
serde_json::to_string(&legacy_file(Some("alpha"))).unwrap(),
)
.unwrap();
let current =
json!({"name": "new", "file": {"services": {}}, "base_dir": "/", "deployed_at": 5});
std::fs::write(
dir.path().join("state/stacks/new.json"),
serde_json::to_string(¤t).unwrap(),
)
.unwrap();
assert_eq!(store.load_all().unwrap().len(), 1);
let old_rev = {
let v = legacy_file(None);
let mut view = v.clone();
view["secrets"] = json!({});
view["previous"] = Value::Null;
let d: StackDef = serde_json::from_value(view).unwrap();
let l = legacy_values(&v);
d.revision_with("web", &|k| l[k].as_bytes().to_vec())
.unwrap()
};
let r = run(&store, &s, None);
assert_eq!(r.len(), 2, "{r:?}");
let lines: Vec<String> = r.iter().map(|m| m.as_ref().unwrap().to_string()).collect();
assert!(
lines[0].starts_with("migrated stack alpha/app: 2 secret value(s)"),
"{lines:?}"
);
assert!(lines[1].contains("stack app:"), "{lines:?}");
for line in &lines {
assert!(!line.contains("t0ken"), "{line}");
}
let all = store.load_all().unwrap();
assert_eq!(all.len(), 3);
let app = all.iter().find(|d| d.qualified() == "app").unwrap();
let org = OrgId::default_org();
assert_eq!(
app.secrets["tok"],
SecretBinding {
name: "app_tok".into(),
driver: "local".into(),
version: 1,
owned: true
}
);
assert_eq!(s.get(&org, "app_tok").unwrap().0, b"t0ken");
assert_eq!(s.get(&org, "app_pem").unwrap().0, b"PEM");
assert_eq!(s.get(&alpha, "app_tok").unwrap().0, b"t0ken");
let prev = app.previous.as_ref().unwrap();
assert_eq!(prev.secrets["tok"].version, 0);
let text = std::fs::read_to_string(dir.path().join("state/stacks/app.json")).unwrap();
assert!(!text.contains(&crate::rpc::b64_encode(b"t0ken")), "{text}");
assert!(!text.contains(&crate::rpc::b64_encode(b"older")), "{text}");
assert_ne!(app.revision("web").unwrap(), old_rev);
assert!(run(&store, &s, None).is_empty());
assert_eq!(s.version(&org, "app_tok").unwrap(), 1);
std::fs::write(
dir.path().join("state/stacks/app.json"),
serde_json::to_string(&legacy_file(None)).unwrap(),
)
.unwrap();
let r = run(&store, &s, None);
assert_eq!(r.len(), 1);
assert_eq!(s.version(&org, "app_tok").unwrap(), 1);
}
#[test]
fn a_bad_stack_is_reported_and_left() {
let dir = tempfile::tempdir().unwrap();
let (s, _) = super::super::secrets::tests_support::store(&dir.path().join("sec"));
let store = Store::open(dir.path().join("state")).unwrap();
let mut v = legacy_file(None);
v["secrets"]["tok"] = json!("!!not base64!!");
std::fs::write(
dir.path().join("state/stacks/app.json"),
serde_json::to_string(&v).unwrap(),
)
.unwrap();
let r = run(&store, &s, None);
assert_eq!(r.len(), 1);
let e = r[0].as_ref().unwrap_err();
assert!(e.contains("not base64"), "{e}");
assert!(!e.contains("not base64!!"), "the value is not echoed: {e}");
}
}