1use std::collections::HashMap;
5use std::time::{Duration, Instant};
6
7use super::Workspaces;
8use crate::client::encode_segment;
9use crate::error::Result;
10use crate::org::OrgId;
11use crate::workspace::{self as ws, Workspace};
12
13#[derive(Default)]
16pub struct Poll {
17 next: HashMap<OrgId, Instant>,
18 versions: HashMap<(String, String, String), u64>,
20}
21
22impl Workspaces {
23 pub(super) fn deliver(&self, org: &OrgId, w: &Workspace) -> Result<()> {
26 let oc = self.oc(org);
27 let Some(state) = oc.get_opt(&format!(
28 "/1.0/instances/{}/state",
29 encode_segment(w.instance())
30 ))?
31 else {
32 return Ok(());
33 };
34 let pid = state["pid"].as_i64().unwrap_or(0);
35 if pid <= 0 {
36 return Ok(());
37 }
38 let u = crate::exec::resolve_user(&oc, w.instance(), &w.user)?;
39 let inst = w.instance();
40 oc.make_dir(inst, "/run/isb", 0, 0, 0o755)?;
41 if let Some(t) = self.token_plain(org, &w.name)? {
42 oc.push_file(inst, ws::TOKEN_PATH, &t, u.uid, u.gid, 0o400)?;
43 }
44 if !w.secrets.is_empty() {
45 oc.make_dir(inst, ws::SECRETS_DIR, u.uid, u.gid, 0o700)?;
46 for name in &w.secrets {
47 match self.secrets.get(org, name) {
48 Ok((v, m)) => {
49 oc.push_file(
50 inst,
51 &format!("{}/{name}", ws::SECRETS_DIR),
52 &v,
53 u.uid,
54 u.gid,
55 0o400,
56 )?;
57 self.saw(org, &w.name, name, m.version);
58 }
59 Err(e) => eprintln!(
60 "isb serve: workspace {org}/{}: secret {name} not delivered: {e}",
61 w.name
62 ),
63 }
64 }
65 }
66 oc.make_dir(inst, "/etc/profile.d", 0, 0, 0o755)?;
67 let profile = ws::profile(self.url(org).as_deref(), org, w);
68 oc.push_file(inst, ws::PROFILE_PATH, profile.as_bytes(), 0, 0, 0o644)?;
69 self.delivered
70 .lock()
71 .unwrap_or_else(|e| e.into_inner())
72 .insert(super::key(&org.incus_project(), inst), pid);
73 Ok(())
74 }
75
76 fn saw(&self, org: &OrgId, w: &str, name: &str, version: u64) {
78 let k = (org.to_string(), w.to_string(), name.to_string());
79 let mut p = self.secret_poll.lock().unwrap_or_else(|e| e.into_inner());
80 p.versions.insert(k, version);
81 }
82
83 fn push_secret(&self, org: &OrgId, w: &Workspace, name: &str) -> Result<Option<u64>> {
86 let oc = self.oc(org);
87 let pid = oc
88 .get_opt(&format!(
89 "/1.0/instances/{}/state",
90 encode_segment(w.instance())
91 ))?
92 .and_then(|s| s["pid"].as_i64())
93 .unwrap_or(0);
94 if pid <= 0 {
95 return Ok(None);
96 }
97 let (v, m) = self.secrets.get(org, name)?;
98 let u = crate::exec::resolve_user(&oc, w.instance(), &w.user)?;
99 oc.make_dir(w.instance(), ws::SECRETS_DIR, u.uid, u.gid, 0o700)?;
100 let path = format!("{}/{name}", ws::SECRETS_DIR);
101 oc.push_file(w.instance(), &path, &v, u.uid, u.gid, 0o400)?;
102 self.saw(org, &w.name, name, m.version);
103 Ok(Some(m.version))
104 }
105
106 pub fn uses(&self, org: &OrgId, name: &str) -> bool {
108 let list = self.store.list(org).unwrap_or_default();
109 list.iter().any(|w| w.secrets.iter().any(|s| s == name))
110 }
111
112 pub fn secret_changed(&self, org: &OrgId, name: &str) -> Vec<(String, String)> {
117 let mut out = Vec::new();
118 for w in self.store.list(org).unwrap_or_default() {
119 if w.secrets.iter().any(|s| s == name) {
120 out.push((
121 w.name.clone(),
122 outcome(name, self.push_secret(org, &w, name)),
123 ));
124 }
125 }
126 out
127 }
128
129 pub(super) fn poll_secrets(&self, ctl: &crate::stack::Controller) {
135 let now = Instant::now();
136 for org in self.store.orgs() {
137 let every = self
138 .store
139 .settings(&org)
140 .ok()
141 .and_then(|s| ws::secret_refresh(&s.secret_refresh).ok())
142 .unwrap_or(Duration::from_secs(3600));
143 {
144 let mut p = self.secret_poll.lock().unwrap_or_else(|e| e.into_inner());
145 let next = p.next.entry(org.clone()).or_insert(now + every);
147 if now < *next {
148 continue;
149 }
150 *next = now + every;
151 }
152 self.poll_org(ctl, &org);
153 }
154 }
155
156 fn poll_org(&self, ctl: &crate::stack::Controller, org: &OrgId) {
157 let list = self.store.list(org).unwrap_or_default();
158 let mut names: Vec<&str> = list
159 .iter()
160 .flat_map(|w| w.secrets.iter().map(String::as_str))
161 .filter(|n| n.contains('/'))
162 .collect();
163 names.sort_unstable();
164 names.dedup();
165 if names.is_empty() {
166 return;
167 }
168 let current: HashMap<&str, u64> = names
169 .iter()
170 .zip(self.secrets.versions(org, &names))
171 .filter_map(|(n, r)| match r {
172 Ok(v) => Some((*n, v)),
173 Err(e) => {
174 eprintln!(
175 "isb serve: workspaces {org}: secret {n}: cannot check its version: {e}"
176 );
177 None
178 }
179 })
180 .collect();
181 let stack = crate::stack::qualified(org, "@workspaces");
182 let moved = {
183 let mut p = self.secret_poll.lock().unwrap_or_else(|e| e.into_inner());
184 moved(org, &list, ¤t, &mut p.versions)
185 };
186 for (w, name, from, to) in moved {
187 let r = self.push_secret(org, w, &name);
188 if matches!(r, Ok(None)) {
189 self.saw(org, &w.name, &name, to);
191 continue;
192 }
193 ctl.event(
194 "secret.rotated",
195 "warn",
196 &stack,
197 &w.name,
198 format!(
199 "new version of secret {name} (v{from} -> v{to}): workspace {} {}",
200 w.name,
201 outcome(&name, r)
202 ),
203 );
204 }
205 }
206}
207
208fn moved<'a>(
213 org: &OrgId,
214 list: &'a [Workspace],
215 current: &HashMap<&str, u64>,
216 seen: &mut HashMap<(String, String, String), u64>,
217) -> Vec<(&'a Workspace, String, u64, u64)> {
218 let mut out = Vec::new();
219 for w in list {
220 for name in w.secrets.iter().filter(|n| n.contains('/')) {
221 let Some(&to) = current.get(name.as_str()) else {
222 continue;
223 };
224 let k = (org.to_string(), w.name.clone(), name.clone());
225 match seen.get(&k) {
226 Some(&from) if from != to => out.push((w, name.clone(), from, to)),
227 Some(_) => {}
228 None => {
229 seen.insert(k, to);
230 }
231 }
232 }
233 }
234 out
235}
236
237fn outcome(name: &str, r: Result<Option<u64>>) -> String {
239 let path = format!("{}/{name}", ws::SECRETS_DIR);
240 match r {
241 Ok(Some(_)) => format!(
242 "delivered {path}; not restarted (a workspace restarts only with confirm): processes that read the old value keep it"
243 ),
244 Ok(None) => "not running: gets the new value when it starts".to_string(),
245 Err(e) => format!("not delivered: {e}; it gets the value when it next starts"),
246 }
247}
248
249#[cfg(test)]
250mod tests {
251 use super::*;
252
253 fn ws(name: &str, secrets: &[&str]) -> Workspace {
254 let mut w: Workspace = serde_json::from_value(serde_json::json!({
255 "name": name, "id": "x", "image": "dev-base", "user": "dev",
256 "home_size": "1GiB", "token_role": "admin", "created_at": 0, "created_by": "t",
257 }))
258 .unwrap();
259 w.secrets = secrets.iter().map(|s| s.to_string()).collect();
260 w
261 }
262
263 #[test]
264 fn only_moved_references_are_reported() {
265 let org = OrgId::default_org();
266 let list = [
267 ws("a", &["ops/db/pw", "local-name"]),
268 ws("b", &["ops/db/pw", "ops/x/y"]),
269 ];
270 let mut seen = HashMap::new();
271 let cur = HashMap::from([("ops/db/pw", 3), ("ops/x/y", 1), ("local-name", 9)]);
272 assert!(moved(&org, &list, &cur, &mut seen).is_empty());
274 assert_eq!(seen.len(), 3);
275 let cur = HashMap::from([("ops/db/pw", 4), ("ops/x/y", 1)]);
276 let m = moved(&org, &list, &cur, &mut seen);
277 let got: Vec<(&str, &str, u64, u64)> = m
278 .iter()
279 .map(|(w, n, f, t)| (w.name.as_str(), n.as_str(), *f, *t))
280 .collect();
281 assert_eq!(got, [("a", "ops/db/pw", 3, 4), ("b", "ops/db/pw", 3, 4)]);
282 assert!(moved(&org, &list, &HashMap::new(), &mut seen).is_empty());
284 }
285}