Skip to main content

isb_daemon/daemon/
workspaces.rs

1//! Workspaces in the daemon (docs/concepts/workspaces.md): the `workspace_*` tools,
2//! the workspace's token and its delivery into the machine, the per-org MCP
3//! listener on the org's bridge, live sessions, and the sandbox reaper.
4//!
5//! - **The workspace** is an incus container in the org's project, named
6//!   after the workspace, with a managed volume `<org>_<name>_home` mounted
7//!   at the workspace user's home. Rebuilding replaces the container from
8//!   its image and mounts the same home again.
9//! - **Its token** (`isb_ws_...`) is minted at create, kept as a SHA-256
10//!   for authentication and, encrypted to the daemon's age key, as the
11//!   token itself so it can be delivered again after a restart. It is never
12//!   returned by a tool, written to a log, or put on a command line: it
13//!   reaches the machine through incus' file API as `/run/isb/token` (0400,
14//!   the workspace user's). Deleting the workspace revokes it.
15//! - **The bridge listener**: for each org with a workspace, the daemon
16//!   serves the org-bound MCP and REST surface (`/orgs/<org>/...` only) on
17//!   the org bridge's gateway address, port 8481 by default. Only peers in
18//!   the org's own subnet are answered, and only with a bearer token (the
19//!   workspace's, or an org API token); the authorizer pins the org.
20//! - **The reaper** deletes sandboxes past their expiry or idle timeout,
21//!   only those isb gave deadlines, never a workspace or a replica, and
22//!   records each in the history.
23
24use std::collections::{BTreeMap, HashMap};
25use std::net::{IpAddr, Ipv4Addr, SocketAddr};
26use std::path::{Path, PathBuf};
27use std::sync::{Arc, Mutex, OnceLock};
28use std::time::Duration;
29
30use serde::Deserialize;
31use serde_json::{Value, json};
32
33use super::{Daemon, args, obj};
34use crate::auth::secret::{self, TokenKind};
35use crate::auth::{Principal, Role};
36use crate::client::{Client, encode_segment};
37use crate::error::{Error, Result};
38use crate::org::OrgId;
39use crate::sandbox::Sandbox;
40use crate::server::http::Shutdown;
41use crate::server::{Caller, Healthz, Listener, Registry, Tool};
42use crate::workspace::{self as ws, Settings, Store, TokenMeta, Workspace};
43
44mod bridge;
45mod create;
46mod herdr;
47mod image_tools;
48mod images;
49mod nesting;
50mod ports;
51mod preview;
52mod proxy;
53mod secrets;
54mod settings;
55mod setup;
56use bridge::{bridge_handler, gateway};
57use create::{check_fields, check_size, token_role, workspace_create};
58pub(super) use herdr::{Herdr, attach_argv};
59pub(super) use ports::start as start_ports;
60pub(super) use preview::route as preview_route;
61pub use preview::{PreviewBase, Previews};
62
63/// The bridge listener's port when `--workspace-mcp-port` is not given.
64pub const DEFAULT_PORT: u16 = 8481;
65
66/// How often the upkeep thread runs, and the reaper within it.
67const UPKEEP_EVERY: Duration = Duration::from_secs(15);
68const REAP_EVERY: Duration = Duration::from_secs(60);
69/// A sandbox using this much of a core is active.
70const ACTIVE_CPU_PCT: f32 = 2.0;
71/// How long an SSH session count is reused.
72const SSH_CACHE: Duration = Duration::from_secs(30);
73
74fn now() -> u64 {
75    crate::stack::now_secs()
76}
77
78fn random_hex(n: usize) -> String {
79    use ring::rand::SecureRandom;
80    let mut b = vec![0u8; n];
81    let _ = ring::rand::SystemRandom::new().fill(&mut b);
82    b.iter().map(|x| format!("{x:02x}")).collect()
83}
84
85fn hex(b: &[u8]) -> String {
86    b.iter().map(|x| format!("{x:02x}")).collect()
87}
88
89fn key(project: &str, instance: &str) -> String {
90    format!("{project}/{instance}")
91}
92
93#[derive(Debug, Clone)]
94struct TokenRef {
95    org: OrgId,
96    name: String,
97    role: Role,
98}
99
100/// A running bridge listener.
101struct Bridge {
102    addr: SocketAddr,
103    stop: Shutdown,
104}
105
106/// Live sessions on an instance (web terminals now; SSH through the
107/// daemon later), counted while their guard lives.
108pub struct SessionGuard {
109    key: String,
110    sessions: Arc<Mutex<HashMap<String, usize>>>,
111}
112
113impl Drop for SessionGuard {
114    fn drop(&mut self) {
115        let mut m = self.sessions.lock().unwrap_or_else(|e| e.into_inner());
116        if let Some(n) = m.get_mut(&self.key) {
117            *n = n.saturating_sub(1);
118            if *n == 0 {
119                m.remove(&self.key);
120            }
121        }
122    }
123}
124
125/// Live sessions a workspace has, as far as isb can tell.
126#[derive(Debug, Clone, Default, serde::Serialize)]
127pub struct Sessions {
128    /// Web terminals open through this daemon.
129    pub terminals: usize,
130    /// Established SSH connections inside the machine (`ss`), when it can
131    /// be asked.
132    pub ssh: Option<usize>,
133    pub total: usize,
134}
135
136impl Sessions {
137    fn describe(&self) -> String {
138        let mut parts = Vec::new();
139        if self.terminals > 0 {
140            parts.push(format!("{} web terminal(s)", self.terminals));
141        }
142        if let Some(n) = self.ssh.filter(|n| *n > 0) {
143            parts.push(format!("{n} SSH connection(s)"));
144        }
145        if parts.is_empty() {
146            "no live sessions isb can see".into()
147        } else {
148            parts.join(" and ")
149        }
150    }
151}
152
153/// The daemon's workspaces.
154pub struct Workspaces {
155    store: Store,
156    client: Client,
157    keyring: Arc<crate::secrets::Keyring>,
158    secrets: Arc<crate::secrets::Secrets>,
159    recorder: Arc<crate::history::Recorder>,
160    port: u16,
161    /// `--workspace-pool`: where new homes go unless the org says.
162    home_pool: Option<String>,
163    /// `--workspace-home-root`: homes are host folders under it.
164    home_root: Option<PathBuf>,
165    tokens: Mutex<HashMap<Vec<u8>, TokenRef>>,
166    /// Token last use, by `<org>/<name>` (in memory).
167    last_used: Mutex<HashMap<String, u64>>,
168    sessions: Arc<Mutex<HashMap<String, usize>>>,
169    /// The latest activity isb saw per `<project>/<instance>`: exec, a
170    /// terminal, CPU use.
171    activity: Mutex<HashMap<String, u64>>,
172    /// The init pid each workspace's credentials were delivered for.
173    delivered: Mutex<HashMap<String, i64>>,
174    secret_poll: Mutex<secrets::Poll>,
175    bridges: Mutex<HashMap<OrgId, Bridge>>,
176    /// The SSH count per workspace and when it was asked, so pages that
177    /// poll do not exec in the machine every few seconds.
178    ssh: Mutex<HashMap<String, (std::time::Instant, Option<usize>)>>,
179    serve: OnceLock<(Listener, Arc<Registry>, Healthz)>,
180    /// Create, rebuild and delete one at a time.
181    lock: Mutex<()>,
182    started: u64,
183    /// Published ports' previews through isb.
184    pub previews: Previews,
185}
186
187impl Workspaces {
188    pub fn new(
189        state_dir: &Path,
190        client: Client,
191        secrets: Arc<crate::secrets::Secrets>,
192        recorder: Arc<crate::history::Recorder>,
193        port: u16,
194        home_pool: Option<String>,
195        home_root: Option<PathBuf>,
196    ) -> Arc<Workspaces> {
197        let w = Arc::new(Workspaces {
198            store: Store::new(state_dir),
199            client,
200            keyring: secrets.keyring().clone(),
201            secrets,
202            recorder,
203            port,
204            home_pool,
205            home_root,
206            tokens: Mutex::new(HashMap::new()),
207            last_used: Mutex::new(HashMap::new()),
208            sessions: Arc::new(Mutex::new(HashMap::new())),
209            activity: Mutex::new(HashMap::new()),
210            delivered: Mutex::new(HashMap::new()),
211            secret_poll: Mutex::default(),
212            bridges: Mutex::new(HashMap::new()),
213            ssh: Mutex::new(HashMap::new()),
214            serve: OnceLock::new(),
215            lock: Mutex::new(()),
216            started: now(),
217            previews: Previews::default(),
218        });
219        w.load_tokens();
220        w
221    }
222
223    fn load_tokens(&self) {
224        let mut m = self.tokens.lock().unwrap_or_else(|e| e.into_inner());
225        for org in self.store.orgs() {
226            for w in self.store.list(&org).unwrap_or_default() {
227                if let Some(t) = &w.token {
228                    if let Some(h) = unhex(&t.hash) {
229                        m.insert(
230                            h,
231                            TokenRef {
232                                org: org.clone(),
233                                name: w.name.clone(),
234                                role: w.token_role,
235                            },
236                        );
237                    }
238                }
239            }
240        }
241    }
242
243    /// The principal behind a workspace token, if it is one of ours.
244    pub fn authenticate(&self, token: &str) -> Option<Principal> {
245        if !secret::well_formed(token, TokenKind::Workspace) {
246            return None;
247        }
248        let h = secret::hash_token(token);
249        let m = self.tokens.lock().unwrap_or_else(|e| e.into_inner());
250        let (stored, r) = m.get_key_value(&h)?;
251        if !secret::ct_eq(stored, &h) {
252            return None;
253        }
254        self.last_used
255            .lock()
256            .unwrap_or_else(|e| e.into_inner())
257            .insert(format!("{}/{}", r.org, r.name), now());
258        Some(Principal::workspace(&r.org, &r.name, r.role))
259    }
260
261    fn index(&self, org: &OrgId, w: &Workspace) {
262        let mut m = self.tokens.lock().unwrap_or_else(|e| e.into_inner());
263        m.retain(|_, r| !(r.org == *org && r.name == w.name));
264        if let Some(h) = w.token.as_ref().and_then(|t| unhex(&t.hash)) {
265            m.insert(
266                h,
267                TokenRef {
268                    org: org.clone(),
269                    name: w.name.clone(),
270                    role: w.token_role,
271                },
272            );
273        }
274    }
275
276    /// Mint a new token for `w` (replacing any), keep it encrypted, and
277    /// index it. The old one stops working at once.
278    fn mint(&self, org: &OrgId, w: &mut Workspace) -> Result<()> {
279        let (token, hash) = secret::new_token(TokenKind::Workspace).map_err(Error::from)?;
280        let ct = self.keyring.encrypt(token.as_bytes())?;
281        crate::app::write_atomic(&self.store.token_path(org, &w.name), &ct)?;
282        w.token = Some(TokenMeta {
283            id: random_hex(4),
284            hash: hex(&hash),
285            created_at: now(),
286        });
287        self.index(org, w);
288        Ok(())
289    }
290
291    fn token_plain(&self, org: &OrgId, name: &str) -> Result<Option<Vec<u8>>> {
292        match std::fs::read(self.store.token_path(org, name)) {
293            Ok(ct) => Ok(Some(self.keyring.decrypt(&ct)?)),
294            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
295            Err(e) => Err(e.into()),
296        }
297    }
298
299    /// Count a session on an instance while the guard lives.
300    pub fn session(&self, project: &str, instance: &str) -> SessionGuard {
301        let k = key(project, instance);
302        *self
303            .sessions
304            .lock()
305            .unwrap_or_else(|e| e.into_inner())
306            .entry(k.clone())
307            .or_insert(0) += 1;
308        self.mark_active(project, instance);
309        SessionGuard {
310            key: k,
311            sessions: self.sessions.clone(),
312        }
313    }
314
315    fn terminals(&self, project: &str, instance: &str) -> usize {
316        self.sessions
317            .lock()
318            .unwrap_or_else(|e| e.into_inner())
319            .get(&key(project, instance))
320            .copied()
321            .unwrap_or(0)
322    }
323
324    /// isb saw the instance used (exec, a terminal).
325    pub fn mark_active(&self, project: &str, instance: &str) {
326        self.activity
327            .lock()
328            .unwrap_or_else(|e| e.into_inner())
329            .insert(key(project, instance), now());
330    }
331
332    /// The latest activity isb saw on an instance, if any.
333    pub fn last_seen(&self, project: &str, instance: &str) -> Option<u64> {
334        self.activity
335            .lock()
336            .unwrap_or_else(|e| e.into_inner())
337            .get(&key(project, instance))
338            .copied()
339    }
340
341    fn last_active(&self, project: &str, instance: &str) -> u64 {
342        let seen = self
343            .activity
344            .lock()
345            .unwrap_or_else(|e| e.into_inner())
346            .get(&key(project, instance))
347            .copied()
348            .unwrap_or(0);
349        // A daemon that just started has seen nothing yet: it counts from
350        // its own start, never from before.
351        seen.max(self.started)
352    }
353
354    /// The org's workspace settings.
355    pub fn settings(&self, org: &OrgId) -> Result<Settings> {
356        self.store.settings(org)
357    }
358
359    /// `http://<gateway>:<port>`: where the org's instances reach isb.
360    pub fn url(&self, org: &OrgId) -> Option<String> {
361        let (gw, _) = self.gateway(org)?;
362        Some(format!("http://{gw}:{}", self.port))
363    }
364
365    fn gateway(&self, org: &OrgId) -> Option<(Ipv4Addr, (u32, u32))> {
366        let info = crate::org::get(&self.client, org).ok()?;
367        gateway(info.subnet.as_deref()?)
368    }
369
370    /// The listener config and registry the bridge listeners serve.
371    pub fn set_serving(&self, l: Listener, registry: Arc<Registry>, healthz: Healthz) {
372        let _ = self.serve.set((l, registry, healthz));
373    }
374
375    /// Serve the org's bridge listener if the org has a workspace, stop it
376    /// if not. Idempotent.
377    fn ensure_bridge(&self, org: &OrgId) {
378        let wanted = !self.store.list(org).unwrap_or_default().is_empty();
379        let mut b = self.bridges.lock().unwrap_or_else(|e| e.into_inner());
380        if !wanted {
381            if let Some(old) = b.remove(org) {
382                old.stop.trigger();
383                eprintln!(
384                    "isb serve: org {org}: MCP on {} stopped (no workspace)",
385                    old.addr
386                );
387            }
388            return;
389        }
390        let Some((gw, net)) = self.gateway(org) else {
391            return;
392        };
393        let addr = SocketAddr::new(IpAddr::V4(gw), self.port);
394        if b.get(org).is_some_and(|x| x.addr == addr) {
395            return;
396        }
397        if let Some(old) = b.remove(org) {
398            old.stop.trigger();
399        }
400        let Some((l, reg, hz)) = self.serve.get() else {
401            return;
402        };
403        let inner = crate::server::handler(l, reg.clone(), hz.clone());
404        let h = bridge_handler(org.clone(), net, inner);
405        let stop = Shutdown::new();
406        match crate::server::spawn_private(addr, h, stop.clone()) {
407            Ok(_) => {
408                eprintln!(
409                    "isb serve: org {org}: MCP for its workspace on http://{addr}/orgs/{org}/mcp (its own subnet, bearer tokens only)"
410                );
411                b.insert(org.clone(), Bridge { addr, stop });
412            }
413            Err(e) => {
414                eprintln!("isb serve: org {org}: cannot serve MCP on {addr}: {e} (retrying)")
415            }
416        }
417    }
418
419    /// Stop every bridge listener.
420    pub fn shutdown(&self) {
421        for (_, b) in self
422            .bridges
423            .lock()
424            .unwrap_or_else(|e| e.into_inner())
425            .drain()
426        {
427            b.stop.trigger();
428        }
429    }
430
431    fn record(&self, org: &OrgId, kind: &str, object: &str, actor: &str, msg: String, d: Value) {
432        self.recorder.record(crate::history::NewRecord {
433            source: "controller".into(),
434            org: Some(org.as_str().to_string()),
435            project: Some(org.incus_project()),
436            kind: kind.into(),
437            object_type: Some(if kind.starts_with("sandbox.") {
438                "instance".into()
439            } else {
440                "workspace".into()
441            }),
442            object: Some(object.to_string()),
443            objects: vec![object.to_string()],
444            actor: Some(actor.to_string()),
445            level: Some("info".into()),
446            message: Some(msg),
447            details: d,
448            ..Default::default()
449        });
450    }
451
452    // ---- the machine ----
453
454    fn oc(&self, org: &OrgId) -> Client {
455        crate::org::client(&self.client, org)
456    }
457
458    /// The storage pool the org's instances use (its default profile's root
459    /// disk), else the host's pick.
460    fn pool(&self, oc: &Client) -> Result<String> {
461        if let Ok(Some(p)) = oc.get_opt("/1.0/profiles/default") {
462            if let Some(pool) = p["devices"]["root"]["pool"].as_str() {
463                return Ok(pool.to_string());
464            }
465        }
466        crate::sandbox::host_facts(oc)?.pick_pool(None)
467    }
468
469    /// Where a new workspace's home goes: the org's `home_pool`, else
470    /// `--workspace-pool`, else the org's default pool.
471    fn new_home_pool(&self, org: &OrgId, oc: &Client) -> Result<String> {
472        let s = self.store.settings(org)?;
473        match s.home_pool.or_else(|| self.home_pool.clone()) {
474            Some(p) => {
475                pool_driver(&self.client, &p)
476                    .map_err(|e| Error::invalid(format!("workspace home pool {p}: {e}")))?;
477                Ok(p)
478            }
479            None => self.pool(oc),
480        }
481    }
482
483    /// The pool this workspace's home is in.
484    fn home_pool_of(&self, w: &Workspace, oc: &Client) -> Result<String> {
485        match &w.pool {
486            Some(p) => Ok(p.clone()),
487            None => self.pool(oc),
488        }
489    }
490
491    /// The instance spec a workspace is created (and rebuilt) from.
492    fn spec(org: &OrgId, w: &Workspace, pool: &str) -> Result<crate::spec::SandboxSpec> {
493        let home = w.home_dir();
494        let mut v = json!({
495            "container_name": w.name,
496            "image": w.image,
497            "user": w.user,
498            "working_dir": home,
499            "labels": {"isb.workspace": w.name, "isb.owner": w.created_by},
500            "raw_config": {"boot.autostart": "true"},
501        });
502        for (k, val) in &w.labels {
503            v["labels"][k] = json!(val);
504        }
505        if let Some(c) = w.cpus {
506            v["cpus"] = json!(c.to_string());
507        }
508        if let Some(m) = &w.memory {
509            v["mem_limit"] = json!(m);
510        }
511        if let Some(r) = &w.root_size {
512            v["raw_devices"] = json!({"root": {"size": r}});
513        }
514        if w.home_bind.is_some() {
515            // The daemon's uid 1:1, so the workspace user (that uid) owns
516            // the host folder's files inside, as the host sees them.
517            v["idmap"] = json!("auto");
518        }
519        v["volumes"] = match &w.home_bind {
520            Some(dir) => json!([{"type": "bind", "source": dir, "target": home}]),
521            None => {
522                json!([{"type": "volume", "source": w.home_volume(org), "target": home, "pool": pool}])
523            }
524        };
525        serde_json::from_value(v).map_err(|e| Error::invalid(format!("workspace spec: {e}")))
526    }
527
528    /// Create (or recreate) the instance, make the user and its home, and
529    /// deliver the credentials.
530    fn build(&self, org: &OrgId, w: &Workspace, log: &mut Vec<String>) -> Result<()> {
531        let oc = self.oc(org);
532        let pool = self.home_pool_of(w, &oc)?;
533        if let Some(dir) = &w.home_bind {
534            self.prepare_host_home(org, Path::new(dir), log)?;
535        }
536        if w.home_bind.is_none() {
537            let mut cfg = crate::plan::Props::new();
538            cfg.insert("size".into(), w.home_size.clone());
539            if crate::volume::ensure(&oc, &pool, &w.home_volume(org), &cfg)? {
540                log.push(format!(
541                    "home volume {} ({}) created",
542                    w.home_volume(org),
543                    w.home_size
544                ));
545            }
546        }
547        let mut w = w.clone();
548        if w.root_size.is_none() && project_has_disk_limit(&self.client, org) {
549            // incus counts every disk against limits.disk, so a root
550            // without a size cannot be created in such a project.
551            w.root_size = Some(DEFAULT_ROOT_SIZE.into());
552        }
553        let w = &w;
554        let mut spec = Self::spec(org, w, &pool)?;
555        if nesting::org_allows(&self.client, org) {
556            nesting::apply(&mut spec);
557        }
558        let base = std::env::temp_dir();
559        let (_sb, _) = Sandbox::connect_or_create_with_base(
560            &oc,
561            &spec,
562            &Default::default(),
563            &base,
564            crate::sandbox::EnsureOptions::default(),
565            &mut |m| log.push(m.to_string()),
566        )?;
567        self.prepare(&oc, w)?;
568        self.deliver(org, w)?;
569        Ok(())
570    }
571
572    /// A host-folder home: made if missing (the daemon's user owns it, which
573    /// the instance maps 1:1), and allowed in the org's restricted project
574    /// as a disk path (its `<root>/<org>` under the home root, else the
575    /// folder itself). Done on every build, so it survives the org's bind
576    /// roots being rewritten.
577    fn prepare_host_home(&self, org: &OrgId, dir: &Path, log: &mut Vec<String>) -> Result<()> {
578        use std::os::unix::fs::PermissionsExt;
579        if !dir.is_absolute() {
580            return Err(Error::invalid(format!(
581                "home {}: an absolute host path",
582                dir.display()
583            )));
584        }
585        if !dir.exists() {
586            std::fs::create_dir_all(dir)?;
587            std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o750))?;
588            log.push(format!("home folder {} created", dir.display()));
589        }
590        let allow = match &self.home_root {
591            Some(root) if dir.starts_with(root.join(org.as_str())) => root.join(org.as_str()),
592            _ => dir.to_path_buf(),
593        };
594        crate::org::allow_home(&self.client, org, &allow)
595    }
596
597    /// The workspace user and its home: made when the image lacks them,
598    /// seeded from `/etc/skel` when the home is empty, owned by the user.
599    fn prepare(&self, oc: &Client, w: &Workspace) -> Result<()> {
600        const SCRIPT: &str = r#"set -e
601u="$1"; h="$2"; id="$3"
602if ! id -u "$u" >/dev/null 2>&1; then
603  if command -v useradd >/dev/null 2>&1; then
604    useradd ${id:+-u "$id"} -M -d "$h" -s /bin/bash "$u" 2>/dev/null || useradd ${id:+-u "$id"} -M -d "$h" "$u"
605  else
606    adduser ${id:+-u "$id"} -D -H -h "$h" "$u"
607  fi
608fi
609mkdir -p "$h"
610if [ -z "$(ls -A "$h" 2>/dev/null)" ] && [ -d /etc/skel ]; then
611  cp -rT /etc/skel "$h"
612  chown -R "$u": "$h"
613fi
614chown "$u": "$h"
615"#;
616        if w.user == "root" {
617            return Ok(());
618        }
619        let sb = Sandbox::get(oc, w.instance())?;
620        let out = sb.exec_with(
621            [
622                "sh",
623                "-c",
624                SCRIPT,
625                "sh",
626                w.user.as_str(),
627                &w.home_dir(),
628                // A host-folder home is mapped 1:1 for the daemon's uid: a
629                // user made here gets that uid, so it owns the files.
630                &if w.home_bind.is_some() {
631                    rustix::process::getuid().as_raw().to_string()
632                } else {
633                    String::new()
634                },
635            ],
636            crate::exec::ExecOptions::default().timeout(Duration::from_secs(120)),
637        )?;
638        if !out.success() {
639            return Err(Error::invalid(format!(
640                "could not prepare user {} in {}: {}",
641                w.user,
642                w.name,
643                String::from_utf8_lossy(&out.stderr).trim()
644            )));
645        }
646        Ok(())
647    }
648
649    /// Live sessions: web terminals through this daemon, and established
650    /// SSH connections inside the machine.
651    pub fn sessions(&self, org: &OrgId, w: &Workspace, running: bool) -> Sessions {
652        let terminals = self.terminals(&org.incus_project(), w.instance());
653        let k = key(&org.incus_project(), w.instance());
654        let cached = self
655            .ssh
656            .lock()
657            .unwrap_or_else(|e| e.into_inner())
658            .get(&k)
659            .filter(|(at, _)| at.elapsed() < SSH_CACHE)
660            .map(|(_, n)| *n);
661        let ssh = if let (true, Some(n)) = (running, cached) {
662            n
663        } else if running {
664            Sandbox::get(&self.oc(org), w.instance())
665                .and_then(|sb| {
666                    sb.exec_with(
667                        [
668                            "sh",
669                            "-c",
670                            "command -v ss >/dev/null 2>&1 || exit 3; ss -Htn state established '( sport = :22 )' | wc -l",
671                        ],
672                        crate::exec::ExecOptions::default().timeout(Duration::from_secs(10)),
673                    )
674                })
675                .ok()
676                .filter(|o| o.success())
677                .and_then(|o| String::from_utf8_lossy(&o.stdout).trim().parse().ok())
678        } else {
679            None
680        };
681        if running && cached.is_none() {
682            self.ssh
683                .lock()
684                .unwrap_or_else(|e| e.into_inner())
685                .insert(k, (std::time::Instant::now(), ssh));
686        }
687        Sessions {
688            terminals,
689            ssh,
690            total: terminals + ssh.unwrap_or(0),
691        }
692    }
693
694    // ---- upkeep ----
695
696    /// Bridges, credential delivery after restarts, and the reaper, on a
697    /// thread of their own.
698    pub fn start(self: &Arc<Self>, ctl: crate::stack::Controller) {
699        let me = self.clone();
700        self.setups_interrupted();
701        let _ = std::thread::Builder::new()
702            .name("isb-workspaces".into())
703            .spawn(move || {
704                let mut last_reap = std::time::Instant::now();
705                loop {
706                    me.upkeep();
707                    me.poll_secrets(&ctl);
708                    if last_reap.elapsed() >= REAP_EVERY {
709                        me.reap(&ctl);
710                        last_reap = std::time::Instant::now();
711                    }
712                    std::thread::sleep(UPKEEP_EVERY);
713                }
714            });
715    }
716
717    fn upkeep(self: &Arc<Self>) {
718        for org in self.store.orgs() {
719            self.ensure_bridge(&org);
720            for w in self.store.list(&org).unwrap_or_default() {
721                let oc = self.oc(&org);
722                let pid = oc
723                    .get_opt(&format!(
724                        "/1.0/instances/{}/state",
725                        encode_segment(w.instance())
726                    ))
727                    .ok()
728                    .flatten()
729                    .and_then(|s| s["pid"].as_i64())
730                    .unwrap_or(0);
731                if pid <= 0 {
732                    continue;
733                }
734                let k = key(&org.incus_project(), w.instance());
735                let done = self
736                    .delivered
737                    .lock()
738                    .unwrap_or_else(|e| e.into_inner())
739                    .get(&k)
740                    .is_some_and(|p| *p == pid);
741                if ws::setup_due(&w) {
742                    self.kick_setup(&org, &w.name);
743                }
744                if !done {
745                    match self.deliver(&org, &w) {
746                        Ok(()) => eprintln!(
747                            "isb serve: workspace {org}/{}: credentials delivered (pid {pid})",
748                            w.name
749                        ),
750                        Err(e) => eprintln!(
751                            "isb serve: workspace {org}/{}: credentials not delivered: {e}",
752                            w.name
753                        ),
754                    }
755                }
756            }
757        }
758    }
759
760    /// Delete sandboxes past their expiry or idle timeout. Idempotent: an
761    /// instance already gone is not an error.
762    fn reap(&self, ctl: &crate::stack::Controller) {
763        let snap = ctl.snapshot();
764        let t = now();
765        for i in snap.instances.values() {
766            if i.cpu_pct.is_some_and(|c| c >= ACTIVE_CPU_PCT) {
767                self.mark_active(&i.project, &i.name);
768            }
769        }
770        for i in snap.instances.values() {
771            let k = key(&i.project, &i.name);
772            if self.terminals(&i.project, &i.name) > 0 {
773                continue;
774            }
775            let Some(reason) = ws::reap_reason(&i.labels, t, self.last_active(&i.project, &i.name))
776            else {
777                continue;
778            };
779            let Some(org) = crate::org::OrgId::from_incus_project(&i.project) else {
780                continue;
781            };
782            let oc = self.oc(&org);
783            // Read it again: the sample may be stale, and only an instance
784            // that still has the deadline it was sampled with is taken.
785            let Ok(sb) = Sandbox::get(&oc, &i.name) else {
786                continue;
787            };
788            let Ok(info) = sb.info() else { continue };
789            let labels: BTreeMap<String, String> = info
790                .config
791                .iter()
792                .filter_map(|(k, v)| k.strip_prefix("user.").map(|k| (k.to_string(), v.clone())))
793                .collect();
794            if ws::reap_reason(&labels, t, self.last_active(&i.project, &i.name)).is_none() {
795                continue;
796            }
797            match Sandbox::remove(&oc, &i.name, true) {
798                Ok(()) => {
799                    eprintln!("isb serve: reaped sandbox {org}/{} ({reason})", i.name);
800                    self.record(
801                        &org,
802                        "sandbox.reaped",
803                        &i.name,
804                        "isb",
805                        format!("sandbox {} deleted: {reason}", i.name),
806                        json!({
807                            "reason": reason,
808                            "owner": labels.get("isb.owner"),
809                            "expires_at": labels.get("isb.expires_at"),
810                            "idle_timeout": labels.get("isb.idle_timeout"),
811                        }),
812                    );
813                }
814                Err(e) if e.is_not_found() => {}
815                Err(e) => eprintln!("isb serve: could not reap {org}/{}: {e}", i.name),
816            }
817            self.activity
818                .lock()
819                .unwrap_or_else(|e| e.into_inner())
820                .remove(&k);
821        }
822    }
823}
824
825/// A new workspace's home snapshot schedule, and how many are kept.
826const HOME_SNAPSHOTS: &str = "@hourly";
827const HOME_SNAPSHOTS_KEEP: u32 = 24;
828
829/// The root disk's size when none is given in an org with a disk quota.
830const DEFAULT_ROOT_SIZE: &str = "20GiB";
831
832/// A sandbox's root disk size when it gives none in an org with a disk
833/// quota.
834pub const SANDBOX_ROOT_SIZE: &str = crate::org::DEFAULT_ROOT_SIZE;
835
836/// A storage pool's driver (`zfs`, `btrfs`, `lvm`, `dir`, ...).
837pub fn pool_driver(client: &Client, pool: &str) -> Result<String> {
838    let p = client
839        .get_opt(&format!("/1.0/storage-pools/{}", encode_segment(pool)))?
840        .ok_or_else(|| Error::NotFound(format!("storage pool {pool}")))?;
841    Ok(p["driver"].as_str().unwrap_or("").to_string())
842}
843
844/// Whether snapshots on this driver share blocks with the volume (cheap),
845/// rather than copying it whole (`dir`).
846pub fn copy_on_write(driver: &str) -> bool {
847    matches!(driver, "zfs" | "btrfs" | "lvm" | "ceph")
848}
849
850/// Whether the org's project has a disk quota (`limits.disk`).
851pub fn project_has_disk_limit(client: &Client, org: &OrgId) -> bool {
852    client
853        .get_opt(&format!(
854            "/1.0/projects/{}",
855            encode_segment(&org.incus_project())
856        ))
857        .ok()
858        .flatten()
859        .is_some_and(|p| p["config"]["limits.disk"].as_str().is_some())
860}
861
862fn unhex(s: &str) -> Option<Vec<u8>> {
863    if s.len() % 2 != 0 {
864        return None;
865    }
866    (0..s.len())
867        .step_by(2)
868        .map(|i| u8::from_str_radix(&s[i..i + 2], 16).ok())
869        .collect()
870}
871
872// ---- the tools ----
873
874/// Who may do what to a workspace: admins and above create, change,
875/// rebuild and delete it; members start, stop and attach; viewers read.
876fn require(c: &Caller, org: &OrgId, min: Role, what: &str) -> Result<()> {
877    match c {
878        Caller::Local { .. } | Caller::Superadmin(_) => Ok(()),
879        Caller::User { principal: p } if p.platform_admin => Ok(()),
880        Caller::User { principal: p } => match p.role_in(org) {
881            Some(r) if r >= min => Ok(()),
882            Some(r) => Err(Error::Forbidden(format!(
883                "{what} is for the org's {}s and above; you are a {r} in {org}",
884                min.as_str()
885            ))),
886            None => Err(Error::Forbidden(format!("no access to org {org}"))),
887        },
888        _ => Err(Error::Forbidden(format!("{what} needs an isb account"))),
889    }
890}
891
892/// The name a call means: the one given, else the org's only workspace,
893/// else `workspace`.
894fn resolve_name(w: &Workspaces, org: &OrgId, given: Option<&str>) -> Result<String> {
895    if let Some(n) = given {
896        ws::check_name(n)?;
897        return Ok(n.to_string());
898    }
899    let all = w.store.list(org)?;
900    Ok(match all.as_slice() {
901        [one] => one.name.clone(),
902        _ => ws::DEFAULT_NAME.to_string(),
903    })
904}
905
906fn load(w: &Workspaces, org: &OrgId, name: &str) -> Result<Workspace> {
907    w.store
908        .get(org, name)?
909        .ok_or_else(|| Error::NotFound(format!("org {org} has no workspace {name}")))
910}
911
912/// Who created it, for `isb.owner` and the definition.
913fn creator(c: &Caller) -> String {
914    match c {
915        Caller::Local { .. } => "local".into(),
916        Caller::Superadmin(s) => s.label(),
917        Caller::User { principal } if principal.is_workspace() => {
918            crate::auth::WORKSPACE_ACTOR.into()
919        }
920        Caller::User { principal } => principal.user.email.clone(),
921        other => other.to_string(),
922    }
923}
924
925/// The confirmation a disruptive action needs, and what it says when it is
926/// missing.
927fn confirm(
928    d: &Daemon,
929    org: &OrgId,
930    w: &Workspace,
931    action: &str,
932    confirmed: bool,
933) -> Result<Sessions> {
934    let running = instance_status(d, org, w).is_some_and(|s| s.eq_ignore_ascii_case("running"));
935    let s = d.workspaces.sessions(org, w, running);
936    if !confirmed {
937        return Err(Error::invalid(format!(
938            "{action} {} in org {org} ends every session on it ({}). If that is intended, call again with confirm: true.",
939            w.name,
940            s.describe()
941        )));
942    }
943    Ok(s)
944}
945
946fn instance_status(d: &Daemon, org: &OrgId, w: &Workspace) -> Option<String> {
947    Sandbox::get(&d.workspaces.oc(org), w.instance())
948        .and_then(|s| s.info())
949        .ok()
950        .map(|i| i.status)
951}
952
953/// A workspace as the tools show it: its definition, the machine, its
954/// resources, sessions, token metadata and how to connect.
955#[expect(
956    clippy::too_many_lines,
957    reason = "predates the lint ratchet; split it when next changed"
958)]
959fn view(d: &Daemon, org: &OrgId, w: &Workspace, sessions: bool) -> Value {
960    let wsm = &d.workspaces;
961    let oc = wsm.oc(org);
962    let info = Sandbox::get(&oc, w.instance()).and_then(|s| s.info()).ok();
963    let running = info
964        .as_ref()
965        .is_some_and(|i| i.status.eq_ignore_ascii_case("running"));
966    let snap = d.ctl.snapshot();
967    let sample = snap
968        .instances
969        .get(&format!("{}/{}", org.incus_project(), w.instance()));
970    let project = org.incus_project();
971    let home = if w.home_bind.is_some() {
972        json!({"bind": w.home_bind, "path": w.home_dir(), "host_root": wsm.home_root})
973    } else {
974        let pool = wsm.home_pool_of(w, &oc).ok();
975        let driver = pool
976            .as_deref()
977            .and_then(|p| pool_driver(&wsm.client, p).ok());
978        let vol = pool.as_deref().and_then(|p| {
979            crate::volume::get(&oc, p, &w.home_volume(org))
980                .ok()
981                .flatten()
982        });
983        json!({
984            "volume": w.home_volume(org),
985            "pool": pool,
986            "driver": driver,
987            // Copy-on-write: a snapshot costs what changed, not a full copy.
988            "cow": driver.as_deref().map(copy_on_write),
989            "path": w.home_dir(),
990            "size": vol.as_ref().and_then(|v| v.config.get("size").cloned()).unwrap_or_else(|| w.home_size.clone()),
991            "exists": vol.is_some(),
992        })
993    };
994    let last_used = wsm
995        .last_used
996        .lock()
997        .unwrap_or_else(|e| e.into_inner())
998        .get(&format!("{org}/{}", w.name))
999        .copied();
1000    let url = wsm.url(org);
1001    let sandboxes = snap
1002        .instances
1003        .values()
1004        .filter(|i| i.project == project && ws::kind_of(&i.labels) == "sandbox")
1005        .count();
1006    let mut v = serde_json::to_value(w).unwrap_or_default();
1007    if let Some(o) = v.as_object_mut() {
1008        o.remove("token");
1009    }
1010    v["org"] = json!(org.as_str());
1011    v["home_dir"] = json!(w.home_dir());
1012    v["instance"] = match &info {
1013        Some(i) => json!({
1014            "name": i.name,
1015            "status": i.status,
1016            "type": i.instance_type,
1017            "created_at": i.created_at,
1018            "ip": sample.and_then(|s| s.ip.clone()),
1019        }),
1020        None => Value::Null,
1021    };
1022    v["status"] = json!(
1023        info.as_ref()
1024            .map(|i| i.status.clone())
1025            .unwrap_or_else(|| "Missing".into())
1026    );
1027    v["resources"] = json!({
1028        "cpu_pct": sample.and_then(|s| s.cpu_pct),
1029        "cpu_history": sample.map(|s| s.cpu_history.clone()).unwrap_or_default(),
1030        "mem_bytes": sample.and_then(|s| s.mem_bytes),
1031        "disk_bytes": sample.and_then(|s| s.disk_bytes),
1032        "cpus": info.as_ref().and_then(|i| i.config.get("limits.cpu").cloned()),
1033        "memory": info.as_ref().and_then(|i| i.config.get("limits.memory").cloned()),
1034    });
1035    v["home"] = home;
1036    v["sessions"] = if sessions {
1037        json!(wsm.sessions(org, w, running))
1038    } else {
1039        json!({"terminals": wsm.terminals(&project, w.instance())})
1040    };
1041    let last = wsm
1042        .activity
1043        .lock()
1044        .unwrap_or_else(|e| e.into_inner())
1045        .get(&key(&project, w.instance()))
1046        .copied();
1047    v["last_activity"] = json!(last.max(last_used));
1048    v["token"] = match &w.token {
1049        Some(t) => json!({
1050            "id": t.id,
1051            "role": w.token_role,
1052            "created_at": t.created_at,
1053            "last_used": last_used,
1054            "path": ws::TOKEN_PATH,
1055        }),
1056        None => Value::Null,
1057    };
1058    v["connect"] = json!({
1059        "url": url,
1060        "mcp_url": url.as_ref().map(|u| format!("{u}/orgs/{org}/mcp")),
1061        "org": org.as_str(),
1062        "user": w.user,
1063        "token_path": ws::TOKEN_PATH,
1064        "env": ["ISB_URL", "ISB_ORG", "ISB_TOKEN", "ISB_WORKSPACE"],
1065    });
1066    v["sandboxes"] = json!(sandboxes);
1067    let allowed = nesting::org_allows(&d.client, org);
1068    v["nesting"] = json!({
1069        "allowed": allowed,
1070        "active": info.as_ref().is_some_and(|i| nesting::nests(&i.config)),
1071        "warning": allowed.then_some(nesting::WARNING),
1072    });
1073    v
1074}
1075
1076#[derive(Deserialize)]
1077#[serde(deny_unknown_fields)]
1078struct NameArgs {
1079    #[serde(default)]
1080    #[allow(dead_code)]
1081    org: Option<String>,
1082    #[serde(default)]
1083    name: Option<String>,
1084    #[serde(default)]
1085    confirm: bool,
1086}
1087
1088#[derive(Deserialize)]
1089#[serde(deny_unknown_fields)]
1090struct UpdateArgs {
1091    #[serde(default)]
1092    #[allow(dead_code)]
1093    org: Option<String>,
1094    #[serde(default)]
1095    name: Option<String>,
1096    #[serde(default)]
1097    image: Option<String>,
1098    #[serde(default)]
1099    cpus: Option<u32>,
1100    #[serde(default)]
1101    memory: Option<String>,
1102    #[serde(default)]
1103    root_size: Option<String>,
1104    #[serde(default)]
1105    home_size: Option<String>,
1106    #[serde(default)]
1107    env: Option<BTreeMap<String, String>>,
1108    #[serde(default)]
1109    secrets: Option<Vec<String>>,
1110    #[serde(default)]
1111    labels: Option<BTreeMap<String, String>>,
1112    #[serde(default)]
1113    token_role: Option<Role>,
1114    /// The first-boot script (`""` removes it).
1115    #[serde(default)]
1116    setup: Option<String>,
1117    #[serde(default)]
1118    confirm: bool,
1119}
1120
1121#[expect(
1122    clippy::too_many_lines,
1123    reason = "predates the lint ratchet; split it when next changed"
1124)]
1125fn workspace_update(d: &Daemon, a: Value, c: &Caller) -> Result<Value> {
1126    let org = super::arg_org(&a)?;
1127    let a: UpdateArgs = args(a)?;
1128    require(c, &org, Role::Admin, "changing a workspace")?;
1129    let wsm = d.workspaces.clone();
1130    let name = resolve_name(&wsm, &org, a.name.as_deref())?;
1131    let _g = wsm.lock.lock().unwrap_or_else(|e| e.into_inner());
1132    let mut w = load(&wsm, &org, &name)?;
1133    let resize = a.cpus.is_some_and(|x| Some(x) != w.cpus)
1134        || a.memory
1135            .as_ref()
1136            .is_some_and(|x| Some(x) != w.memory.as_ref())
1137        || a.root_size
1138            .as_ref()
1139            .is_some_and(|x| Some(x) != w.root_size.as_ref())
1140        || a.home_size.as_ref().is_some_and(|x| *x != w.home_size);
1141    if resize {
1142        confirm(d, &org, &w, "Resizing", a.confirm)?;
1143    }
1144    if let Some(s) = &a.root_size {
1145        check_size("root_size", s)?;
1146    }
1147    if let Some(s) = &a.home_size {
1148        check_size("home_size", s)?;
1149    }
1150    check_fields(
1151        a.env.as_ref().unwrap_or(&BTreeMap::new()),
1152        a.secrets.as_deref().unwrap_or(&[]),
1153        a.labels.as_ref().unwrap_or(&BTreeMap::new()),
1154    )?;
1155    if let Some(ss) = &a.secrets {
1156        for s in ss {
1157            d.secrets
1158                .inspect(&org, s)
1159                .map_err(|e| Error::invalid(format!("secret {s}: {e}")))?;
1160        }
1161    }
1162    let oc = wsm.oc(&org);
1163    let path = format!("/1.0/instances/{}", encode_segment(w.instance()));
1164    let mut changed = Vec::new();
1165    let mut patch = serde_json::Map::new();
1166    if let Some(n) = a.cpus {
1167        patch.insert("limits.cpu".into(), json!(n.to_string()));
1168        w.cpus = Some(n);
1169        changed.push("cpus");
1170    }
1171    if let Some(m) = &a.memory {
1172        patch.insert("limits.memory".into(), json!(m));
1173        w.memory = Some(m.clone());
1174        changed.push("memory");
1175    }
1176    if let Some(l) = &a.labels {
1177        for k in w.labels.keys() {
1178            if !l.contains_key(k) {
1179                patch.insert(format!("user.{k}"), json!(""));
1180            }
1181        }
1182        for (k, v) in l {
1183            patch.insert(format!("user.{k}"), json!(v));
1184        }
1185        w.labels = l.clone();
1186        changed.push("labels");
1187    }
1188    if !patch.is_empty() {
1189        oc.mutate(
1190            "PATCH",
1191            &path,
1192            Some(&json!({"config": patch})),
1193            &format!("update workspace {name}"),
1194            oc.timeouts.other,
1195        )?;
1196    }
1197    if let Some(r) = &a.root_size {
1198        let inst = oc.get(&path)?;
1199        let mut root = inst["devices"]["root"].clone();
1200        if root.is_null() {
1201            root = json!({"type": "disk", "path": "/", "pool": wsm.pool(&oc)?});
1202        }
1203        root["size"] = json!(r);
1204        oc.mutate(
1205            "PATCH",
1206            &path,
1207            Some(&json!({"devices": {"root": root}})),
1208            &format!("resize workspace {name}'s root"),
1209            oc.timeouts.other,
1210        )?;
1211        w.root_size = Some(r.clone());
1212        changed.push("root_size");
1213    }
1214    if let Some(h) = &a.home_size {
1215        if w.home_bind.is_none() {
1216            let pool = wsm.home_pool_of(&w, &oc)?;
1217            oc.mutate(
1218                "PATCH",
1219                &format!(
1220                    "/1.0/storage-pools/{}/volumes/custom/{}",
1221                    encode_segment(&pool),
1222                    encode_segment(&w.home_volume(&org))
1223                ),
1224                Some(&json!({"config": {"size": h}})),
1225                &format!("resize workspace {name}'s home"),
1226                oc.timeouts.other,
1227            )?;
1228        }
1229        w.home_size = h.clone();
1230        changed.push("home_size");
1231    }
1232    if let Some(i) = &a.image {
1233        if i.trim().is_empty() {
1234            return Err(Error::invalid("image cannot be empty"));
1235        }
1236        w.image = i.trim().to_string();
1237        changed.push("image (takes effect on rebuild)");
1238    }
1239    let mut redeliver = false;
1240    if let Some(e) = a.env {
1241        w.env = e;
1242        redeliver = true;
1243        changed.push("env");
1244    }
1245    if let Some(s) = a.secrets {
1246        w.secrets = s;
1247        redeliver = true;
1248        changed.push("secrets");
1249    }
1250    if let Some(r) = a.token_role {
1251        w.token_role = token_role(Some(r))?;
1252        wsm.index(&org, &w);
1253        changed.push("token_role");
1254    }
1255    if a.setup.is_some() {
1256        w.setup = setup::check_setup(a.setup)?;
1257        if w.setup.is_none() {
1258            w.setup_state = None;
1259        }
1260        changed.push("setup (runs on the next rebuild, or with workspace_setup_run)");
1261    }
1262    w.updated_at = now();
1263    wsm.store.put(&org, &w)?;
1264    if redeliver {
1265        wsm.deliver(&org, &w)?;
1266    }
1267    wsm.record(
1268        &org,
1269        "workspace.updated",
1270        &name,
1271        &creator(c),
1272        format!("workspace {name} changed: {}", changed.join(", ")),
1273        json!({"changed": changed}),
1274    );
1275    let mut v = view(d, &org, &w, false);
1276    v["changed"] = json!(changed);
1277    Ok(v)
1278}
1279
1280fn power(d: &Daemon, a: Value, c: &Caller, action: &str) -> Result<Value> {
1281    let org = super::arg_org(&a)?;
1282    let a: NameArgs = args(a)?;
1283    require(c, &org, Role::Member, &format!("{action} a workspace"))?;
1284    let wsm = d.workspaces.clone();
1285    let name = resolve_name(&wsm, &org, a.name.as_deref())?;
1286    let w = load(&wsm, &org, &name)?;
1287    let sb = Sandbox::get(&wsm.oc(&org), w.instance())?;
1288    let ended = match action {
1289        "start" => {
1290            sb.start()?;
1291            None
1292        }
1293        "stop" => {
1294            let s = confirm(d, &org, &w, "Stopping", a.confirm)?;
1295            sb.stop(false, Duration::from_secs(30))
1296                .or_else(|_| sb.stop(true, Duration::from_secs(30)))?;
1297            Some(s)
1298        }
1299        _ => {
1300            let s = confirm(d, &org, &w, "Restarting", a.confirm)?;
1301            sb.restart()?;
1302            Some(s)
1303        }
1304    };
1305    if action != "stop" {
1306        // A fresh boot: /run is empty again.
1307        let _ = sb.wait_ready();
1308        wsm.deliver(&org, &w)?;
1309    }
1310    wsm.record(
1311        &org,
1312        &format!("workspace.{action}"),
1313        &name,
1314        &creator(c),
1315        format!("workspace {name}: {action} by {}", creator(c)),
1316        json!({"sessions_ended": ended}),
1317    );
1318    let mut v = view(d, &org, &w, false);
1319    if let Some(s) = ended {
1320        v["sessions_ended"] = json!(s);
1321    }
1322    Ok(v)
1323}
1324
1325#[derive(Deserialize)]
1326#[serde(deny_unknown_fields)]
1327struct RebuildArgs {
1328    #[serde(default)]
1329    #[allow(dead_code)]
1330    org: Option<String>,
1331    #[serde(default)]
1332    name: Option<String>,
1333    #[serde(default)]
1334    image: Option<String>,
1335    #[serde(default)]
1336    confirm: bool,
1337}
1338
1339fn workspace_rebuild(d: &Daemon, a: Value, c: &Caller) -> Result<Value> {
1340    let org = super::arg_org(&a)?;
1341    let a: RebuildArgs = args(a)?;
1342    require(c, &org, Role::Admin, "rebuilding a workspace")?;
1343    let wsm = d.workspaces.clone();
1344    let name = resolve_name(&wsm, &org, a.name.as_deref())?;
1345    let _g = wsm.lock.lock().unwrap_or_else(|e| e.into_inner());
1346    let mut w = load(&wsm, &org, &name)?;
1347    let ended = confirm(d, &org, &w, "Rebuilding", a.confirm)?;
1348    if let Some(i) = a.image.filter(|i| !i.trim().is_empty()) {
1349        w.image = i.trim().to_string();
1350    }
1351    let oc = wsm.oc(&org);
1352    match Sandbox::remove(&oc, w.instance(), true) {
1353        Ok(()) => {}
1354        Err(e) if e.is_not_found() => {}
1355        Err(e) => return Err(e),
1356    }
1357    let mut log = Vec::new();
1358    wsm.build(&org, &w, &mut log)?;
1359    let t = now();
1360    w.rebuilt_at = Some(t);
1361    w.updated_at = t;
1362    w.setup_state = ws::setup_next(
1363        w.setup.is_some(),
1364        w.setup_state.as_ref(),
1365        ws::SetupEvent::Built,
1366        t,
1367    )?;
1368    wsm.store.put(&org, &w)?;
1369    drop(_g);
1370    wsm.kick_setup(&org, &name);
1371    wsm.record(
1372        &org,
1373        "workspace.rebuilt",
1374        &name,
1375        &creator(c),
1376        format!("workspace {name} rebuilt from {}; home kept", w.image),
1377        json!({"image": w.image, "sessions_ended": ended}),
1378    );
1379    let mut v = view(d, &org, &w, false);
1380    v["log"] = json!(log);
1381    v["sessions_ended"] = json!(ended);
1382    Ok(v)
1383}
1384
1385#[derive(Deserialize)]
1386#[serde(deny_unknown_fields)]
1387struct DeleteArgs {
1388    #[serde(default)]
1389    #[allow(dead_code)]
1390    org: Option<String>,
1391    #[serde(default)]
1392    name: Option<String>,
1393    #[serde(default)]
1394    keep_home: bool,
1395    #[serde(default)]
1396    confirm: bool,
1397}
1398
1399fn workspace_delete(d: &Daemon, a: Value, c: &Caller) -> Result<Value> {
1400    let org = super::arg_org(&a)?;
1401    let a: DeleteArgs = args(a)?;
1402    require(c, &org, Role::Admin, "deleting a workspace")?;
1403    let wsm = d.workspaces.clone();
1404    let name = resolve_name(&wsm, &org, a.name.as_deref())?;
1405    let _g = wsm.lock.lock().unwrap_or_else(|e| e.into_inner());
1406    let w = load(&wsm, &org, &name)?;
1407    let what = if a.keep_home || w.home_bind.is_some() {
1408        "Deleting (keeping its home)"
1409    } else {
1410        "Deleting, home and all,"
1411    };
1412    let ended = confirm(d, &org, &w, what, a.confirm)?;
1413    let oc = wsm.oc(&org);
1414    match Sandbox::remove(&oc, w.instance(), true) {
1415        Ok(()) => {}
1416        Err(e) if e.is_not_found() => {}
1417        Err(e) => return Err(e),
1418    }
1419    // The token stops working before anything else can fail.
1420    wsm.index(
1421        &org,
1422        &Workspace {
1423            token: None,
1424            ..w.clone()
1425        },
1426    );
1427    let mut home_deleted = false;
1428    if !a.keep_home && w.home_bind.is_none() {
1429        let pool = wsm.home_pool_of(&w, &oc)?;
1430        match crate::volume::remove(&oc, &pool, &w.home_volume(&org)) {
1431            Ok(()) => home_deleted = true,
1432            Err(e) if e.is_not_found() => {}
1433            Err(e) => return Err(e),
1434        }
1435    }
1436    wsm.store.delete(&org, &name)?;
1437    wsm.ensure_bridge(&org);
1438    wsm.record(
1439        &org,
1440        "workspace.deleted",
1441        &name,
1442        &creator(c),
1443        format!(
1444            "workspace {name} deleted; its token revoked{}",
1445            if home_deleted {
1446                ", its home deleted"
1447            } else {
1448                ", its home kept"
1449            }
1450        ),
1451        json!({"home_deleted": home_deleted, "sessions_ended": ended}),
1452    );
1453    Ok(json!({
1454        "ok": true,
1455        "name": name,
1456        "token_revoked": true,
1457        "home_deleted": home_deleted,
1458        "home_volume": (!home_deleted && w.home_bind.is_none()).then(|| w.home_volume(&org)),
1459        "sessions_ended": ended,
1460    }))
1461}
1462
1463fn workspace_token_rotate(d: &Daemon, a: Value, c: &Caller) -> Result<Value> {
1464    let org = super::arg_org(&a)?;
1465    let a: NameArgs = args(a)?;
1466    require(c, &org, Role::Admin, "rotating a workspace's token")?;
1467    let wsm = d.workspaces.clone();
1468    let name = resolve_name(&wsm, &org, a.name.as_deref())?;
1469    let _g = wsm.lock.lock().unwrap_or_else(|e| e.into_inner());
1470    let mut w = load(&wsm, &org, &name)?;
1471    wsm.mint(&org, &mut w)?;
1472    w.updated_at = now();
1473    wsm.store.put(&org, &w)?;
1474    let delivered = match wsm.deliver(&org, &w) {
1475        Ok(()) => true,
1476        Err(e) => {
1477            eprintln!("isb serve: workspace {org}/{name}: new token not delivered yet: {e}");
1478            false
1479        }
1480    };
1481    let id = w.token.as_ref().map(|t| t.id.clone());
1482    wsm.record(
1483        &org,
1484        "workspace.token_rotated",
1485        &name,
1486        &creator(c),
1487        format!("workspace {name}: token rotated; the old one no longer works"),
1488        json!({"token_id": id}),
1489    );
1490    Ok(json!({
1491        "ok": true,
1492        "name": name,
1493        "token": {"id": id, "role": w.token_role, "created_at": w.token.as_ref().map(|t| t.created_at)},
1494        "delivered": delivered,
1495        "message": format!(
1496            "The old token no longer works. The new one is at {} inside the workspace (new login shells read it into $ISB_TOKEN); it is never shown here.",
1497            ws::TOKEN_PATH
1498        ),
1499    }))
1500}
1501
1502#[expect(
1503    clippy::too_many_lines,
1504    reason = "predates the lint ratchet; split it when next changed"
1505)]
1506pub(super) fn register(r: &mut Registry, d: Arc<Daemon>) -> Result<()> {
1507    let ro = json!({"readOnlyHint": true, "openWorldHint": false});
1508    let destructive = json!({"destructiveHint": true, "openWorldHint": false});
1509    let write = json!({"destructiveHint": false, "openWorldHint": false});
1510
1511    macro_rules! tool {
1512        ($name:expr, $title:expr, $desc:expr, $schema:expr, $ann:expr, $f:expr) => {{
1513            let d = d.clone();
1514            let f = $f;
1515            r.register(
1516                Tool::new($name, $desc, $schema, move |a, c| {
1517                    // An org that does not exist is refused before anything.
1518                    crate::org::check_exists(&d.client, &super::arg_org(&a)?)?;
1519                    f(&d, a, c)
1520                })
1521                .title($title)
1522                .annotations($ann.clone()),
1523            )?;
1524        }};
1525    }
1526    let name =
1527        || json!({"type": "string", "description": "The workspace (default: the org's only one)."});
1528    let confirm_p = || json!({"type": "boolean", "description": "Required: this ends live sessions on the workspace. Without it the call only says what would end."});
1529
1530    tool!(
1531        "workspace_get",
1532        "Get the workspace",
1533        "The org's workspace (its long-lived machine, docs/concepts/workspaces.md): image, size, home volume, status, CPU and memory, live sessions (web terminals, SSH), last activity, its token's metadata (never the token) and how to connect; `workspace` is null when the org has none yet, and `create` then says what one can be made from (`images`, `default_image`) and the org's quota and usage (`quota`). Also the org's workspace settings.",
1534        obj(json!({"name": name()}), &[]),
1535        ro,
1536        |d: &Daemon, a: Value, _c: &Caller| -> Result<Value> {
1537            let org = super::arg_org(&a)?;
1538            #[derive(Deserialize)]
1539            #[serde(deny_unknown_fields)]
1540            struct A {
1541                #[serde(default)]
1542                #[allow(dead_code)]
1543                org: Option<String>,
1544                #[serde(default)]
1545                name: Option<String>,
1546            }
1547            let a: A = args(a)?;
1548            let wsm = &d.workspaces;
1549            let name = resolve_name(wsm, &org, a.name.as_deref())?;
1550            let w = wsm.store.get(&org, &name)?;
1551            let create = w.is_none().then(|| images::create_options(wsm, &org));
1552            Ok(json!({
1553                "org": org.as_str(),
1554                "settings": wsm.store.settings(&org)?,
1555                "workspace": w.map(|w| view(d, &org, &w, true)),
1556                "create": create,
1557            }))
1558        }
1559    );
1560    tool!(
1561        "workspace_list",
1562        "List workspaces",
1563        "The org's workspaces (one per org unless a platform admin raised max_workspaces), as workspace_get shows each, without the session count.",
1564        obj(json!({}), &[]),
1565        ro,
1566        |d: &Daemon, a: Value, _c: &Caller| -> Result<Value> {
1567            let org = super::arg_org(&a)?;
1568            let wsm = &d.workspaces;
1569            let all: Vec<Value> = wsm
1570                .store
1571                .list(&org)?
1572                .iter()
1573                .map(|w| view(d, &org, w, false))
1574                .collect();
1575            Ok(
1576                json!({"org": org.as_str(), "settings": wsm.store.settings(&org)?, "workspaces": all}),
1577            )
1578        }
1579    );
1580    tool!(
1581        "workspace_create",
1582        "Create the workspace",
1583        "Create the org's workspace: an unprivileged container from `image` with a managed home volume (`home_size`, counted against the org's disk quota) at the workspace user's home, and an org token (role `token_role`, default admin) delivered inside as /run/isb/token and $ISB_TOKEN, with $ISB_URL and $ISB_ORG, so the isb CLI and MCP clients inside work with no setup. One per org. Org admins and above.",
1584        obj(
1585            json!({
1586                "name": {"type": "string", "description": "Default: workspace."},
1587                "image": {"type": "string", "description": "An incus image (a local alias such as dev-base, or a remote one such as images:ubuntu/24.04) or registry:APP:TAG. Default: dev-base when this host has it, else images:ubuntu/24.04; workspace_get lists the choices."},
1588                "user": {"type": "string", "description": "The workspace user (default dev); created when the image lacks it."},
1589                "cpus": {"type": "integer", "minimum": 1},
1590                "memory": {"type": "string", "description": "e.g. 8GiB."},
1591                "root_size": {"type": "string", "description": "The root disk, e.g. 30GiB (default: the pool's)."},
1592                "home_size": {"type": "string", "description": "The home volume (default 20GiB)."},
1593                "env": {"type": "object", "additionalProperties": {"type": "string"}, "description": "Plain variables for login shells."},
1594                "secrets": {"type": "array", "items": {"type": "string"}, "description": "Org secrets delivered as /run/isb/secrets/NAME."},
1595                "labels": {"type": "object", "additionalProperties": {"type": "string"}},
1596                "token_role": {"type": "string", "enum": ["viewer", "member", "admin"], "description": "The workspace token's role in the org (default admin)."},
1597                "home_bind": {"type": "string", "description": "Superadmins only: this host directory as the home (an existing box's, when migrating), instead of the default home."},
1598                "setup": {"type": "string", "description": "A first-boot script, run once as root on the first start (and after each rebuild, or on request with workspace_setup_run), logged to the history. Not for secrets."}
1599            }),
1600            &[]
1601        ),
1602        write,
1603        workspace_create
1604    );
1605    tool!(
1606        "workspace_update",
1607        "Change the workspace",
1608        "Change the workspace: cpus, memory, root_size and home_size apply at once (resizing needs confirm: true, since it can end sessions); env and secrets are delivered again (new login shells see them); image applies on the next rebuild; labels; token_role. Fields left out are kept. Org admins and above.",
1609        obj(
1610            json!({
1611                "name": name(),
1612                "image": {"type": "string"},
1613                "cpus": {"type": "integer", "minimum": 1},
1614                "memory": {"type": "string"},
1615                "root_size": {"type": "string"},
1616                "home_size": {"type": "string", "description": "Grow the home volume."},
1617                "env": {"type": "object", "additionalProperties": {"type": "string"}, "description": "Replaces the variables."},
1618                "secrets": {"type": "array", "items": {"type": "string"}, "description": "Replaces the delivered secrets."},
1619                "labels": {"type": "object", "additionalProperties": {"type": "string"}, "description": "Replaces the labels."},
1620                "token_role": {"type": "string", "enum": ["viewer", "member", "admin"]},
1621                "setup": {"type": "string", "description": "Replaces the first-boot script (\"\" removes it); it runs on the next rebuild or with workspace_setup_run."},
1622                "confirm": confirm_p()
1623            }),
1624            &[]
1625        ),
1626        write,
1627        workspace_update
1628    );
1629    tool!(
1630        "workspace_start",
1631        "Start the workspace",
1632        "Start the workspace and deliver its credentials. Org members and above.",
1633        obj(json!({"name": name()}), &[]),
1634        write,
1635        |d: &Daemon, a: Value, c: &Caller| power(d, a, c, "start")
1636    );
1637    tool!(
1638        "workspace_stop",
1639        "Stop the workspace",
1640        "Stop the workspace. This ends every session on it (terminals, SSH, the agents running there): without confirm: true it only reports the live sessions. Org members and above.",
1641        obj(json!({"name": name(), "confirm": confirm_p()}), &[]),
1642        write,
1643        |d: &Daemon, a: Value, c: &Caller| power(d, a, c, "stop")
1644    );
1645    tool!(
1646        "workspace_restart",
1647        "Restart the workspace",
1648        "Restart the workspace. This ends every session on it: without confirm: true it only reports the live sessions. Org members and above.",
1649        obj(json!({"name": name(), "confirm": confirm_p()}), &[]),
1650        write,
1651        |d: &Daemon, a: Value, c: &Caller| power(d, a, c, "restart")
1652    );
1653    tool!(
1654        "workspace_rebuild",
1655        "Rebuild the workspace",
1656        "Replace the workspace's machine with a fresh one from its image (or `image`), keeping its home volume and token: what to do when the root is damaged. Ends every session; needs confirm: true. Software installed outside the home is gone. Org admins and above.",
1657        obj(
1658            json!({"name": name(), "image": {"type": "string", "description": "Rebuild from this image instead (it becomes the workspace's)."}, "confirm": confirm_p()}),
1659            &[]
1660        ),
1661        destructive,
1662        workspace_rebuild
1663    );
1664    tool!(
1665        "workspace_delete",
1666        "Delete the workspace",
1667        "Delete the workspace: its machine, its token (revoked at once) and, unless keep_home, its home volume. Needs confirm: true. Org admins and above.",
1668        obj(
1669            json!({"name": name(), "keep_home": {"type": "boolean", "description": "Keep the home volume (it can be attached to a new workspace of the same name)."}, "confirm": confirm_p()}),
1670            &[]
1671        ),
1672        destructive,
1673        workspace_delete
1674    );
1675    tool!(
1676        "workspace_token_rotate",
1677        "Rotate the workspace's token",
1678        "Mint the workspace a new token and deliver it inside; the old one stops working at once. The token is never returned. Org admins and above.",
1679        obj(json!({"name": name()}), &[]),
1680        write,
1681        workspace_token_rotate
1682    );
1683    tool!(
1684        "workspace_settings",
1685        "Workspace settings",
1686        "The org's workspace settings: max_workspaces (1; platform admins can raise it), and the defaults for new sandboxes, sandbox_expiry (24h, at most 30d) and sandbox_idle (2h, or none); and secret_refresh (1h, at least 10s), how often the workspaces' secrets that are driver references (vault/item/field) are checked for a new version, which is written into running workspaces without a restart. Without changes it reads them; org admins change the sandbox defaults and secret_refresh.",
1687        obj(
1688            json!({
1689                "max_workspaces": {"type": "integer", "minimum": 1, "maximum": 100},
1690                "sandbox_expiry": {"type": "string", "description": "e.g. 24h, 7d."},
1691                "sandbox_idle": {"type": "string", "description": "e.g. 2h, or none."},
1692                "secret_refresh": {"type": "string", "description": "e.g. 1h, 5m; at least 10s."},
1693                "home_kind": {"type": "string", "enum": ["", "volume", "host"], "description": "Platform admins: where new workspace homes go: a managed volume, or a host folder under isb serve's --workspace-home-root (\"\": the daemon's default)."},
1694                "home_pool": {"type": "string", "description": "Platform admins: the storage pool new workspace homes go in (\"\" clears it: the daemon's --workspace-pool, else the org's default pool)."}
1695            }),
1696            &[]
1697        ),
1698        write,
1699        settings::workspace_settings
1700    );
1701    tool!(
1702        "workspace_setup_run",
1703        "Run the workspace's setup script",
1704        "Run the workspace's first-boot setup script again, as root: now when the workspace is running, else on its next start. Its outcome and the tail of its output go to the history (workspace.setup). Org admins and above.",
1705        obj(json!({"name": name()}), &[]),
1706        write,
1707        setup::workspace_setup_run
1708    );
1709    tool!(
1710        "workspace_terminals",
1711        "The workspace's terminal sessions",
1712        "How the workspace's web terminal works and its live sessions: mode `herdr` when herdr is installed in the workspace (each tab is a herdr tab in the `isb web` workspace of the user's herdr server, so a reload or a dropped connection reattaches to the same shell; `sessions` lists them), else `shell` (plain shells that end with their tab). Org members and above.",
1713        obj(json!({"name": name()}), &[]),
1714        ro,
1715        herdr::workspace_terminals
1716    );
1717    tool!(
1718        "workspace_terminal_update",
1719        "Rename or end a terminal session",
1720        "A herdr-backed web terminal session: `rename` it (the herdr tab's label), or `end: true` to close it and every shell in it. Org members and above.",
1721        obj(
1722            json!({
1723                "name": name(),
1724                "session": {"type": "string", "description": "The session (its tab's name)."},
1725                "rename": {"type": "string", "description": "Its new name."},
1726                "end": {"type": "boolean", "description": "Close the session and its shells."}
1727            }),
1728            &["session"]
1729        ),
1730        write,
1731        herdr::workspace_terminal_update
1732    );
1733    image_tools::register(r, d.clone())?;
1734    nesting::register(r, d.clone())?;
1735    ports::register(r, d)?;
1736    Ok(())
1737}
1738
1739#[cfg(test)]
1740mod tests {
1741    use super::*;
1742
1743    #[test]
1744    fn the_instance_spec_has_the_home_the_size_and_the_labels() {
1745        let org = OrgId::new("acme").unwrap();
1746        let mut w: Workspace = serde_json::from_value(json!({
1747            "name": "workspace", "id": "x", "image": "dev-base", "user": "dev",
1748            "home_size": "5GiB", "token_role": "admin", "created_at": 0, "created_by": "a@x.io",
1749            "cpus": 2, "memory": "2GiB", "root_size": "30GiB", "labels": {"team": "ops"}
1750        }))
1751        .unwrap();
1752        let s = Workspaces::spec(&org, &w, "default").unwrap();
1753        assert_eq!(s.name.as_deref(), Some("workspace"));
1754        assert_eq!(s.user.as_deref(), Some("dev"));
1755        assert_eq!(s.working_dir.as_deref(), Some("/home/dev"));
1756        assert_eq!(s.cpus.as_deref(), Some("2"));
1757        assert!(s.memory.is_some());
1758        assert_eq!(s.labels["isb.workspace"], "workspace");
1759        assert_eq!(s.labels["isb.owner"], "a@x.io");
1760        assert_eq!(s.labels["team"], "ops");
1761        assert_eq!(s.raw_devices["root"]["size"], "30GiB");
1762        assert_eq!(s.raw_config["boot.autostart"], "true");
1763        assert_eq!(s.volumes.len(), 1);
1764        assert_eq!(s.volumes[0].source, "acme_workspace_home");
1765        assert_eq!(s.volumes[0].target, "/home/dev");
1766        // A migration bind instead of the volume.
1767        w.home_bind = Some("/srv/workspaces/acme/home".into());
1768        let s = Workspaces::spec(&org, &w, "default").unwrap();
1769        assert_eq!(s.volumes[0].source, "/srv/workspaces/acme/home");
1770    }
1771
1772    /// A workspace manager over `state`, its incus a socket that is not
1773    /// there.
1774    pub(super) fn manager(state: &Path, home_root: Option<PathBuf>) -> Workspaces {
1775        let keyring = Arc::new(crate::secrets::Keyring::new(
1776            age::x25519::Identity::generate(),
1777            vec![],
1778        ));
1779        Workspaces {
1780            store: Store::new(state),
1781            client: Client::with_socket(state.join("no-incus.sock")),
1782            keyring: keyring.clone(),
1783            secrets: Arc::new(crate::secrets::Secrets::new(
1784                crate::secrets::LocalDriver::new(state, keyring),
1785            )),
1786            recorder: crate::history::Recorder::start(Arc::new(
1787                crate::audit::AuditLog::open(&state.join("a.db"), Duration::from_secs(60)).unwrap(),
1788            )),
1789            port: DEFAULT_PORT,
1790            home_pool: None,
1791            home_root,
1792            tokens: Mutex::new(HashMap::new()),
1793            last_used: Mutex::new(HashMap::new()),
1794            sessions: Arc::new(Mutex::new(HashMap::new())),
1795            activity: Mutex::new(HashMap::new()),
1796            delivered: Mutex::new(HashMap::new()),
1797            secret_poll: Mutex::default(),
1798            bridges: Mutex::new(HashMap::new()),
1799            ssh: Mutex::new(HashMap::new()),
1800            serve: OnceLock::new(),
1801            lock: Mutex::new(()),
1802            started: 0,
1803            previews: Previews::default(),
1804        }
1805    }
1806
1807    pub(super) fn a_workspace() -> Workspace {
1808        serde_json::from_value(json!({
1809            "name": "workspace", "id": "x", "image": "dev-base", "user": "dev",
1810            "home_size": "1GiB", "token_role": "admin", "created_at": 0, "created_by": "a"
1811        }))
1812        .unwrap()
1813    }
1814
1815    #[test]
1816    fn workspace_tokens_authenticate_as_the_org_workspace_and_rotate() {
1817        let d = tempfile::tempdir().unwrap();
1818        let org = OrgId::new("acme").unwrap();
1819        let m = manager(d.path(), None);
1820        let store = m.store.clone();
1821        let mut w = a_workspace();
1822        m.mint(&org, &mut w).unwrap();
1823        store.put(&org, &w).unwrap();
1824        let t1 = String::from_utf8(m.token_plain(&org, "workspace").unwrap().unwrap()).unwrap();
1825        assert!(t1.starts_with("isb_ws_"));
1826        // The definition keeps a hash, never the token.
1827        let on_disk =
1828            std::fs::read_to_string(d.path().join("orgs/acme/workspaces/workspace.json")).unwrap();
1829        assert!(!on_disk.contains(&t1));
1830        let p = m.authenticate(&t1).unwrap();
1831        assert_eq!(p.role_in(&org), Some(Role::Admin));
1832        assert!(p.is_workspace() && !p.platform_admin);
1833        assert_eq!(p.orgs.len(), 1);
1834        assert!(m.authenticate("isb_ws_nope").is_none());
1835        // Rotating: the old token is refused at once.
1836        m.mint(&org, &mut w).unwrap();
1837        let t2 = String::from_utf8(m.token_plain(&org, "workspace").unwrap().unwrap()).unwrap();
1838        assert_ne!(t1, t2);
1839        assert!(m.authenticate(&t1).is_none());
1840        assert!(m.authenticate(&t2).is_some());
1841        // A daemon starting over the same state knows it.
1842        store.put(&org, &w).unwrap();
1843        m.tokens.lock().unwrap().clear();
1844        m.load_tokens();
1845        assert!(m.authenticate(&t2).is_some());
1846        // Deleting revokes.
1847        m.index(&org, &Workspace { token: None, ..w });
1848        assert!(m.authenticate(&t2).is_none());
1849        // Sessions count while their guard lives.
1850        let g = m.session("isb-acme", "workspace");
1851        assert_eq!(m.terminals("isb-acme", "workspace"), 1);
1852        drop(g);
1853        assert_eq!(m.terminals("isb-acme", "workspace"), 0);
1854    }
1855}