1use std::collections::HashMap;
5use std::time::{Duration, Instant};
6
7use super::{LocalOrg, 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, local: &LocalOrg) {
135 let now = Instant::now();
136 for org in self.store.orgs() {
137 if !local(&org) {
138 continue;
139 }
140 let every = self
141 .store
142 .settings(&org)
143 .ok()
144 .and_then(|s| ws::secret_refresh(&s.secret_refresh).ok())
145 .unwrap_or(Duration::from_secs(3600));
146 {
147 let mut p = self.secret_poll.lock().unwrap_or_else(|e| e.into_inner());
148 let next = p.next.entry(org.clone()).or_insert(now + every);
150 if now < *next {
151 continue;
152 }
153 *next = now + every;
154 }
155 self.poll_org(ctl, &org);
156 }
157 }
158
159 fn poll_org(&self, ctl: &crate::stack::Controller, org: &OrgId) {
160 let list = self.store.list(org).unwrap_or_default();
161 let mut names: Vec<&str> = list
162 .iter()
163 .flat_map(|w| w.secrets.iter().map(String::as_str))
164 .filter(|n| n.contains('/'))
165 .collect();
166 names.sort_unstable();
167 names.dedup();
168 if names.is_empty() {
169 return;
170 }
171 let current: HashMap<&str, u64> = names
172 .iter()
173 .zip(self.secrets.versions(org, &names))
174 .filter_map(|(n, r)| match r {
175 Ok(v) => Some((*n, v)),
176 Err(e) => {
177 eprintln!(
178 "isb serve: workspaces {org}: secret {n}: cannot check its version: {e}"
179 );
180 None
181 }
182 })
183 .collect();
184 let stack = crate::stack::qualified(org, "@workspaces");
185 let moved = {
186 let mut p = self.secret_poll.lock().unwrap_or_else(|e| e.into_inner());
187 moved(org, &list, ¤t, &mut p.versions)
188 };
189 for (w, name, from, to) in moved {
190 let r = self.push_secret(org, w, &name);
191 if matches!(r, Ok(None)) {
192 self.saw(org, &w.name, &name, to);
194 continue;
195 }
196 ctl.event(
197 "secret.rotated",
198 "warn",
199 &stack,
200 &w.name,
201 format!(
202 "new version of secret {name} (v{from} -> v{to}): workspace {} {}",
203 w.name,
204 outcome(&name, r)
205 ),
206 );
207 }
208 }
209}
210
211fn moved<'a>(
216 org: &OrgId,
217 list: &'a [Workspace],
218 current: &HashMap<&str, u64>,
219 seen: &mut HashMap<(String, String, String), u64>,
220) -> Vec<(&'a Workspace, String, u64, u64)> {
221 let mut out = Vec::new();
222 for w in list {
223 for name in w.secrets.iter().filter(|n| n.contains('/')) {
224 let Some(&to) = current.get(name.as_str()) else {
225 continue;
226 };
227 let k = (org.to_string(), w.name.clone(), name.clone());
228 match seen.get(&k) {
229 Some(&from) if from != to => out.push((w, name.clone(), from, to)),
230 Some(_) => {}
231 None => {
232 seen.insert(k, to);
233 }
234 }
235 }
236 }
237 out
238}
239
240fn outcome(name: &str, r: Result<Option<u64>>) -> String {
242 let path = format!("{}/{name}", ws::SECRETS_DIR);
243 match r {
244 Ok(Some(_)) => format!(
245 "delivered {path}; not restarted (a workspace restarts only with confirm): processes that read the old value keep it"
246 ),
247 Ok(None) => "not running: gets the new value when it starts".to_string(),
248 Err(e) => format!("not delivered: {e}; it gets the value when it next starts"),
249 }
250}
251
252#[cfg(test)]
253mod tests {
254 use super::*;
255
256 fn ws(name: &str, secrets: &[&str]) -> Workspace {
257 let mut w: Workspace = serde_json::from_value(serde_json::json!({
258 "name": name, "id": "x", "image": "dev-base", "user": "dev",
259 "home_size": "1GiB", "token_role": "admin", "created_at": 0, "created_by": "t",
260 }))
261 .unwrap();
262 w.secrets = secrets.iter().map(|s| s.to_string()).collect();
263 w
264 }
265
266 #[test]
267 fn only_moved_references_are_reported() {
268 let org = OrgId::default_org();
269 let list = [
270 ws("a", &["ops/db/pw", "local-name"]),
271 ws("b", &["ops/db/pw", "ops/x/y"]),
272 ];
273 let mut seen = HashMap::new();
274 let cur = HashMap::from([("ops/db/pw", 3), ("ops/x/y", 1), ("local-name", 9)]);
275 assert!(moved(&org, &list, &cur, &mut seen).is_empty());
277 assert_eq!(seen.len(), 3);
278 let cur = HashMap::from([("ops/db/pw", 4), ("ops/x/y", 1)]);
279 let m = moved(&org, &list, &cur, &mut seen);
280 let got: Vec<(&str, &str, u64, u64)> = m
281 .iter()
282 .map(|(w, n, f, t)| (w.name.as_str(), n.as_str(), *f, *t))
283 .collect();
284 assert_eq!(got, [("a", "ops/db/pw", 3, 4), ("b", "ops/db/pw", 3, 4)]);
285 assert!(moved(&org, &list, &HashMap::new(), &mut seen).is_empty());
287 }
288}