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