Skip to main content

isb_daemon/daemon/workspaces/
secrets.rs

1//! What a running workspace is given: its token, its named secrets and its
2//! login environment, and a new secret value as it changes.
3
4use 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/// What the workspaces' secret poller remembers: when each org is next due,
14/// and the version of each secret last written into each workspace.
15#[derive(Default)]
16pub struct Poll {
17    next: HashMap<OrgId, Instant>,
18    /// (org, workspace, secret) to version.
19    versions: HashMap<(String, String, String), u64>,
20}
21
22impl Workspaces {
23    /// Write the token, the named secrets and the login environment into a
24    /// running workspace. Files only, through incus' file API.
25    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    /// Remember the version of `name` last written into a workspace.
77    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    /// Write the current value of `name` into a workspace if it runs:
84    /// `Some(version)` when written, `None` when it is not running.
85    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    /// Whether any workspace of the org takes `name`.
107    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    /// A secret got a new value: write it into every running workspace of
113    /// the org that takes it (`/run/isb/secrets/NAME`), and restart none of
114    /// them, since a workspace restarts only with `confirm`: what already
115    /// read the old value keeps it. Returns `(workspace, what happened)`.
116    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    /// Every org's `secret_refresh`, check the driver references (names
130    /// with a `/`) its workspaces take, in one round per org, and write a
131    /// new version into each running workspace that takes it, with a
132    /// `secret.rotated` event (stack `<org>/@workspaces`). Store secrets
133    /// need no polling: `isb secret set` delivers them.
134    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                // First sight: the boot delivery just wrote what is current.
149                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, &current, &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                // Stopped: its start delivers it; nothing to say twice.
193                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
211/// The driver references whose `current` version differs from the one last
212/// written into a workspace: `(workspace, name, from, to)`. A reference
213/// never written there (a stopped workspace) is recorded at `current`, not
214/// reported: its next start delivers it.
215fn 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
240/// What writing a secret into a workspace came to, for reports.
241fn 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        // First sight records; nothing is reported, store names never are.
276        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        // A version that could not be read is left alone.
286        assert!(moved(&org, &list, &HashMap::new(), &mut seen).is_empty());
287    }
288}