Skip to main content

isb_apps/app/
db_secrets.rs

1//! A database app's credentials as org secrets: its passwords, generated
2//! once, and the URL secrets that carry the password, kept in step with it.
3
4use super::{Apps, DeployLog, SecretHook};
5use crate::app::{AppSpec, Source, git};
6use crate::error::{Error, Result};
7use crate::org::OrgId;
8
9impl Apps {
10    /// A database's passwords, generated unless they exist (a database
11    /// re-created over its kept volume needs the passwords that volume was
12    /// initialized with), and its connection URL.
13    pub(super) fn database_credentials(
14        &self,
15        org: &OrgId,
16        spec: &AppSpec,
17        db: &crate::app::DatabaseSource,
18    ) -> Result<()> {
19        use crate::app::database as d;
20        let mut names = vec![d::password_secret(&spec.name)];
21        if db.engine.has_root_password() {
22            names.push(d::root_password_secret(&spec.name));
23        }
24        for n in &names {
25            match self.inner.secrets.inspect(org, n) {
26                Ok(_) => {}
27                Err(e) if e.is_not_found() => {
28                    self.inner
29                        .secrets
30                        .set(org, n, git::random_hex(16).as_bytes())?;
31                }
32                Err(e) => return Err(e),
33            }
34        }
35        self.write_urls(org, spec, db)?;
36        Ok(())
37    }
38
39    /// Store the URL secret and the database's `urls` again from its
40    /// current password; returns the names whose value moved.
41    pub(super) fn write_urls(
42        &self,
43        org: &OrgId,
44        spec: &AppSpec,
45        db: &crate::app::DatabaseSource,
46    ) -> Result<Vec<String>> {
47        use crate::app::database as d;
48        let (pw, _) = self
49            .inner
50            .secrets
51            .get(org, &d::password_secret(&spec.name))?;
52        let pw = String::from_utf8(pw).map_err(|_| Error::invalid("the password is not text"))?;
53        let url = d::internal_url(spec, db, pw.trim());
54        let wanted = std::iter::once((d::url_secret(&spec.name), url.clone())).chain(
55            db.urls
56                .iter()
57                .map(|(n, q)| (n.clone(), d::with_query(&url, q))),
58        );
59        let mut moved = Vec::new();
60        for (name, value) in wanted {
61            let before = self.inner.secrets.version(org, &name).ok();
62            let m = self.inner.secrets.put(org, &name, value.as_bytes())?;
63            if Some(m.version) != before {
64                moved.push(m.name);
65            }
66        }
67        Ok(moved)
68    }
69
70    /// The URL secret `name` got a new value at deploy: the stacks using it
71    /// cycle per their `on_change` (as after `secret_set`), and the hook
72    /// delivers it to the workspaces.
73    pub(super) fn url_secret_moved(&self, org: &OrgId, name: &str, log: &mut DeployLog) {
74        for c in self.inner.ctl.secret_changed(org, name) {
75            let what = match &c.error {
76                Some(e) => format!("not cycled: {e}"),
77                None => format!("{:?}", c.action).to_lowercase(),
78            };
79            log.line(&format!("secret {name}: {}/{} {what}", c.stack, c.service));
80        }
81        if let Some(f) = self.inner.secret_hook.get() {
82            f(org, name);
83        }
84    }
85
86    /// Tell `f` about each secret that gets a new value outside `secret_set`
87    /// (once; later calls are ignored).
88    pub fn on_secret_changed(&self, f: SecretHook) {
89        let _ = self.inner.secret_hook.set(f);
90    }
91
92    /// The secret `name` got a new value: when it is a database app's
93    /// password (`db.<app>.password`), store the URL secret and the
94    /// database's `urls` again with it. Returns the secrets whose value
95    /// moved.
96    pub fn database_password_changed(&self, org: &OrgId, name: &str) -> Result<Vec<String>> {
97        let Some(app) = name
98            .strip_prefix("db.")
99            .and_then(|r| r.strip_suffix(".password"))
100        else {
101            return Ok(vec![]);
102        };
103        let a = match self.get(org, app) {
104            Ok(a) => a,
105            Err(e) if e.is_not_found() => return Ok(vec![]),
106            Err(e) => return Err(e),
107        };
108        let Source::Database(db) = &a.spec.source else {
109            return Ok(vec![]);
110        };
111        self.write_urls(org, &a.spec, db)
112    }
113}
114
115#[cfg(test)]
116mod tests {
117    use std::sync::Arc;
118    use std::time::Duration;
119
120    use super::*;
121    use crate::secrets::{Keyring, LocalDriver, Secrets};
122
123    /// A stack already using a hand-made copy of a connection URL, which
124    /// the database adopts as a `urls` entry.
125    fn jobs_stack(
126        secrets: &Secrets,
127        store: &crate::stack::Store,
128        org: &OrgId,
129        dir: &std::path::Path,
130    ) {
131        secrets.set(org, "dsn.main-db.jobs", b"stale").unwrap();
132        let file: crate::spec::ComposeFile = serde_yaml_ng::from_str(
133        "services:\n  worker:\n    image: images:debian/12\n    environment:\n      DATABASE_URL: {secret: jobs}\nsecrets:\n  jobs: {external: true, name: dsn.main-db.jobs}\n",
134    )
135    .unwrap();
136        let bound = crate::stack::secrets::bind(
137            secrets,
138            org,
139            "jobs",
140            &file,
141            &std::collections::BTreeMap::new(),
142            false,
143        )
144        .unwrap();
145        store
146            .save(&crate::stack::StackDef {
147                source: None,
148                domains: Default::default(),
149                name: "jobs".into(),
150                org: org.clone(),
151                file,
152                base_dir: dir.into(),
153                secrets: bound,
154                force: std::collections::BTreeMap::new(),
155                images: std::collections::BTreeMap::new(),
156                deployed_at: 0,
157                deployed_by: String::new(),
158                previous: None,
159            })
160            .unwrap();
161    }
162
163    #[test]
164    fn url_secrets_follow_the_password() {
165        let dir = tempfile::tempdir().unwrap();
166        let k = Keyring::new(age::x25519::Identity::generate(), vec![]);
167        let secrets = Arc::new(Secrets::new(LocalDriver::new(dir.path(), Arc::new(k))));
168        let client = crate::client::Client::with_socket("/nonexistent/isb-test/incus.sock");
169        let store = crate::stack::Store::open(dir.path()).unwrap();
170        let org = OrgId::default_org();
171        jobs_stack(&secrets, &store, &org, dir.path());
172        let ctl = crate::stack::Controller::start(
173            client.clone(),
174            store,
175            Duration::from_secs(60),
176            secrets.clone(),
177        )
178        .unwrap();
179        let ap = Apps::new(dir.path(), client, ctl, secrets.clone());
180        let told = Arc::new(std::sync::Mutex::new(Vec::<String>::new()));
181        let t = told.clone();
182        ap.on_secret_changed(Arc::new(move |_: &OrgId, n: &str| {
183            t.lock().unwrap().push(n.to_string());
184        }));
185        let spec: AppSpec = serde_json::from_value(serde_json::json!({
186            "name": "main-db", "project": "shop",
187            "source": {"database": {"engine": "postgres", "urls": {
188                "dsn.main-db.web": "sslmode=disable", "dsn.main-db.plain": "",
189            }}},
190        }))
191        .unwrap();
192        ap.project_create(&org, "shop", "", &[]).unwrap();
193        ap.create(&org, spec).unwrap();
194        let Source::Database(db) = &ap.get(&org, "main-db").unwrap().spec.source else {
195            panic!("a database")
196        };
197        let value = |n: &str| String::from_utf8(secrets.get(&org, n).unwrap().0).unwrap();
198        ap.database_credentials(&org, &ap.get(&org, "main-db").unwrap().spec, db)
199            .unwrap();
200        let url = value("db.main-db.url");
201        assert_eq!(value("dsn.main-db.web"), format!("{url}?sslmode=disable"));
202        assert_eq!(value("dsn.main-db.plain"), url);
203
204        secrets
205            .set(&org, "db.main-db.password", b"n3w-pass")
206            .unwrap();
207        let mut moved = ap
208            .database_password_changed(&org, "db.main-db.password")
209            .unwrap();
210        moved.sort();
211        assert_eq!(
212            moved,
213            ["db.main-db.url", "dsn.main-db.plain", "dsn.main-db.web"]
214        );
215        assert!(value("dsn.main-db.web").contains(":n3w-pass@"));
216        assert!(value("dsn.main-db.web").ends_with("?sslmode=disable"));
217        assert!(
218            ap.database_password_changed(&org, "db.main-db.password")
219                .unwrap()
220                .is_empty(),
221            "nothing moves twice"
222        );
223        assert!(
224            ap.database_password_changed(&org, "dsn.main-db.web")
225                .unwrap()
226                .is_empty()
227        );
228
229        // An entry added later, over the stale copy, is written by the next
230        // deploy (which then fails here, with no incusd: the secrets come
231        // first), and the stack using the copy follows it.
232        let bound = || {
233            ap.inner
234                .ctl
235                .definitions()
236                .iter()
237                .find(|d| d.name == "jobs")
238                .unwrap()
239                .secrets["jobs"]
240                .version
241        };
242        let before = bound();
243        ap.update(
244            &org,
245            "main-db",
246            &serde_json::json!({"source": {"database": {"urls": {"dsn.main-db.jobs": "x=1"}}}}),
247        )
248        .unwrap();
249        let d = ap
250            .deploy(&org, "main-db", super::super::Trigger::Api, "t", None)
251            .unwrap();
252        ap.wait(&org, "main-db", d.id, Duration::from_secs(60))
253            .unwrap();
254        assert_eq!(
255            value("dsn.main-db.jobs"),
256            format!("{}?x=1", value("db.main-db.url"))
257        );
258        let now = secrets.version(&org, "dsn.main-db.jobs").unwrap();
259        assert!(now > before);
260        assert_eq!(bound(), now, "the stack using it moved to the new value");
261        assert_eq!(*told.lock().unwrap(), ["dsn.main-db.jobs"]);
262    }
263}