1use super::{Apps, DeployLog, SecretHook};
5use crate::app::{AppSpec, Source, git};
6use crate::error::{Error, Result};
7use crate::org::OrgId;
8
9impl Apps {
10 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 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 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 pub fn on_secret_changed(&self, f: SecretHook) {
89 let _ = self.inner.secret_hook.set(f);
90 }
91
92 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 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 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}