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    /// `volume` or `host`: where new homes go, overriding the daemon
357    /// (`host` needs `--workspace-home-root`). Platform admins.
358    #[serde(default, skip_serializing_if = "Option::is_none")]
359    pub home_kind: Option<String>,
360    /// The storage pool new workspace homes go in (platform admins); unset:
361    /// the daemon's `--workspace-pool`, else the org's default pool.
362    #[serde(default, skip_serializing_if = "Option::is_none")]
363    pub home_pool: Option<String>,
364}
365
366fn one() -> u32 {
367    1
368}
369
370fn default_expiry() -> String {
371    "24h".into()
372}
373
374fn default_idle() -> String {
375    "2h".into()
376}
377
378impl Default for Settings {
379    fn default() -> Self {
380        Settings {
381            max_workspaces: one(),
382            sandbox_expiry: default_expiry(),
383            sandbox_idle: default_idle(),
384            home_kind: None,
385            home_pool: None,
386        }
387    }
388}
389
390impl Settings {
391    /// Check the values a caller set.
392    pub fn validate(&self) -> Result<()> {
393        if self.max_workspaces == 0 || self.max_workspaces > 100 {
394            return Err(Error::invalid("max_workspaces: 1 to 100"));
395        }
396        lifetime(&self.sandbox_expiry)?;
397        idle(&self.sandbox_idle)?;
398        Ok(())
399    }
400}
401
402/// A sandbox lifetime: a duration up to [`MAX_SANDBOX_LIFETIME`].
403pub fn lifetime(s: &str) -> Result<Duration> {
404    let d = crate::flex::parse_duration(s.trim())
405        .map_err(|e| Error::invalid(format!("expiry {s:?}: {e}")))?;
406    if d.is_zero() || d > MAX_SANDBOX_LIFETIME {
407        return Err(Error::invalid(format!(
408            "expiry {s:?}: between a second and 30 days"
409        )));
410    }
411    Ok(d)
412}
413
414/// An idle timeout: a duration of at least a minute, or `none`.
415pub fn idle(s: &str) -> Result<Option<Duration>> {
416    let s = s.trim();
417    if matches!(s, "none" | "never" | "off") {
418        return Ok(None);
419    }
420    let d = crate::flex::parse_duration(s)
421        .map_err(|e| Error::invalid(format!("idle timeout {s:?}: {e}")))?;
422    if d < Duration::from_secs(60) || d > MAX_SANDBOX_LIFETIME {
423        return Err(Error::invalid(format!(
424            "idle timeout {s:?}: between a minute and 30 days, or none"
425        )));
426    }
427    Ok(Some(d))
428}
429
430/// A new sandbox's deadlines from the org's settings and what the caller
431/// asked for: `(expires_at, idle_timeout_secs)`.
432pub fn sandbox_deadlines(
433    settings: &Settings,
434    expires: Option<&str>,
435    idle_timeout: Option<&str>,
436    now: u64,
437) -> Result<(u64, Option<u64>)> {
438    let life = lifetime(expires.unwrap_or(&settings.sandbox_expiry))?;
439    let idle = idle(idle_timeout.unwrap_or(&settings.sandbox_idle))?;
440    Ok((now + life.as_secs(), idle.map(|d| d.as_secs())))
441}
442
443/// A sandbox's expiry pushed out by `by` (from the later of now and its
444/// current expiry), capped at 30 days from now.
445pub fn extended(current: Option<u64>, by: Duration, now: u64) -> Result<u64> {
446    if by.is_zero() {
447        return Err(Error::invalid("extend by a positive duration"));
448    }
449    let from = current.unwrap_or(now).max(now);
450    let want = from.saturating_add(by.as_secs());
451    let cap = now + MAX_SANDBOX_LIFETIME.as_secs();
452    if want > cap {
453        return Err(Error::invalid(format!(
454            "a sandbox lives at most 30 days from now; the most it can be extended by is {}",
455            human(cap.saturating_sub(from))
456        )));
457    }
458    Ok(want)
459}
460
461/// `90061` -> `1d 1h`: the two largest units.
462pub fn human(secs: u64) -> String {
463    let units = [(86400, "d"), (3600, "h"), (60, "m"), (1, "s")];
464    let mut parts = Vec::new();
465    let mut rest = secs;
466    for (n, u) in units {
467        if rest >= n {
468            parts.push(format!("{}{u}", rest / n));
469            rest %= n;
470        }
471        if parts.len() == 2 {
472            break;
473        }
474    }
475    if parts.is_empty() {
476        "0s".into()
477    } else {
478        parts.join(" ")
479    }
480}
481
482/// What kind of instance this is to isb, from its `user.*` config (keys
483/// without the `user.` prefix, as the metrics sample has them).
484pub fn kind_of(labels: &BTreeMap<String, String>) -> &'static str {
485    if labels.contains_key("isb.workspace") {
486        "workspace"
487    } else if labels.contains_key("isb.stack") {
488        "replica"
489    } else if labels.contains_key("isb.build") {
490        "build"
491    } else {
492        "sandbox"
493    }
494}
495
496/// Why the reaper takes this instance now, if it does. Only sandboxes isb
497/// gave deadlines (`isb.expires_at`, `isb.idle_timeout`) are candidates:
498/// never a workspace, a stack replica or an instance isb did not make.
499/// `last_active` is the latest activity isb saw (unix seconds).
500pub fn reap_reason(
501    labels: &BTreeMap<String, String>,
502    now: u64,
503    last_active: u64,
504) -> Option<String> {
505    if kind_of(labels) != "sandbox" {
506        return None;
507    }
508    let num = |k: &str| labels.get(k).and_then(|v| v.trim().parse::<u64>().ok());
509    if let Some(at) = num("isb.expires_at") {
510        if now >= at {
511            return Some(format!("expired {} ago", human(now - at)));
512        }
513    }
514    if let Some(idle) = num("isb.idle_timeout").filter(|s| *s > 0) {
515        if now.saturating_sub(last_active) >= idle {
516            return Some(format!(
517                "idle for {}",
518                human(now.saturating_sub(last_active))
519            ));
520        }
521    }
522    None
523}
524
525/// The directory an org's workspaces are kept in.
526pub fn dir(state: &Path, org: &OrgId) -> PathBuf {
527    crate::app::org_root(state, org).join("workspaces")
528}
529
530/// Reads and writes workspace definitions and settings.
531#[derive(Debug, Clone)]
532pub struct Store {
533    state: PathBuf,
534}
535
536impl Store {
537    pub fn new(state: &Path) -> Store {
538        Store {
539            state: state.to_path_buf(),
540        }
541    }
542
543    fn path(&self, org: &OrgId, name: &str) -> PathBuf {
544        dir(&self.state, org).join(format!("{name}.json"))
545    }
546
547    pub fn token_path(&self, org: &OrgId, name: &str) -> PathBuf {
548        dir(&self.state, org).join(format!("{name}.token.age"))
549    }
550
551    pub fn list(&self, org: &OrgId) -> Result<Vec<Workspace>> {
552        let d = dir(&self.state, org);
553        let mut out = Vec::new();
554        let rd = match std::fs::read_dir(&d) {
555            Ok(rd) => rd,
556            Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(out),
557            Err(e) => return Err(e.into()),
558        };
559        for e in rd.flatten() {
560            let p = e.path();
561            let Some(stem) = p
562                .file_name()
563                .and_then(|n| n.to_str())
564                .and_then(|n| n.strip_suffix(".json"))
565            else {
566                continue;
567            };
568            if stem == "settings" || check_name(stem).is_err() {
569                continue;
570            }
571            match self.get(org, stem) {
572                Ok(Some(w)) => out.push(w),
573                Ok(None) => {}
574                Err(e) => eprintln!("isb serve: workspace {}: {e}", p.display()),
575            }
576        }
577        out.sort_by(|a, b| a.name.cmp(&b.name));
578        Ok(out)
579    }
580
581    pub fn get(&self, org: &OrgId, name: &str) -> Result<Option<Workspace>> {
582        check_name(name)?;
583        match std::fs::read(self.path(org, name)) {
584            Ok(b) => Ok(Some(serde_json::from_slice(&b).map_err(|e| {
585                Error::invalid(format!("workspace {name} in {org}: {e}"))
586            })?)),
587            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
588            Err(e) => Err(e.into()),
589        }
590    }
591
592    pub fn put(&self, org: &OrgId, w: &Workspace) -> Result<()> {
593        check_name(&w.name)?;
594        crate::app::write_atomic(&self.path(org, &w.name), &serde_json::to_vec_pretty(w)?)
595    }
596
597    pub fn delete(&self, org: &OrgId, name: &str) -> Result<()> {
598        for p in [self.path(org, name), self.token_path(org, name)] {
599            match std::fs::remove_file(&p) {
600                Ok(()) => {}
601                Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
602                Err(e) => return Err(e.into()),
603            }
604        }
605        Ok(())
606    }
607
608    pub fn settings(&self, org: &OrgId) -> Result<Settings> {
609        match std::fs::read(dir(&self.state, org).join("settings.json")) {
610            Ok(b) => serde_json::from_slice(&b)
611                .map_err(|e| Error::invalid(format!("workspace settings of {org}: {e}"))),
612            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Settings::default()),
613            Err(e) => Err(e.into()),
614        }
615    }
616
617    pub fn put_settings(&self, org: &OrgId, s: &Settings) -> Result<()> {
618        s.validate()?;
619        crate::app::write_atomic(
620            &dir(&self.state, org).join("settings.json"),
621            &serde_json::to_vec_pretty(s)?,
622        )
623    }
624
625    /// Every org with a workspace directory: the default org's, then each
626    /// `orgs/<org>/workspaces`.
627    pub fn orgs(&self) -> Vec<OrgId> {
628        let mut out = Vec::new();
629        if dir(&self.state, &OrgId::default_org()).is_dir() {
630            out.push(OrgId::default_org());
631        }
632        if let Ok(rd) = std::fs::read_dir(self.state.join("orgs")) {
633            for e in rd.flatten() {
634                let Some(n) = e.file_name().to_str().map(String::from) else {
635                    continue;
636                };
637                if let Ok(o) = OrgId::new(n) {
638                    if !o.is_default() && dir(&self.state, &o).is_dir() {
639                        out.push(o);
640                    }
641                }
642            }
643        }
644        out.sort_by(|a, b| a.as_str().cmp(b.as_str()));
645        out
646    }
647}
648
649/// Shell-quote `s` for a POSIX shell (single quotes).
650pub fn sh_quote(s: &str) -> String {
651    format!("'{}'", s.replace('\'', r"'\''"))
652}
653
654/// `/etc/profile.d/isb.sh`: the workspace's variables, then isb's. The token
655/// is read from its file, never written here.
656pub fn profile(url: Option<&str>, org: &OrgId, w: &Workspace) -> String {
657    let mut s = String::from(
658        "# Written by isb for this workspace (docs/concepts/workspaces.md); rewritten on every start.\n",
659    );
660    for (k, v) in &w.env {
661        s.push_str(&format!("export {k}={}\n", sh_quote(v)));
662    }
663    if let Some(u) = url {
664        s.push_str(&format!("export ISB_URL={}\n", sh_quote(u)));
665    }
666    s.push_str(&format!("export ISB_ORG={}\n", sh_quote(org.as_str())));
667    s.push_str(&format!("export ISB_WORKSPACE={}\n", sh_quote(&w.name)));
668    s.push_str(&format!(
669        "if [ -r {TOKEN_PATH} ]; then ISB_TOKEN=$(cat {TOKEN_PATH}); export ISB_TOKEN; fi\n"
670    ));
671    s
672}
673
674#[cfg(test)]
675mod tests {
676    use super::*;
677
678    fn labels(kv: &[(&str, &str)]) -> BTreeMap<String, String> {
679        kv.iter()
680            .map(|(k, v)| (k.to_string(), v.to_string()))
681            .collect()
682    }
683
684    #[test]
685    fn host_folder_homes() {
686        let org = OrgId::new("ocai").unwrap();
687        let root = Path::new("/srv/workspaces");
688        assert_eq!(
689            host_home(root, &org, DEFAULT_NAME),
690            Path::new("/srv/workspaces/ocai/home")
691        );
692        assert_eq!(
693            host_home(root, &org, "lab"),
694            Path::new("/srv/workspaces/ocai/lab/home")
695        );
696    }
697
698    #[test]
699    fn human_durations() {
700        assert_eq!(human(0), "0s");
701        assert_eq!(human(59), "59s");
702        assert_eq!(human(3600 + 120 + 5), "1h 2m");
703        assert_eq!(human(90061), "1d 1h");
704    }
705
706    #[test]
707    fn names_users_and_env() {
708        assert!(check_name("workspace").is_ok());
709        assert!(check_name("ws-2").is_ok());
710        for bad in ["", "2ws", "Ws", "ws_", "ws-", &"a".repeat(31)] {
711            assert!(check_name(bad).is_err(), "{bad}");
712        }
713        assert!(check_user("dev").is_ok());
714        assert!(check_user("Dev").is_err());
715        assert!(check_env_name("EDITOR").is_ok());
716        assert!(check_env_name("ISB_TOKEN").is_err());
717        assert!(check_env_name("1X").is_err());
718    }
719
720    #[test]
721    fn settings_default_and_bounds() {
722        let s = Settings::default();
723        assert_eq!(s.max_workspaces, 1);
724        assert_eq!(s.sandbox_expiry, "24h");
725        assert_eq!(s.sandbox_idle, "2h");
726        s.validate().unwrap();
727        let mut b = s.clone();
728        b.sandbox_expiry = "31d".into();
729        assert!(b.validate().is_err());
730        b.sandbox_expiry = "7d".into();
731        b.sandbox_idle = "none".into();
732        b.validate().unwrap();
733        b.max_workspaces = 0;
734        assert!(b.validate().is_err());
735        // A file from before a field existed gets its default.
736        let old: Settings = serde_json::from_str(r#"{"max_workspaces": 2}"#).unwrap();
737        assert_eq!(old.sandbox_idle, "2h");
738    }
739
740    #[test]
741    fn deadlines_and_extension() {
742        let s = Settings::default();
743        let (exp, idle) = sandbox_deadlines(&s, None, None, 1000).unwrap();
744        assert_eq!(exp, 1000 + 86400);
745        assert_eq!(idle, Some(7200));
746        let (exp, idle) = sandbox_deadlines(&s, Some("1h"), Some("none"), 0).unwrap();
747        assert_eq!((exp, idle), (3600, None));
748        assert!(sandbox_deadlines(&s, Some("40d"), None, 0).is_err());
749        assert!(sandbox_deadlines(&s, None, Some("10s"), 0).is_err());
750        // From the later of now and the current expiry.
751        assert_eq!(
752            extended(Some(500), Duration::from_secs(100), 1000).unwrap(),
753            1100
754        );
755        assert_eq!(
756            extended(Some(2000), Duration::from_secs(100), 1000).unwrap(),
757            2100
758        );
759        assert!(extended(None, Duration::from_secs(31 * 86400), 0).is_err());
760        assert!(extended(None, Duration::ZERO, 0).is_err());
761    }
762
763    #[test]
764    fn the_reaper_takes_only_sandboxes_past_their_deadlines() {
765        let now = 10_000;
766        let exp = labels(&[("isb.expires_at", "9000")]);
767        assert!(reap_reason(&exp, now, now).unwrap().starts_with("expired"));
768        let live = labels(&[("isb.expires_at", "20000"), ("isb.idle_timeout", "600")]);
769        assert_eq!(reap_reason(&live, now, now - 100), None);
770        assert!(
771            reap_reason(&live, now, now - 600)
772                .unwrap()
773                .starts_with("idle")
774        );
775        // Never a workspace, a replica, a build or an instance without
776        // deadlines, whatever its labels say.
777        for extra in ["isb.workspace", "isb.stack", "isb.build"] {
778            let mut l = exp.clone();
779            l.insert(extra.into(), "x".into());
780            assert_eq!(reap_reason(&l, now, 0), None, "{extra}");
781        }
782        assert_eq!(
783            reap_reason(&labels(&[("isb.owner", "mcp:a")]), now, 0),
784            None
785        );
786        assert_eq!(
787            reap_reason(&labels(&[("isb.expires_at", "soon")]), now, 0),
788            None
789        );
790    }
791
792    #[test]
793    fn the_store_round_trips() {
794        let d = tempfile::tempdir().unwrap();
795        let st = Store::new(d.path());
796        let org = OrgId::new("acme").unwrap();
797        assert!(st.list(&org).unwrap().is_empty());
798        assert_eq!(st.settings(&org).unwrap(), Settings::default());
799        let w = Workspace {
800            name: "workspace".into(),
801            id: "abc".into(),
802            image: "dev-base".into(),
803            user: "dev".into(),
804            cpus: Some(2),
805            memory: None,
806            root_size: None,
807            home_size: "10GiB".into(),
808            env: BTreeMap::new(),
809            secrets: vec![],
810            labels: BTreeMap::new(),
811            token_role: Role::Admin,
812            home_bind: None,
813            pool: None,
814            created_at: 1,
815            created_by: "a@x.io".into(),
816            updated_at: 1,
817            rebuilt_at: None,
818            token: None,
819            setup: None,
820            setup_state: None,
821            ports: vec![],
822        };
823        st.put(&org, &w).unwrap();
824        st.put_settings(&org, &Settings::default()).unwrap();
825        assert_eq!(st.list(&org).unwrap(), vec![w.clone()]);
826        assert_eq!(st.orgs(), vec![org.clone()]);
827        assert_eq!(w.home_volume(&org), "acme_workspace_home");
828        assert_eq!(w.home_dir(), "/home/dev");
829        st.delete(&org, "workspace").unwrap();
830        assert!(st.get(&org, "workspace").unwrap().is_none());
831    }
832
833    #[test]
834    fn the_profile_quotes_values_and_never_holds_the_token() {
835        let org = OrgId::new("acme").unwrap();
836        let mut w: Workspace = serde_json::from_value(serde_json::json!({
837            "name": "workspace", "id": "x", "image": "dev-base", "user": "dev",
838            "home_size": "1GiB", "token_role": "admin", "created_at": 0, "created_by": "a"
839        }))
840        .unwrap();
841        w.env.insert("GREETING".into(), "it's".into());
842        let p = profile(Some("http://10.0.0.1:8481"), &org, &w);
843        assert!(p.contains("export GREETING='it'\\''s'\n"));
844        assert!(p.contains("export ISB_URL='http://10.0.0.1:8481'\n"));
845        assert!(p.contains("export ISB_ORG='acme'\n"));
846        assert!(p.contains("ISB_TOKEN=$(cat /run/isb/token)"));
847    }
848
849    #[test]
850    fn the_setup_script_runs_once_per_build_and_again_on_request() {
851        use SetupStatus::*;
852        let next = |cur: Option<&SetupState>, ev| setup_next(true, cur, ev, 7).unwrap();
853        // Built: pending; started: running, counted; finished: done.
854        let p = next(None, SetupEvent::Built).unwrap();
855        assert_eq!((p.status, p.runs), (Pending, 0));
856        let r = next(Some(&p), SetupEvent::Started).unwrap();
857        assert_eq!((r.status, r.runs), (Running, 1));
858        let ok = next(Some(&r), SetupEvent::Finished(0)).unwrap();
859        assert_eq!((ok.status, ok.exit_code), (Succeeded, Some(0)));
860        let bad = next(Some(&r), SetupEvent::Finished(3)).unwrap();
861        assert_eq!((bad.status, bad.exit_code), (Failed, Some(3)));
862        let err = next(Some(&r), SetupEvent::Error("timed out".into())).unwrap();
863        assert_eq!(
864            (err.status, err.message.as_deref()),
865            (Failed, Some("timed out"))
866        );
867        // Done stays done until a rebuild or a request; both keep the count.
868        let again = next(Some(&ok), SetupEvent::Requested).unwrap();
869        assert_eq!((again.status, again.runs), (Pending, 1));
870        assert_eq!(next(Some(&bad), SetupEvent::Built).unwrap().status, Pending);
871        // Not twice at once, and no start or finish out of turn.
872        assert!(setup_next(true, Some(&r), SetupEvent::Requested, 7).is_err());
873        assert!(setup_next(true, Some(&ok), SetupEvent::Started, 7).is_err());
874        assert!(setup_next(true, Some(&p), SetupEvent::Finished(0), 7).is_err());
875        // A daemon restart fails a run it had begun, and nothing else.
876        let lost = next(Some(&r), SetupEvent::DaemonStarted).unwrap();
877        assert_eq!(lost.status, Failed);
878        assert!(lost.message.unwrap().contains("restarted"));
879        assert_eq!(next(Some(&ok), SetupEvent::DaemonStarted), Some(ok.clone()));
880        // No script: no state, and nothing to request.
881        assert_eq!(
882            setup_next(false, Some(&p), SetupEvent::Built, 7).unwrap(),
883            None
884        );
885        assert!(setup_next(false, None, SetupEvent::Requested, 7).is_err());
886        // Due only when pending with a script.
887        let mut w: Workspace = serde_json::from_value(serde_json::json!({
888            "name": "workspace", "id": "x", "image": "dev-base", "user": "dev",
889            "home_size": "1GiB", "token_role": "admin", "created_at": 0, "created_by": "a",
890            "setup": "apt-get install -y htop", "setup_state": {"status": "pending", "at": 1}
891        }))
892        .unwrap();
893        assert!(setup_due(&w));
894        w.setup_state = Some(ok);
895        assert!(!setup_due(&w));
896    }
897}