Skip to main content

isb_daemon/
workspace.rs

1//! Workspaces (docs/concepts/workspaces.md): an org's long-lived machine, where its
2//! people and agents work, and the rules for the short-lived sandboxes
3//! beside it.
4//!
5//! This module is the model: a workspace's definition and where it is kept,
6//! the org's workspace settings (how many workspaces, sandbox expiry and
7//! idle defaults), and the pure decisions the daemon makes from them (a
8//! sandbox's deadlines, whether the reaper takes it). The daemon's side,
9//! the tools, the instance and the token, is `daemon::workspaces`.
10//!
11//! On disk, under the org's state directory (`<state>/workspaces/` for the
12//! default org, `<state>/orgs/<org>/workspaces/` for the others), 0600:
13//!
14//! - `<name>.json`: the definition, with the token's id and SHA-256 (never
15//!   the token);
16//! - `<name>.token.age`: the token itself, encrypted to the daemon's age
17//!   key, so it can be delivered again into a restarted or rebuilt machine;
18//! - `settings.json`: the org's workspace settings.
19
20use std::collections::BTreeMap;
21use std::path::{Path, PathBuf};
22use std::time::Duration;
23
24use serde::{Deserialize, Serialize};
25
26use crate::auth::Role;
27use crate::error::{Error, Result};
28use crate::org::OrgId;
29
30/// The name a workspace gets when none is given; with one workspace per
31/// org, the only name most orgs ever see.
32pub const DEFAULT_NAME: &str = "workspace";
33
34/// The workspace user when none is given (`dev-base`'s, uid 1000).
35pub const DEFAULT_USER: &str = "dev";
36
37/// The home volume's size when none is given.
38pub const DEFAULT_HOME_SIZE: &str = "20GiB";
39
40/// Instance config keys (`user.*`) isb keeps on workspaces and sandboxes.
41pub const KEY_WORKSPACE: &str = "user.isb.workspace";
42pub const KEY_EXPIRES_AT: &str = "user.isb.expires_at";
43pub const KEY_IDLE_TIMEOUT: &str = "user.isb.idle_timeout";
44
45/// The longest a sandbox may live from now, by default or extended.
46pub const MAX_SANDBOX_LIFETIME: Duration = Duration::from_secs(30 * 86400);
47
48/// Where the token is delivered inside the workspace.
49pub const TOKEN_PATH: &str = "/run/isb/token";
50/// Named org secrets, one file each.
51pub const SECRETS_DIR: &str = "/run/isb/secrets";
52/// Login shells' environment: `ISB_URL`, `ISB_ORG`, `ISB_TOKEN`, the
53/// workspace's own variables.
54pub const PROFILE_PATH: &str = "/etc/profile.d/isb.sh";
55
56/// A workspace's definition.
57#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
58pub struct Workspace {
59    pub name: String,
60    /// Stable across rebuilds; a new workspace of the same name gets a new
61    /// one.
62    pub id: String,
63    /// An incus image (`dev-base`, `images:ubuntu/24.04`) or a registry
64    /// image (`registry:APP:TAG`).
65    pub image: String,
66    /// The workspace user: its home is the home volume.
67    pub user: String,
68    #[serde(default, skip_serializing_if = "Option::is_none")]
69    pub cpus: Option<u32>,
70    #[serde(default, skip_serializing_if = "Option::is_none")]
71    pub memory: Option<String>,
72    /// The root disk's size; the pool's default when unset.
73    #[serde(default, skip_serializing_if = "Option::is_none")]
74    pub root_size: Option<String>,
75    pub home_size: String,
76    /// Plain variables for login shells (not secrets: use `secrets`).
77    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
78    pub env: BTreeMap<String, String>,
79    /// Org secrets delivered as files under `/run/isb/secrets/<NAME>`.
80    #[serde(default, skip_serializing_if = "Vec::is_empty")]
81    pub secrets: Vec<String>,
82    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
83    pub labels: BTreeMap<String, String>,
84    /// The role the workspace's token has in its org.
85    pub token_role: Role,
86    /// A host directory bound as the home instead of a volume: under the
87    /// daemon's `--workspace-home-root`, or a path a superadmin named.
88    /// isb never deletes it.
89    #[serde(default, skip_serializing_if = "Option::is_none")]
90    pub home_bind: Option<String>,
91    /// The storage pool the home volume is in, decided at create.
92    #[serde(default, skip_serializing_if = "Option::is_none")]
93    pub pool: Option<String>,
94    pub created_at: u64,
95    pub created_by: String,
96    #[serde(default)]
97    pub updated_at: u64,
98    #[serde(default, skip_serializing_if = "Option::is_none")]
99    pub rebuilt_at: Option<u64>,
100    #[serde(default, skip_serializing_if = "Option::is_none")]
101    pub token: Option<TokenMeta>,
102    /// A first-boot script: run once as root on the first start after a
103    /// create or a rebuild, and again on demand. Not for secrets: the
104    /// definition shows it (use `secrets`).
105    #[serde(default, skip_serializing_if = "Option::is_none")]
106    pub setup: Option<String>,
107    /// Where the setup script is: pending, running, succeeded or failed.
108    #[serde(default, skip_serializing_if = "Option::is_none")]
109    pub setup_state: Option<SetupState>,
110    /// The ports it publishes (`workspace_port_add`).
111    #[serde(default, skip_serializing_if = "Vec::is_empty")]
112    pub ports: Vec<PublishedPort>,
113}
114
115/// The largest setup script accepted.
116pub const MAX_SETUP: usize = 64 * 1024;
117
118/// A setup script's state.
119#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
120#[serde(rename_all = "lowercase")]
121pub enum SetupStatus {
122    /// To run on the next start (or now, when running).
123    Pending,
124    Running,
125    Succeeded,
126    Failed,
127}
128
129/// The setup script's last run, or the one to come.
130#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
131pub struct SetupState {
132    pub status: SetupStatus,
133    /// When the status last changed.
134    pub at: u64,
135    /// How many times it has started.
136    #[serde(default)]
137    pub runs: u32,
138    #[serde(default, skip_serializing_if = "Option::is_none")]
139    pub exit_code: Option<i32>,
140    #[serde(default, skip_serializing_if = "Option::is_none")]
141    pub message: Option<String>,
142}
143
144/// What happens to a setup script.
145#[derive(Debug, Clone, PartialEq, Eq)]
146pub enum SetupEvent {
147    /// The machine was created or rebuilt.
148    Built,
149    /// Someone asked to run it again.
150    Requested,
151    /// The daemon began running it.
152    Started,
153    /// It ended with this exit code.
154    Finished(i32),
155    /// It could not run (the push or the exec failed, or it timed out).
156    Error(String),
157    /// The daemon started: a run it had begun is gone.
158    DaemonStarted,
159}
160
161/// The setup state after `ev`: `None` when there is no script.
162pub fn setup_next(
163    script: bool,
164    cur: Option<&SetupState>,
165    ev: SetupEvent,
166    now: u64,
167) -> Result<Option<SetupState>> {
168    use SetupStatus::*;
169    if !script {
170        return match ev {
171            SetupEvent::Requested => Err(Error::invalid(
172                "the workspace has no setup script (set one with workspace_update)",
173            )),
174            _ => Ok(None),
175        };
176    }
177    let runs = cur.map_or(0, |c| c.runs);
178    let status = cur.map(|c| c.status);
179    let st = |status, exit_code, message| SetupState {
180        status,
181        at: now,
182        runs,
183        exit_code,
184        message,
185    };
186    Ok(Some(match (ev, status) {
187        (SetupEvent::Requested, Some(Running)) => {
188            return Err(Error::invalid(
189                "the setup script is running; wait for it to end",
190            ));
191        }
192        (SetupEvent::Built | SetupEvent::Requested, _) => st(Pending, None, None),
193        (SetupEvent::Started, Some(Pending)) => SetupState {
194            runs: runs + 1,
195            ..st(Running, None, None)
196        },
197        (SetupEvent::Finished(0), Some(Running)) => st(Succeeded, Some(0), None),
198        (SetupEvent::Finished(c), Some(Running)) => st(Failed, Some(c), None),
199        (SetupEvent::Error(m), Some(Running)) => st(Failed, None, Some(m)),
200        (SetupEvent::DaemonStarted, Some(Running)) => st(
201            Failed,
202            None,
203            Some("interrupted: the daemon restarted while it ran".into()),
204        ),
205        (ev @ (SetupEvent::Started | SetupEvent::Finished(_) | SetupEvent::Error(_)), _) => {
206            return Err(Error::invalid(format!(
207                "setup: {ev:?} while it is {}",
208                status.map_or("not set up".into(), |s| format!("{s:?}").to_lowercase())
209            )));
210        }
211        (SetupEvent::DaemonStarted, _) => return Ok(cur.cloned()),
212    }))
213}
214
215/// Whether the setup script should run now (the machine running).
216pub fn setup_due(w: &Workspace) -> bool {
217    w.setup.is_some()
218        && w.setup_state
219            .as_ref()
220            .is_some_and(|s| s.status == SetupStatus::Pending)
221}
222
223/// A port the workspace publishes: always through isb's own preview proxy
224/// (for members, on a preview origin of its own), and on `host` through
225/// the org's ingress when one is given (docs/concepts/workspaces.md#ports).
226#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
227pub struct PublishedPort {
228    pub port: u16,
229    /// The hostname the org's ingress serves it on, like an app's domain.
230    #[serde(default, skip_serializing_if = "Option::is_none")]
231    pub host: Option<String>,
232    /// `host` was generated (`auto`, under sslip.io): outside the allowlist.
233    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
234    pub auto: bool,
235    pub added_by: String,
236    pub added_at: u64,
237}
238
239/// How many ports one workspace may publish.
240pub const MAX_PORTS: usize = 20;
241
242/// What is kept of the workspace's token: never the token.
243#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
244pub struct TokenMeta {
245    /// A short random id, for the audit log and the UI.
246    pub id: String,
247    /// SHA-256 of the token, hex.
248    pub hash: String,
249    pub created_at: u64,
250}
251
252impl Workspace {
253    /// The incus instance: the workspace's name.
254    pub fn instance(&self) -> &str {
255        &self.name
256    }
257
258    /// The home volume in the org's project.
259    pub fn home_volume(&self, org: &OrgId) -> String {
260        home_volume(org, &self.name)
261    }
262
263    /// The workspace user's home directory.
264    pub fn home_dir(&self) -> String {
265        if self.user == "root" {
266            "/root".into()
267        } else {
268            format!("/home/{}", self.user)
269        }
270    }
271}
272
273/// A host-folder home under `root`: `<root>/<org>/home` for the org's
274/// `workspace`, `<root>/<org>/<name>/home` for another name.
275pub fn host_home(root: &Path, org: &OrgId, name: &str) -> PathBuf {
276    if name == DEFAULT_NAME {
277        root.join(org.as_str()).join("home")
278    } else {
279        root.join(org.as_str()).join(name).join("home")
280    }
281}
282
283/// `<org>_<name>_home`, e.g. `acme_workspace_home`.
284pub fn home_volume(org: &OrgId, name: &str) -> String {
285    format!("{}_{name}_home", org.as_str())
286}
287
288/// A workspace name: `[a-z][a-z0-9-]*`, at most 30 characters (it is also
289/// the instance's name).
290pub fn check_name(name: &str) -> Result<()> {
291    let ok = !name.is_empty()
292        && name.len() <= 30
293        && name.starts_with(|c: char| c.is_ascii_lowercase())
294        && name
295            .bytes()
296            .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
297        && !name.ends_with('-');
298    if ok {
299        Ok(())
300    } else {
301        Err(Error::invalid(format!(
302            "workspace name {name:?}: [a-z0-9-], starting with a letter, at most 30 characters"
303        )))
304    }
305}
306
307/// A guest user name for the workspace user.
308pub fn check_user(user: &str) -> Result<()> {
309    let ok = !user.is_empty()
310        && user.len() <= 32
311        && user.starts_with(|c: char| c.is_ascii_lowercase() || c == '_')
312        && user
313            .bytes()
314            .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-' || b == b'_');
315    if ok {
316        Ok(())
317    } else {
318        Err(Error::invalid(format!(
319            "user {user:?}: a lowercase user name ([a-z_][a-z0-9_-]*)"
320        )))
321    }
322}
323
324/// An environment variable name a login shell accepts.
325pub fn check_env_name(k: &str) -> Result<()> {
326    let ok = !k.is_empty()
327        && k.starts_with(|c: char| c.is_ascii_alphabetic() || c == '_')
328        && k.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'_');
329    if !ok {
330        return Err(Error::invalid(format!(
331            "environment variable {k:?}: letters, digits and _, not starting with a digit"
332        )));
333    }
334    if k.starts_with("ISB_") {
335        return Err(Error::invalid(format!(
336            "environment variable {k}: ISB_* are set by isb"
337        )));
338    }
339    Ok(())
340}
341
342/// An org's workspace settings.
343#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
344pub struct Settings {
345    /// How many workspaces the org may have. One: the org's box is a single
346    /// place. Platform admins can lift it.
347    #[serde(default = "one")]
348    pub max_workspaces: u32,
349    /// A new sandbox's lifetime (`24h`); at most 30 days.
350    #[serde(default = "default_expiry")]
351    pub sandbox_expiry: String,
352    /// How long a sandbox may sit idle before it is reaped (`2h`), or
353    /// `none`.
354    #[serde(default = "default_idle")]
355    pub sandbox_idle: String,
356    /// How often the org's workspaces' secrets that are driver references
357    /// (`vault/item/field`) are checked for a new version (`1h`; at least
358    /// `10s`). A new one is written into the running workspaces.
359    #[serde(default = "default_secret_refresh")]
360    pub secret_refresh: String,
361    /// `volume` or `host`: where new homes go, overriding the daemon
362    /// (`host` needs `--workspace-home-root`). Platform admins.
363    #[serde(default, skip_serializing_if = "Option::is_none")]
364    pub home_kind: Option<String>,
365    /// The storage pool new workspace homes go in (platform admins); unset:
366    /// the daemon's `--workspace-pool`, else the org's default pool.
367    #[serde(default, skip_serializing_if = "Option::is_none")]
368    pub home_pool: Option<String>,
369}
370
371fn one() -> u32 {
372    1
373}
374
375fn default_expiry() -> String {
376    "24h".into()
377}
378
379fn default_idle() -> String {
380    "2h".into()
381}
382
383fn default_secret_refresh() -> String {
384    "1h".into()
385}
386
387impl Default for Settings {
388    fn default() -> Self {
389        Settings {
390            max_workspaces: one(),
391            sandbox_expiry: default_expiry(),
392            sandbox_idle: default_idle(),
393            secret_refresh: default_secret_refresh(),
394            home_kind: None,
395            home_pool: None,
396        }
397    }
398}
399
400impl Settings {
401    /// Check the values a caller set.
402    pub fn validate(&self) -> Result<()> {
403        if self.max_workspaces == 0 || self.max_workspaces > 100 {
404            return Err(Error::invalid("max_workspaces: 1 to 100"));
405        }
406        lifetime(&self.sandbox_expiry)?;
407        idle(&self.sandbox_idle)?;
408        secret_refresh(&self.secret_refresh)?;
409        Ok(())
410    }
411}
412
413/// A sandbox lifetime: a duration up to [`MAX_SANDBOX_LIFETIME`].
414pub fn lifetime(s: &str) -> Result<Duration> {
415    let d = crate::flex::parse_duration(s.trim())
416        .map_err(|e| Error::invalid(format!("expiry {s:?}: {e}")))?;
417    if d.is_zero() || d > MAX_SANDBOX_LIFETIME {
418        return Err(Error::invalid(format!(
419            "expiry {s:?}: between a second and 30 days"
420        )));
421    }
422    Ok(d)
423}
424
425/// How often workspace driver references are polled: at least 10 seconds.
426pub fn secret_refresh(s: &str) -> Result<Duration> {
427    let d = crate::flex::parse_duration(s.trim())
428        .map_err(|e| Error::invalid(format!("secret_refresh {s:?}: {e}")))?;
429    if d < Duration::from_secs(10) {
430        return Err(Error::invalid(format!(
431            "secret_refresh {s:?}: at least 10s"
432        )));
433    }
434    Ok(d)
435}
436
437/// An idle timeout: a duration of at least a minute, or `none`.
438pub fn idle(s: &str) -> Result<Option<Duration>> {
439    let s = s.trim();
440    if matches!(s, "none" | "never" | "off") {
441        return Ok(None);
442    }
443    let d = crate::flex::parse_duration(s)
444        .map_err(|e| Error::invalid(format!("idle timeout {s:?}: {e}")))?;
445    if d < Duration::from_secs(60) || d > MAX_SANDBOX_LIFETIME {
446        return Err(Error::invalid(format!(
447            "idle timeout {s:?}: between a minute and 30 days, or none"
448        )));
449    }
450    Ok(Some(d))
451}
452
453/// A new sandbox's deadlines from the org's settings and what the caller
454/// asked for: `(expires_at, idle_timeout_secs)`.
455pub fn sandbox_deadlines(
456    settings: &Settings,
457    expires: Option<&str>,
458    idle_timeout: Option<&str>,
459    now: u64,
460) -> Result<(u64, Option<u64>)> {
461    let life = lifetime(expires.unwrap_or(&settings.sandbox_expiry))?;
462    let idle = idle(idle_timeout.unwrap_or(&settings.sandbox_idle))?;
463    Ok((now + life.as_secs(), idle.map(|d| d.as_secs())))
464}
465
466/// A sandbox's expiry pushed out by `by` (from the later of now and its
467/// current expiry), capped at 30 days from now.
468pub fn extended(current: Option<u64>, by: Duration, now: u64) -> Result<u64> {
469    if by.is_zero() {
470        return Err(Error::invalid("extend by a positive duration"));
471    }
472    let from = current.unwrap_or(now).max(now);
473    let want = from.saturating_add(by.as_secs());
474    let cap = now + MAX_SANDBOX_LIFETIME.as_secs();
475    if want > cap {
476        return Err(Error::invalid(format!(
477            "a sandbox lives at most 30 days from now; the most it can be extended by is {}",
478            human(cap.saturating_sub(from))
479        )));
480    }
481    Ok(want)
482}
483
484/// `90061` -> `1d 1h`: the two largest units.
485pub fn human(secs: u64) -> String {
486    let units = [(86400, "d"), (3600, "h"), (60, "m"), (1, "s")];
487    let mut parts = Vec::new();
488    let mut rest = secs;
489    for (n, u) in units {
490        if rest >= n {
491            parts.push(format!("{}{u}", rest / n));
492            rest %= n;
493        }
494        if parts.len() == 2 {
495            break;
496        }
497    }
498    if parts.is_empty() {
499        "0s".into()
500    } else {
501        parts.join(" ")
502    }
503}
504
505/// What kind of instance this is to isb, from its `user.*` config (keys
506/// without the `user.` prefix, as the metrics sample has them).
507pub fn kind_of(labels: &BTreeMap<String, String>) -> &'static str {
508    if labels.contains_key("isb.workspace") {
509        "workspace"
510    } else if labels.contains_key("isb.stack") {
511        "replica"
512    } else if labels.contains_key("isb.build") {
513        "build"
514    } else {
515        "sandbox"
516    }
517}
518
519/// Why the reaper takes this instance now, if it does. Only sandboxes isb
520/// gave deadlines (`isb.expires_at`, `isb.idle_timeout`) are candidates:
521/// never a workspace, a stack replica or an instance isb did not make.
522/// `last_active` is the latest activity isb saw (unix seconds).
523pub fn reap_reason(
524    labels: &BTreeMap<String, String>,
525    now: u64,
526    last_active: u64,
527) -> Option<String> {
528    if kind_of(labels) != "sandbox" {
529        return None;
530    }
531    let num = |k: &str| labels.get(k).and_then(|v| v.trim().parse::<u64>().ok());
532    if let Some(at) = num("isb.expires_at") {
533        if now >= at {
534            return Some(format!("expired {} ago", human(now - at)));
535        }
536    }
537    if let Some(idle) = num("isb.idle_timeout").filter(|s| *s > 0) {
538        if now.saturating_sub(last_active) >= idle {
539            return Some(format!(
540                "idle for {}",
541                human(now.saturating_sub(last_active))
542            ));
543        }
544    }
545    None
546}
547
548/// The directory an org's workspaces are kept in.
549pub fn dir(state: &Path, org: &OrgId) -> PathBuf {
550    crate::app::org_root(state, org).join("workspaces")
551}
552
553/// Reads and writes workspace definitions and settings.
554#[derive(Debug, Clone)]
555pub struct Store {
556    state: PathBuf,
557}
558
559impl Store {
560    pub fn new(state: &Path) -> Store {
561        Store {
562            state: state.to_path_buf(),
563        }
564    }
565
566    fn path(&self, org: &OrgId, name: &str) -> PathBuf {
567        dir(&self.state, org).join(format!("{name}.json"))
568    }
569
570    pub fn token_path(&self, org: &OrgId, name: &str) -> PathBuf {
571        dir(&self.state, org).join(format!("{name}.token.age"))
572    }
573
574    pub fn list(&self, org: &OrgId) -> Result<Vec<Workspace>> {
575        let d = dir(&self.state, org);
576        let mut out = Vec::new();
577        let rd = match std::fs::read_dir(&d) {
578            Ok(rd) => rd,
579            Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(out),
580            Err(e) => return Err(e.into()),
581        };
582        for e in rd.flatten() {
583            let p = e.path();
584            let Some(stem) = p
585                .file_name()
586                .and_then(|n| n.to_str())
587                .and_then(|n| n.strip_suffix(".json"))
588            else {
589                continue;
590            };
591            if stem == "settings" || check_name(stem).is_err() {
592                continue;
593            }
594            match self.get(org, stem) {
595                Ok(Some(w)) => out.push(w),
596                Ok(None) => {}
597                Err(e) => eprintln!("isb serve: workspace {}: {e}", p.display()),
598            }
599        }
600        out.sort_by(|a, b| a.name.cmp(&b.name));
601        Ok(out)
602    }
603
604    pub fn get(&self, org: &OrgId, name: &str) -> Result<Option<Workspace>> {
605        check_name(name)?;
606        match std::fs::read(self.path(org, name)) {
607            Ok(b) => Ok(Some(serde_json::from_slice(&b).map_err(|e| {
608                Error::invalid(format!("workspace {name} in {org}: {e}"))
609            })?)),
610            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
611            Err(e) => Err(e.into()),
612        }
613    }
614
615    pub fn put(&self, org: &OrgId, w: &Workspace) -> Result<()> {
616        check_name(&w.name)?;
617        crate::app::write_atomic(&self.path(org, &w.name), &serde_json::to_vec_pretty(w)?)
618    }
619
620    pub fn delete(&self, org: &OrgId, name: &str) -> Result<()> {
621        for p in [self.path(org, name), self.token_path(org, name)] {
622            match std::fs::remove_file(&p) {
623                Ok(()) => {}
624                Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
625                Err(e) => return Err(e.into()),
626            }
627        }
628        Ok(())
629    }
630
631    pub fn settings(&self, org: &OrgId) -> Result<Settings> {
632        match std::fs::read(dir(&self.state, org).join("settings.json")) {
633            Ok(b) => serde_json::from_slice(&b)
634                .map_err(|e| Error::invalid(format!("workspace settings of {org}: {e}"))),
635            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Settings::default()),
636            Err(e) => Err(e.into()),
637        }
638    }
639
640    pub fn put_settings(&self, org: &OrgId, s: &Settings) -> Result<()> {
641        s.validate()?;
642        crate::app::write_atomic(
643            &dir(&self.state, org).join("settings.json"),
644            &serde_json::to_vec_pretty(s)?,
645        )
646    }
647
648    /// Every org with a workspace directory: the default org's, then each
649    /// `orgs/<org>/workspaces`.
650    pub fn orgs(&self) -> Vec<OrgId> {
651        let mut out = Vec::new();
652        if dir(&self.state, &OrgId::default_org()).is_dir() {
653            out.push(OrgId::default_org());
654        }
655        if let Ok(rd) = std::fs::read_dir(self.state.join("orgs")) {
656            for e in rd.flatten() {
657                let Some(n) = e.file_name().to_str().map(String::from) else {
658                    continue;
659                };
660                if let Ok(o) = OrgId::new(n) {
661                    if !o.is_default() && dir(&self.state, &o).is_dir() {
662                        out.push(o);
663                    }
664                }
665            }
666        }
667        out.sort_by(|a, b| a.as_str().cmp(b.as_str()));
668        out
669    }
670}
671
672/// Shell-quote `s` for a POSIX shell (single quotes).
673pub fn sh_quote(s: &str) -> String {
674    format!("'{}'", s.replace('\'', r"'\''"))
675}
676
677/// `/etc/profile.d/isb.sh`: the workspace's variables, then isb's. The token
678/// is read from its file, never written here.
679pub fn profile(url: Option<&str>, org: &OrgId, w: &Workspace) -> String {
680    let mut s = String::from(
681        "# Written by isb for this workspace (docs/concepts/workspaces.md); rewritten on every start.\n",
682    );
683    for (k, v) in &w.env {
684        s.push_str(&format!("export {k}={}\n", sh_quote(v)));
685    }
686    if let Some(u) = url {
687        s.push_str(&format!("export ISB_URL={}\n", sh_quote(u)));
688    }
689    s.push_str(&format!("export ISB_ORG={}\n", sh_quote(org.as_str())));
690    s.push_str(&format!("export ISB_WORKSPACE={}\n", sh_quote(&w.name)));
691    s.push_str(&format!(
692        "if [ -r {TOKEN_PATH} ]; then ISB_TOKEN=$(cat {TOKEN_PATH}); export ISB_TOKEN; fi\n"
693    ));
694    s
695}
696
697#[cfg(test)]
698mod tests {
699    use super::*;
700
701    fn labels(kv: &[(&str, &str)]) -> BTreeMap<String, String> {
702        kv.iter()
703            .map(|(k, v)| (k.to_string(), v.to_string()))
704            .collect()
705    }
706
707    #[test]
708    fn host_folder_homes() {
709        let org = OrgId::new("ocai").unwrap();
710        let root = Path::new("/srv/workspaces");
711        assert_eq!(
712            host_home(root, &org, DEFAULT_NAME),
713            Path::new("/srv/workspaces/ocai/home")
714        );
715        assert_eq!(
716            host_home(root, &org, "lab"),
717            Path::new("/srv/workspaces/ocai/lab/home")
718        );
719    }
720
721    #[test]
722    fn human_durations() {
723        assert_eq!(human(0), "0s");
724        assert_eq!(human(59), "59s");
725        assert_eq!(human(3600 + 120 + 5), "1h 2m");
726        assert_eq!(human(90061), "1d 1h");
727    }
728
729    #[test]
730    fn names_users_and_env() {
731        assert!(check_name("workspace").is_ok());
732        assert!(check_name("ws-2").is_ok());
733        for bad in ["", "2ws", "Ws", "ws_", "ws-", &"a".repeat(31)] {
734            assert!(check_name(bad).is_err(), "{bad}");
735        }
736        assert!(check_user("dev").is_ok());
737        assert!(check_user("Dev").is_err());
738        assert!(check_env_name("EDITOR").is_ok());
739        assert!(check_env_name("ISB_TOKEN").is_err());
740        assert!(check_env_name("1X").is_err());
741    }
742
743    #[test]
744    fn settings_default_and_bounds() {
745        let s = Settings::default();
746        assert_eq!(s.max_workspaces, 1);
747        assert_eq!(s.sandbox_expiry, "24h");
748        assert_eq!(s.sandbox_idle, "2h");
749        s.validate().unwrap();
750        let mut b = s.clone();
751        b.sandbox_expiry = "31d".into();
752        assert!(b.validate().is_err());
753        b.sandbox_expiry = "7d".into();
754        b.sandbox_idle = "none".into();
755        b.validate().unwrap();
756        b.max_workspaces = 0;
757        assert!(b.validate().is_err());
758        // A file from before a field existed gets its default.
759        let old: Settings = serde_json::from_str(r#"{"max_workspaces": 2}"#).unwrap();
760        assert_eq!(old.sandbox_idle, "2h");
761    }
762
763    #[test]
764    fn deadlines_and_extension() {
765        let s = Settings::default();
766        let (exp, idle) = sandbox_deadlines(&s, None, None, 1000).unwrap();
767        assert_eq!(exp, 1000 + 86400);
768        assert_eq!(idle, Some(7200));
769        let (exp, idle) = sandbox_deadlines(&s, Some("1h"), Some("none"), 0).unwrap();
770        assert_eq!((exp, idle), (3600, None));
771        assert!(sandbox_deadlines(&s, Some("40d"), None, 0).is_err());
772        assert!(sandbox_deadlines(&s, None, Some("10s"), 0).is_err());
773        // From the later of now and the current expiry.
774        assert_eq!(
775            extended(Some(500), Duration::from_secs(100), 1000).unwrap(),
776            1100
777        );
778        assert_eq!(
779            extended(Some(2000), Duration::from_secs(100), 1000).unwrap(),
780            2100
781        );
782        assert!(extended(None, Duration::from_secs(31 * 86400), 0).is_err());
783        assert!(extended(None, Duration::ZERO, 0).is_err());
784    }
785
786    #[test]
787    fn the_reaper_takes_only_sandboxes_past_their_deadlines() {
788        let now = 10_000;
789        let exp = labels(&[("isb.expires_at", "9000")]);
790        assert!(reap_reason(&exp, now, now).unwrap().starts_with("expired"));
791        let live = labels(&[("isb.expires_at", "20000"), ("isb.idle_timeout", "600")]);
792        assert_eq!(reap_reason(&live, now, now - 100), None);
793        assert!(
794            reap_reason(&live, now, now - 600)
795                .unwrap()
796                .starts_with("idle")
797        );
798        // Never a workspace, a replica, a build or an instance without
799        // deadlines, whatever its labels say.
800        for extra in ["isb.workspace", "isb.stack", "isb.build"] {
801            let mut l = exp.clone();
802            l.insert(extra.into(), "x".into());
803            assert_eq!(reap_reason(&l, now, 0), None, "{extra}");
804        }
805        assert_eq!(
806            reap_reason(&labels(&[("isb.owner", "mcp:a")]), now, 0),
807            None
808        );
809        assert_eq!(
810            reap_reason(&labels(&[("isb.expires_at", "soon")]), now, 0),
811            None
812        );
813    }
814
815    #[test]
816    fn the_store_round_trips() {
817        let d = tempfile::tempdir().unwrap();
818        let st = Store::new(d.path());
819        let org = OrgId::new("acme").unwrap();
820        assert!(st.list(&org).unwrap().is_empty());
821        assert_eq!(st.settings(&org).unwrap(), Settings::default());
822        let w = Workspace {
823            name: "workspace".into(),
824            id: "abc".into(),
825            image: "dev-base".into(),
826            user: "dev".into(),
827            cpus: Some(2),
828            memory: None,
829            root_size: None,
830            home_size: "10GiB".into(),
831            env: BTreeMap::new(),
832            secrets: vec![],
833            labels: BTreeMap::new(),
834            token_role: Role::Admin,
835            home_bind: None,
836            pool: None,
837            created_at: 1,
838            created_by: "a@x.io".into(),
839            updated_at: 1,
840            rebuilt_at: None,
841            token: None,
842            setup: None,
843            setup_state: None,
844            ports: vec![],
845        };
846        st.put(&org, &w).unwrap();
847        st.put_settings(&org, &Settings::default()).unwrap();
848        assert_eq!(st.list(&org).unwrap(), vec![w.clone()]);
849        assert_eq!(st.orgs(), vec![org.clone()]);
850        assert_eq!(w.home_volume(&org), "acme_workspace_home");
851        assert_eq!(w.home_dir(), "/home/dev");
852        st.delete(&org, "workspace").unwrap();
853        assert!(st.get(&org, "workspace").unwrap().is_none());
854    }
855
856    #[test]
857    fn the_profile_quotes_values_and_never_holds_the_token() {
858        let org = OrgId::new("acme").unwrap();
859        let mut w: Workspace = serde_json::from_value(serde_json::json!({
860            "name": "workspace", "id": "x", "image": "dev-base", "user": "dev",
861            "home_size": "1GiB", "token_role": "admin", "created_at": 0, "created_by": "a"
862        }))
863        .unwrap();
864        w.env.insert("GREETING".into(), "it's".into());
865        let p = profile(Some("http://10.0.0.1:8481"), &org, &w);
866        assert!(p.contains("export GREETING='it'\\''s'\n"));
867        assert!(p.contains("export ISB_URL='http://10.0.0.1:8481'\n"));
868        assert!(p.contains("export ISB_ORG='acme'\n"));
869        assert!(p.contains("ISB_TOKEN=$(cat /run/isb/token)"));
870    }
871
872    #[test]
873    fn the_setup_script_runs_once_per_build_and_again_on_request() {
874        use SetupStatus::*;
875        let next = |cur: Option<&SetupState>, ev| setup_next(true, cur, ev, 7).unwrap();
876        // Built: pending; started: running, counted; finished: done.
877        let p = next(None, SetupEvent::Built).unwrap();
878        assert_eq!((p.status, p.runs), (Pending, 0));
879        let r = next(Some(&p), SetupEvent::Started).unwrap();
880        assert_eq!((r.status, r.runs), (Running, 1));
881        let ok = next(Some(&r), SetupEvent::Finished(0)).unwrap();
882        assert_eq!((ok.status, ok.exit_code), (Succeeded, Some(0)));
883        let bad = next(Some(&r), SetupEvent::Finished(3)).unwrap();
884        assert_eq!((bad.status, bad.exit_code), (Failed, Some(3)));
885        let err = next(Some(&r), SetupEvent::Error("timed out".into())).unwrap();
886        assert_eq!(
887            (err.status, err.message.as_deref()),
888            (Failed, Some("timed out"))
889        );
890        // Done stays done until a rebuild or a request; both keep the count.
891        let again = next(Some(&ok), SetupEvent::Requested).unwrap();
892        assert_eq!((again.status, again.runs), (Pending, 1));
893        assert_eq!(next(Some(&bad), SetupEvent::Built).unwrap().status, Pending);
894        // Not twice at once, and no start or finish out of turn.
895        assert!(setup_next(true, Some(&r), SetupEvent::Requested, 7).is_err());
896        assert!(setup_next(true, Some(&ok), SetupEvent::Started, 7).is_err());
897        assert!(setup_next(true, Some(&p), SetupEvent::Finished(0), 7).is_err());
898        // A daemon restart fails a run it had begun, and nothing else.
899        let lost = next(Some(&r), SetupEvent::DaemonStarted).unwrap();
900        assert_eq!(lost.status, Failed);
901        assert!(lost.message.unwrap().contains("restarted"));
902        assert_eq!(next(Some(&ok), SetupEvent::DaemonStarted), Some(ok.clone()));
903        // No script: no state, and nothing to request.
904        assert_eq!(
905            setup_next(false, Some(&p), SetupEvent::Built, 7).unwrap(),
906            None
907        );
908        assert!(setup_next(false, None, SetupEvent::Requested, 7).is_err());
909        // Due only when pending with a script.
910        let mut w: Workspace = serde_json::from_value(serde_json::json!({
911            "name": "workspace", "id": "x", "image": "dev-base", "user": "dev",
912            "home_size": "1GiB", "token_role": "admin", "created_at": 0, "created_by": "a",
913            "setup": "apt-get install -y htop", "setup_state": {"status": "pending", "at": 1}
914        }))
915        .unwrap();
916        assert!(setup_due(&w));
917        w.setup_state = Some(ok);
918        assert!(!setup_due(&w));
919    }
920}