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 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
63pub const DEFAULT_PORT: u16 = 8481;
65
66const UPKEEP_EVERY: Duration = Duration::from_secs(15);
68const REAP_EVERY: Duration = Duration::from_secs(60);
69const ACTIVE_CPU_PCT: f32 = 2.0;
71const 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
100struct Bridge {
102 addr: SocketAddr,
103 stop: Shutdown,
104}
105
106pub 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#[derive(Debug, Clone, Default, serde::Serialize)]
127pub struct Sessions {
128 pub terminals: usize,
130 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
153pub 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 home_pool: Option<String>,
163 home_root: Option<PathBuf>,
165 tokens: Mutex<HashMap<Vec<u8>, TokenRef>>,
166 last_used: Mutex<HashMap<String, u64>>,
168 sessions: Arc<Mutex<HashMap<String, usize>>>,
169 activity: Mutex<HashMap<String, u64>>,
172 delivered: Mutex<HashMap<String, i64>>,
174 secret_poll: Mutex<secrets::Poll>,
175 bridges: Mutex<HashMap<OrgId, Bridge>>,
176 ssh: Mutex<HashMap<String, (std::time::Instant, Option<usize>)>>,
179 serve: OnceLock<(Listener, Arc<Registry>, Healthz)>,
180 lock: Mutex<()>,
182 started: u64,
183 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 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 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 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 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 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 seen.max(self.started)
352 }
353
354 pub fn settings(&self, org: &OrgId) -> Result<Settings> {
356 self.store.settings(org)
357 }
358
359 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 pub fn set_serving(&self, l: Listener, registry: Arc<Registry>, healthz: Healthz) {
372 let _ = self.serve.set((l, registry, healthz));
373 }
374
375 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 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 fn oc(&self, org: &OrgId) -> Client {
455 crate::org::client(&self.client, org)
456 }
457
458 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 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 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 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 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 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 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 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 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 &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 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 pub fn start(self: &Arc<Self>, ctl: crate::stack::Controller, local: LocalOrg) {
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(&local);
707 me.poll_secrets(&ctl, &local);
708 if last_reap.elapsed() >= REAP_EVERY {
709 me.reap(&ctl, &local);
710 last_reap = std::time::Instant::now();
711 }
712 std::thread::sleep(UPKEEP_EVERY);
713 }
714 });
715 }
716
717 fn upkeep(self: &Arc<Self>, local: &LocalOrg) {
718 for org in self.store.orgs() {
719 if !local(&org) {
720 continue;
721 }
722 self.ensure_bridge(&org);
723 for w in self.store.list(&org).unwrap_or_default() {
724 let oc = self.oc(&org);
725 let pid = oc
726 .get_opt(&format!(
727 "/1.0/instances/{}/state",
728 encode_segment(w.instance())
729 ))
730 .ok()
731 .flatten()
732 .and_then(|s| s["pid"].as_i64())
733 .unwrap_or(0);
734 if pid <= 0 {
735 continue;
736 }
737 let k = key(&org.incus_project(), w.instance());
738 let done = self
739 .delivered
740 .lock()
741 .unwrap_or_else(|e| e.into_inner())
742 .get(&k)
743 .is_some_and(|p| *p == pid);
744 if ws::setup_due(&w) {
745 self.kick_setup(&org, &w.name);
746 }
747 if !done {
748 match self.deliver(&org, &w) {
749 Ok(()) => eprintln!(
750 "isb serve: workspace {org}/{}: credentials delivered (pid {pid})",
751 w.name
752 ),
753 Err(e) => eprintln!(
754 "isb serve: workspace {org}/{}: credentials not delivered: {e}",
755 w.name
756 ),
757 }
758 }
759 }
760 }
761 }
762
763 fn reap(&self, ctl: &crate::stack::Controller, local: &LocalOrg) {
766 let snap = ctl.snapshot();
767 let t = now();
768 for i in snap.instances.values() {
769 if i.cpu_pct.is_some_and(|c| c >= ACTIVE_CPU_PCT) {
770 self.mark_active(&i.project, &i.name);
771 }
772 }
773 for i in snap.instances.values() {
774 let k = key(&i.project, &i.name);
775 if self.terminals(&i.project, &i.name) > 0 {
776 continue;
777 }
778 let Some(reason) = ws::reap_reason(&i.labels, t, self.last_active(&i.project, &i.name))
779 else {
780 continue;
781 };
782 let Some(org) = crate::org::OrgId::from_incus_project(&i.project) else {
783 continue;
784 };
785 if !local(&org) {
786 continue;
787 }
788 let oc = self.oc(&org);
789 let Ok(sb) = Sandbox::get(&oc, &i.name) else {
792 continue;
793 };
794 let Ok(info) = sb.info() else { continue };
795 let labels: BTreeMap<String, String> = info
796 .config
797 .iter()
798 .filter_map(|(k, v)| k.strip_prefix("user.").map(|k| (k.to_string(), v.clone())))
799 .collect();
800 if ws::reap_reason(&labels, t, self.last_active(&i.project, &i.name)).is_none() {
801 continue;
802 }
803 match Sandbox::remove(&oc, &i.name, true) {
804 Ok(()) => {
805 eprintln!("isb serve: reaped sandbox {org}/{} ({reason})", i.name);
806 self.record(
807 &org,
808 "sandbox.reaped",
809 &i.name,
810 "isb",
811 format!("sandbox {} deleted: {reason}", i.name),
812 json!({
813 "reason": reason,
814 "owner": labels.get("isb.owner"),
815 "expires_at": labels.get("isb.expires_at"),
816 "idle_timeout": labels.get("isb.idle_timeout"),
817 }),
818 );
819 }
820 Err(e) if e.is_not_found() => {}
821 Err(e) => eprintln!("isb serve: could not reap {org}/{}: {e}", i.name),
822 }
823 self.activity
824 .lock()
825 .unwrap_or_else(|e| e.into_inner())
826 .remove(&k);
827 }
828 }
829}
830
831pub type LocalOrg = Arc<dyn Fn(&OrgId) -> bool + Send + Sync>;
833
834const HOME_SNAPSHOTS: &str = "@hourly";
836const HOME_SNAPSHOTS_KEEP: u32 = 24;
837
838const DEFAULT_ROOT_SIZE: &str = "20GiB";
840
841pub const SANDBOX_ROOT_SIZE: &str = "10GiB";
844
845pub fn pool_driver(client: &Client, pool: &str) -> Result<String> {
847 let p = client
848 .get_opt(&format!("/1.0/storage-pools/{}", encode_segment(pool)))?
849 .ok_or_else(|| Error::NotFound(format!("storage pool {pool}")))?;
850 Ok(p["driver"].as_str().unwrap_or("").to_string())
851}
852
853pub fn copy_on_write(driver: &str) -> bool {
856 matches!(driver, "zfs" | "btrfs" | "lvm" | "ceph")
857}
858
859pub fn project_has_disk_limit(client: &Client, org: &OrgId) -> bool {
861 client
862 .get_opt(&format!(
863 "/1.0/projects/{}",
864 encode_segment(&org.incus_project())
865 ))
866 .ok()
867 .flatten()
868 .is_some_and(|p| p["config"]["limits.disk"].as_str().is_some())
869}
870
871fn unhex(s: &str) -> Option<Vec<u8>> {
872 if s.len() % 2 != 0 {
873 return None;
874 }
875 (0..s.len())
876 .step_by(2)
877 .map(|i| u8::from_str_radix(&s[i..i + 2], 16).ok())
878 .collect()
879}
880
881fn require(c: &Caller, org: &OrgId, min: Role, what: &str) -> Result<()> {
886 match c {
887 Caller::Local { .. } | Caller::Superadmin(_) => Ok(()),
888 Caller::User { principal: p } if p.platform_admin => Ok(()),
889 Caller::User { principal: p } => match p.role_in(org) {
890 Some(r) if r >= min => Ok(()),
891 Some(r) => Err(Error::Forbidden(format!(
892 "{what} is for the org's {}s and above; you are a {r} in {org}",
893 min.as_str()
894 ))),
895 None => Err(Error::Forbidden(format!("no access to org {org}"))),
896 },
897 _ => Err(Error::Forbidden(format!("{what} needs an isb account"))),
898 }
899}
900
901fn resolve_name(w: &Workspaces, org: &OrgId, given: Option<&str>) -> Result<String> {
904 if let Some(n) = given {
905 ws::check_name(n)?;
906 return Ok(n.to_string());
907 }
908 let all = w.store.list(org)?;
909 Ok(match all.as_slice() {
910 [one] => one.name.clone(),
911 _ => ws::DEFAULT_NAME.to_string(),
912 })
913}
914
915fn load(w: &Workspaces, org: &OrgId, name: &str) -> Result<Workspace> {
916 w.store
917 .get(org, name)?
918 .ok_or_else(|| Error::NotFound(format!("org {org} has no workspace {name}")))
919}
920
921fn creator(c: &Caller) -> String {
923 match c {
924 Caller::Local { .. } => "local".into(),
925 Caller::Superadmin(s) => s.label(),
926 Caller::User { principal } if principal.is_workspace() => {
927 crate::auth::WORKSPACE_ACTOR.into()
928 }
929 Caller::User { principal } => principal.user.email.clone(),
930 other => other.to_string(),
931 }
932}
933
934fn confirm(
937 d: &Daemon,
938 org: &OrgId,
939 w: &Workspace,
940 action: &str,
941 confirmed: bool,
942) -> Result<Sessions> {
943 let running = instance_status(d, org, w).is_some_and(|s| s.eq_ignore_ascii_case("running"));
944 let s = d.workspaces.sessions(org, w, running);
945 if !confirmed {
946 return Err(Error::invalid(format!(
947 "{action} {} in org {org} ends every session on it ({}). If that is intended, call again with confirm: true.",
948 w.name,
949 s.describe()
950 )));
951 }
952 Ok(s)
953}
954
955fn instance_status(d: &Daemon, org: &OrgId, w: &Workspace) -> Option<String> {
956 Sandbox::get(&d.workspaces.oc(org), w.instance())
957 .and_then(|s| s.info())
958 .ok()
959 .map(|i| i.status)
960}
961
962#[expect(
965 clippy::too_many_lines,
966 reason = "predates the lint ratchet; split it when next changed"
967)]
968fn view(d: &Daemon, org: &OrgId, w: &Workspace, sessions: bool) -> Value {
969 let wsm = &d.workspaces;
970 let oc = wsm.oc(org);
971 let info = Sandbox::get(&oc, w.instance()).and_then(|s| s.info()).ok();
972 let running = info
973 .as_ref()
974 .is_some_and(|i| i.status.eq_ignore_ascii_case("running"));
975 let snap = d.ctl.snapshot();
976 let sample = snap
977 .instances
978 .get(&format!("{}/{}", org.incus_project(), w.instance()));
979 let project = org.incus_project();
980 let home = if w.home_bind.is_some() {
981 json!({"bind": w.home_bind, "path": w.home_dir(), "host_root": wsm.home_root})
982 } else {
983 let pool = wsm.home_pool_of(w, &oc).ok();
984 let driver = pool
985 .as_deref()
986 .and_then(|p| pool_driver(&wsm.client, p).ok());
987 let vol = pool.as_deref().and_then(|p| {
988 crate::volume::get(&oc, p, &w.home_volume(org))
989 .ok()
990 .flatten()
991 });
992 json!({
993 "volume": w.home_volume(org),
994 "pool": pool,
995 "driver": driver,
996 "cow": driver.as_deref().map(copy_on_write),
998 "path": w.home_dir(),
999 "size": vol.as_ref().and_then(|v| v.config.get("size").cloned()).unwrap_or_else(|| w.home_size.clone()),
1000 "exists": vol.is_some(),
1001 })
1002 };
1003 let last_used = wsm
1004 .last_used
1005 .lock()
1006 .unwrap_or_else(|e| e.into_inner())
1007 .get(&format!("{org}/{}", w.name))
1008 .copied();
1009 let url = wsm.url(org);
1010 let sandboxes = snap
1011 .instances
1012 .values()
1013 .filter(|i| i.project == project && ws::kind_of(&i.labels) == "sandbox")
1014 .count();
1015 let mut v = serde_json::to_value(w).unwrap_or_default();
1016 if let Some(o) = v.as_object_mut() {
1017 o.remove("token");
1018 }
1019 v["org"] = json!(org.as_str());
1020 v["home_dir"] = json!(w.home_dir());
1021 v["instance"] = match &info {
1022 Some(i) => json!({
1023 "name": i.name,
1024 "status": i.status,
1025 "type": i.instance_type,
1026 "created_at": i.created_at,
1027 "ip": sample.and_then(|s| s.ip.clone()),
1028 }),
1029 None => Value::Null,
1030 };
1031 v["status"] = json!(
1032 info.as_ref()
1033 .map(|i| i.status.clone())
1034 .unwrap_or_else(|| "Missing".into())
1035 );
1036 v["resources"] = json!({
1037 "cpu_pct": sample.and_then(|s| s.cpu_pct),
1038 "cpu_history": sample.map(|s| s.cpu_history.clone()).unwrap_or_default(),
1039 "mem_bytes": sample.and_then(|s| s.mem_bytes),
1040 "disk_bytes": sample.and_then(|s| s.disk_bytes),
1041 "cpus": info.as_ref().and_then(|i| i.config.get("limits.cpu").cloned()),
1042 "memory": info.as_ref().and_then(|i| i.config.get("limits.memory").cloned()),
1043 });
1044 v["home"] = home;
1045 v["sessions"] = if sessions {
1046 json!(wsm.sessions(org, w, running))
1047 } else {
1048 json!({"terminals": wsm.terminals(&project, w.instance())})
1049 };
1050 let last = wsm
1051 .activity
1052 .lock()
1053 .unwrap_or_else(|e| e.into_inner())
1054 .get(&key(&project, w.instance()))
1055 .copied();
1056 v["last_activity"] = json!(last.max(last_used));
1057 v["token"] = match &w.token {
1058 Some(t) => json!({
1059 "id": t.id,
1060 "role": w.token_role,
1061 "created_at": t.created_at,
1062 "last_used": last_used,
1063 "path": ws::TOKEN_PATH,
1064 }),
1065 None => Value::Null,
1066 };
1067 v["connect"] = json!({
1068 "url": url,
1069 "mcp_url": url.as_ref().map(|u| format!("{u}/orgs/{org}/mcp")),
1070 "org": org.as_str(),
1071 "user": w.user,
1072 "token_path": ws::TOKEN_PATH,
1073 "env": ["ISB_URL", "ISB_ORG", "ISB_TOKEN", "ISB_WORKSPACE"],
1074 });
1075 v["sandboxes"] = json!(sandboxes);
1076 let allowed = nesting::org_allows(&d.client, org);
1077 v["nesting"] = json!({
1078 "allowed": allowed,
1079 "active": info.as_ref().is_some_and(|i| nesting::nests(&i.config)),
1080 "warning": allowed.then_some(nesting::WARNING),
1081 });
1082 v
1083}
1084
1085#[derive(Deserialize)]
1086#[serde(deny_unknown_fields)]
1087struct NameArgs {
1088 #[serde(default)]
1089 #[allow(dead_code)]
1090 org: Option<String>,
1091 #[serde(default)]
1092 name: Option<String>,
1093 #[serde(default)]
1094 confirm: bool,
1095}
1096
1097#[derive(Deserialize)]
1098#[serde(deny_unknown_fields)]
1099struct UpdateArgs {
1100 #[serde(default)]
1101 #[allow(dead_code)]
1102 org: Option<String>,
1103 #[serde(default)]
1104 name: Option<String>,
1105 #[serde(default)]
1106 image: Option<String>,
1107 #[serde(default)]
1108 cpus: Option<u32>,
1109 #[serde(default)]
1110 memory: Option<String>,
1111 #[serde(default)]
1112 root_size: Option<String>,
1113 #[serde(default)]
1114 home_size: Option<String>,
1115 #[serde(default)]
1116 env: Option<BTreeMap<String, String>>,
1117 #[serde(default)]
1118 secrets: Option<Vec<String>>,
1119 #[serde(default)]
1120 labels: Option<BTreeMap<String, String>>,
1121 #[serde(default)]
1122 token_role: Option<Role>,
1123 #[serde(default)]
1125 setup: Option<String>,
1126 #[serde(default)]
1127 confirm: bool,
1128}
1129
1130#[expect(
1131 clippy::too_many_lines,
1132 reason = "predates the lint ratchet; split it when next changed"
1133)]
1134fn workspace_update(d: &Daemon, a: Value, c: &Caller) -> Result<Value> {
1135 let org = super::arg_org(&a)?;
1136 let a: UpdateArgs = args(a)?;
1137 require(c, &org, Role::Admin, "changing a workspace")?;
1138 let wsm = d.workspaces.clone();
1139 let name = resolve_name(&wsm, &org, a.name.as_deref())?;
1140 let _g = wsm.lock.lock().unwrap_or_else(|e| e.into_inner());
1141 let mut w = load(&wsm, &org, &name)?;
1142 let resize = a.cpus.is_some_and(|x| Some(x) != w.cpus)
1143 || a.memory
1144 .as_ref()
1145 .is_some_and(|x| Some(x) != w.memory.as_ref())
1146 || a.root_size
1147 .as_ref()
1148 .is_some_and(|x| Some(x) != w.root_size.as_ref())
1149 || a.home_size.as_ref().is_some_and(|x| *x != w.home_size);
1150 if resize {
1151 confirm(d, &org, &w, "Resizing", a.confirm)?;
1152 }
1153 if let Some(s) = &a.root_size {
1154 check_size("root_size", s)?;
1155 }
1156 if let Some(s) = &a.home_size {
1157 check_size("home_size", s)?;
1158 }
1159 check_fields(
1160 a.env.as_ref().unwrap_or(&BTreeMap::new()),
1161 a.secrets.as_deref().unwrap_or(&[]),
1162 a.labels.as_ref().unwrap_or(&BTreeMap::new()),
1163 )?;
1164 if let Some(ss) = &a.secrets {
1165 for s in ss {
1166 d.secrets
1167 .inspect(&org, s)
1168 .map_err(|e| Error::invalid(format!("secret {s}: {e}")))?;
1169 }
1170 }
1171 let oc = wsm.oc(&org);
1172 let path = format!("/1.0/instances/{}", encode_segment(w.instance()));
1173 let mut changed = Vec::new();
1174 let mut patch = serde_json::Map::new();
1175 if let Some(n) = a.cpus {
1176 patch.insert("limits.cpu".into(), json!(n.to_string()));
1177 w.cpus = Some(n);
1178 changed.push("cpus");
1179 }
1180 if let Some(m) = &a.memory {
1181 patch.insert("limits.memory".into(), json!(m));
1182 w.memory = Some(m.clone());
1183 changed.push("memory");
1184 }
1185 if let Some(l) = &a.labels {
1186 for k in w.labels.keys() {
1187 if !l.contains_key(k) {
1188 patch.insert(format!("user.{k}"), json!(""));
1189 }
1190 }
1191 for (k, v) in l {
1192 patch.insert(format!("user.{k}"), json!(v));
1193 }
1194 w.labels = l.clone();
1195 changed.push("labels");
1196 }
1197 if !patch.is_empty() {
1198 oc.mutate(
1199 "PATCH",
1200 &path,
1201 Some(&json!({"config": patch})),
1202 &format!("update workspace {name}"),
1203 oc.timeouts.other,
1204 )?;
1205 }
1206 if let Some(r) = &a.root_size {
1207 let inst = oc.get(&path)?;
1208 let mut root = inst["devices"]["root"].clone();
1209 if root.is_null() {
1210 root = json!({"type": "disk", "path": "/", "pool": wsm.pool(&oc)?});
1211 }
1212 root["size"] = json!(r);
1213 oc.mutate(
1214 "PATCH",
1215 &path,
1216 Some(&json!({"devices": {"root": root}})),
1217 &format!("resize workspace {name}'s root"),
1218 oc.timeouts.other,
1219 )?;
1220 w.root_size = Some(r.clone());
1221 changed.push("root_size");
1222 }
1223 if let Some(h) = &a.home_size {
1224 if w.home_bind.is_none() {
1225 let pool = wsm.home_pool_of(&w, &oc)?;
1226 oc.mutate(
1227 "PATCH",
1228 &format!(
1229 "/1.0/storage-pools/{}/volumes/custom/{}",
1230 encode_segment(&pool),
1231 encode_segment(&w.home_volume(&org))
1232 ),
1233 Some(&json!({"config": {"size": h}})),
1234 &format!("resize workspace {name}'s home"),
1235 oc.timeouts.other,
1236 )?;
1237 }
1238 w.home_size = h.clone();
1239 changed.push("home_size");
1240 }
1241 if let Some(i) = &a.image {
1242 if i.trim().is_empty() {
1243 return Err(Error::invalid("image cannot be empty"));
1244 }
1245 w.image = i.trim().to_string();
1246 changed.push("image (takes effect on rebuild)");
1247 }
1248 let mut redeliver = false;
1249 if let Some(e) = a.env {
1250 w.env = e;
1251 redeliver = true;
1252 changed.push("env");
1253 }
1254 if let Some(s) = a.secrets {
1255 w.secrets = s;
1256 redeliver = true;
1257 changed.push("secrets");
1258 }
1259 if let Some(r) = a.token_role {
1260 w.token_role = token_role(Some(r))?;
1261 wsm.index(&org, &w);
1262 changed.push("token_role");
1263 }
1264 if a.setup.is_some() {
1265 w.setup = setup::check_setup(a.setup)?;
1266 if w.setup.is_none() {
1267 w.setup_state = None;
1268 }
1269 changed.push("setup (runs on the next rebuild, or with workspace_setup_run)");
1270 }
1271 w.updated_at = now();
1272 wsm.store.put(&org, &w)?;
1273 if redeliver {
1274 wsm.deliver(&org, &w)?;
1275 }
1276 wsm.record(
1277 &org,
1278 "workspace.updated",
1279 &name,
1280 &creator(c),
1281 format!("workspace {name} changed: {}", changed.join(", ")),
1282 json!({"changed": changed}),
1283 );
1284 let mut v = view(d, &org, &w, false);
1285 v["changed"] = json!(changed);
1286 Ok(v)
1287}
1288
1289fn power(d: &Daemon, a: Value, c: &Caller, action: &str) -> Result<Value> {
1290 let org = super::arg_org(&a)?;
1291 let a: NameArgs = args(a)?;
1292 require(c, &org, Role::Member, &format!("{action} a workspace"))?;
1293 let wsm = d.workspaces.clone();
1294 let name = resolve_name(&wsm, &org, a.name.as_deref())?;
1295 let w = load(&wsm, &org, &name)?;
1296 let sb = Sandbox::get(&wsm.oc(&org), w.instance())?;
1297 let ended = match action {
1298 "start" => {
1299 sb.start()?;
1300 None
1301 }
1302 "stop" => {
1303 let s = confirm(d, &org, &w, "Stopping", a.confirm)?;
1304 sb.stop(false, Duration::from_secs(30))
1305 .or_else(|_| sb.stop(true, Duration::from_secs(30)))?;
1306 Some(s)
1307 }
1308 _ => {
1309 let s = confirm(d, &org, &w, "Restarting", a.confirm)?;
1310 sb.restart()?;
1311 Some(s)
1312 }
1313 };
1314 if action != "stop" {
1315 let _ = sb.wait_ready();
1317 wsm.deliver(&org, &w)?;
1318 }
1319 wsm.record(
1320 &org,
1321 &format!("workspace.{action}"),
1322 &name,
1323 &creator(c),
1324 format!("workspace {name}: {action} by {}", creator(c)),
1325 json!({"sessions_ended": ended}),
1326 );
1327 let mut v = view(d, &org, &w, false);
1328 if let Some(s) = ended {
1329 v["sessions_ended"] = json!(s);
1330 }
1331 Ok(v)
1332}
1333
1334#[derive(Deserialize)]
1335#[serde(deny_unknown_fields)]
1336struct RebuildArgs {
1337 #[serde(default)]
1338 #[allow(dead_code)]
1339 org: Option<String>,
1340 #[serde(default)]
1341 name: Option<String>,
1342 #[serde(default)]
1343 image: Option<String>,
1344 #[serde(default)]
1345 confirm: bool,
1346}
1347
1348fn workspace_rebuild(d: &Daemon, a: Value, c: &Caller) -> Result<Value> {
1349 let org = super::arg_org(&a)?;
1350 let a: RebuildArgs = args(a)?;
1351 require(c, &org, Role::Admin, "rebuilding a workspace")?;
1352 let wsm = d.workspaces.clone();
1353 let name = resolve_name(&wsm, &org, a.name.as_deref())?;
1354 let _g = wsm.lock.lock().unwrap_or_else(|e| e.into_inner());
1355 let mut w = load(&wsm, &org, &name)?;
1356 let ended = confirm(d, &org, &w, "Rebuilding", a.confirm)?;
1357 if let Some(i) = a.image.filter(|i| !i.trim().is_empty()) {
1358 w.image = i.trim().to_string();
1359 }
1360 let oc = wsm.oc(&org);
1361 match Sandbox::remove(&oc, w.instance(), true) {
1362 Ok(()) => {}
1363 Err(e) if e.is_not_found() => {}
1364 Err(e) => return Err(e),
1365 }
1366 let mut log = Vec::new();
1367 wsm.build(&org, &w, &mut log)?;
1368 let t = now();
1369 w.rebuilt_at = Some(t);
1370 w.updated_at = t;
1371 w.setup_state = ws::setup_next(
1372 w.setup.is_some(),
1373 w.setup_state.as_ref(),
1374 ws::SetupEvent::Built,
1375 t,
1376 )?;
1377 wsm.store.put(&org, &w)?;
1378 drop(_g);
1379 wsm.kick_setup(&org, &name);
1380 wsm.record(
1381 &org,
1382 "workspace.rebuilt",
1383 &name,
1384 &creator(c),
1385 format!("workspace {name} rebuilt from {}; home kept", w.image),
1386 json!({"image": w.image, "sessions_ended": ended}),
1387 );
1388 let mut v = view(d, &org, &w, false);
1389 v["log"] = json!(log);
1390 v["sessions_ended"] = json!(ended);
1391 Ok(v)
1392}
1393
1394#[derive(Deserialize)]
1395#[serde(deny_unknown_fields)]
1396struct DeleteArgs {
1397 #[serde(default)]
1398 #[allow(dead_code)]
1399 org: Option<String>,
1400 #[serde(default)]
1401 name: Option<String>,
1402 #[serde(default)]
1403 keep_home: bool,
1404 #[serde(default)]
1405 confirm: bool,
1406}
1407
1408fn workspace_delete(d: &Daemon, a: Value, c: &Caller) -> Result<Value> {
1409 let org = super::arg_org(&a)?;
1410 let a: DeleteArgs = args(a)?;
1411 require(c, &org, Role::Admin, "deleting a workspace")?;
1412 let wsm = d.workspaces.clone();
1413 let name = resolve_name(&wsm, &org, a.name.as_deref())?;
1414 let _g = wsm.lock.lock().unwrap_or_else(|e| e.into_inner());
1415 let w = load(&wsm, &org, &name)?;
1416 let what = if a.keep_home || w.home_bind.is_some() {
1417 "Deleting (keeping its home)"
1418 } else {
1419 "Deleting, home and all,"
1420 };
1421 let ended = confirm(d, &org, &w, what, a.confirm)?;
1422 let oc = wsm.oc(&org);
1423 match Sandbox::remove(&oc, w.instance(), true) {
1424 Ok(()) => {}
1425 Err(e) if e.is_not_found() => {}
1426 Err(e) => return Err(e),
1427 }
1428 wsm.index(
1430 &org,
1431 &Workspace {
1432 token: None,
1433 ..w.clone()
1434 },
1435 );
1436 let mut home_deleted = false;
1437 if !a.keep_home && w.home_bind.is_none() {
1438 let pool = wsm.home_pool_of(&w, &oc)?;
1439 match crate::volume::remove(&oc, &pool, &w.home_volume(&org)) {
1440 Ok(()) => home_deleted = true,
1441 Err(e) if e.is_not_found() => {}
1442 Err(e) => return Err(e),
1443 }
1444 }
1445 wsm.store.delete(&org, &name)?;
1446 wsm.ensure_bridge(&org);
1447 wsm.record(
1448 &org,
1449 "workspace.deleted",
1450 &name,
1451 &creator(c),
1452 format!(
1453 "workspace {name} deleted; its token revoked{}",
1454 if home_deleted {
1455 ", its home deleted"
1456 } else {
1457 ", its home kept"
1458 }
1459 ),
1460 json!({"home_deleted": home_deleted, "sessions_ended": ended}),
1461 );
1462 Ok(json!({
1463 "ok": true,
1464 "name": name,
1465 "token_revoked": true,
1466 "home_deleted": home_deleted,
1467 "home_volume": (!home_deleted && w.home_bind.is_none()).then(|| w.home_volume(&org)),
1468 "sessions_ended": ended,
1469 }))
1470}
1471
1472fn workspace_token_rotate(d: &Daemon, a: Value, c: &Caller) -> Result<Value> {
1473 let org = super::arg_org(&a)?;
1474 let a: NameArgs = args(a)?;
1475 require(c, &org, Role::Admin, "rotating a workspace's token")?;
1476 let wsm = d.workspaces.clone();
1477 let name = resolve_name(&wsm, &org, a.name.as_deref())?;
1478 let _g = wsm.lock.lock().unwrap_or_else(|e| e.into_inner());
1479 let mut w = load(&wsm, &org, &name)?;
1480 wsm.mint(&org, &mut w)?;
1481 w.updated_at = now();
1482 wsm.store.put(&org, &w)?;
1483 let delivered = match wsm.deliver(&org, &w) {
1484 Ok(()) => true,
1485 Err(e) => {
1486 eprintln!("isb serve: workspace {org}/{name}: new token not delivered yet: {e}");
1487 false
1488 }
1489 };
1490 let id = w.token.as_ref().map(|t| t.id.clone());
1491 wsm.record(
1492 &org,
1493 "workspace.token_rotated",
1494 &name,
1495 &creator(c),
1496 format!("workspace {name}: token rotated; the old one no longer works"),
1497 json!({"token_id": id}),
1498 );
1499 Ok(json!({
1500 "ok": true,
1501 "name": name,
1502 "token": {"id": id, "role": w.token_role, "created_at": w.token.as_ref().map(|t| t.created_at)},
1503 "delivered": delivered,
1504 "message": format!(
1505 "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.",
1506 ws::TOKEN_PATH
1507 ),
1508 }))
1509}
1510
1511#[expect(
1512 clippy::too_many_lines,
1513 reason = "predates the lint ratchet; split it when next changed"
1514)]
1515pub(super) fn register(r: &mut Registry, d: Arc<Daemon>) -> Result<()> {
1516 let ro = json!({"readOnlyHint": true, "openWorldHint": false});
1517 let destructive = json!({"destructiveHint": true, "openWorldHint": false});
1518 let write = json!({"destructiveHint": false, "openWorldHint": false});
1519
1520 macro_rules! tool {
1521 ($name:expr, $title:expr, $desc:expr, $schema:expr, $ann:expr, $f:expr) => {{
1522 let d = d.clone();
1523 let f = $f;
1524 r.register(
1525 Tool::new($name, $desc, $schema, move |a, c| {
1526 crate::org::check_exists(&d.client, &super::arg_org(&a)?)?;
1528 f(&d, a, c)
1529 })
1530 .title($title)
1531 .annotations($ann.clone()),
1532 )?;
1533 }};
1534 }
1535 let name =
1536 || json!({"type": "string", "description": "The workspace (default: the org's only one)."});
1537 let confirm_p = || json!({"type": "boolean", "description": "Required: this ends live sessions on the workspace. Without it the call only says what would end."});
1538
1539 tool!(
1540 "workspace_get",
1541 "Get the workspace",
1542 "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.",
1543 obj(json!({"name": name()}), &[]),
1544 ro,
1545 |d: &Daemon, a: Value, _c: &Caller| -> Result<Value> {
1546 let org = super::arg_org(&a)?;
1547 #[derive(Deserialize)]
1548 #[serde(deny_unknown_fields)]
1549 struct A {
1550 #[serde(default)]
1551 #[allow(dead_code)]
1552 org: Option<String>,
1553 #[serde(default)]
1554 name: Option<String>,
1555 }
1556 let a: A = args(a)?;
1557 let wsm = &d.workspaces;
1558 let name = resolve_name(wsm, &org, a.name.as_deref())?;
1559 let w = wsm.store.get(&org, &name)?;
1560 let create = w.is_none().then(|| images::create_options(wsm, &org));
1561 Ok(json!({
1562 "org": org.as_str(),
1563 "settings": wsm.store.settings(&org)?,
1564 "workspace": w.map(|w| view(d, &org, &w, true)),
1565 "create": create,
1566 }))
1567 }
1568 );
1569 tool!(
1570 "workspace_list",
1571 "List workspaces",
1572 "The org's workspaces (one per org unless a platform admin raised max_workspaces), as workspace_get shows each, without the session count.",
1573 obj(json!({}), &[]),
1574 ro,
1575 |d: &Daemon, a: Value, _c: &Caller| -> Result<Value> {
1576 let org = super::arg_org(&a)?;
1577 let wsm = &d.workspaces;
1578 let all: Vec<Value> = wsm
1579 .store
1580 .list(&org)?
1581 .iter()
1582 .map(|w| view(d, &org, w, false))
1583 .collect();
1584 Ok(
1585 json!({"org": org.as_str(), "settings": wsm.store.settings(&org)?, "workspaces": all}),
1586 )
1587 }
1588 );
1589 tool!(
1590 "workspace_create",
1591 "Create the workspace",
1592 "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.",
1593 obj(
1594 json!({
1595 "name": {"type": "string", "description": "Default: workspace."},
1596 "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."},
1597 "user": {"type": "string", "description": "The workspace user (default dev); created when the image lacks it."},
1598 "cpus": {"type": "integer", "minimum": 1},
1599 "memory": {"type": "string", "description": "e.g. 8GiB."},
1600 "root_size": {"type": "string", "description": "The root disk, e.g. 30GiB (default: the pool's)."},
1601 "home_size": {"type": "string", "description": "The home volume (default 20GiB)."},
1602 "env": {"type": "object", "additionalProperties": {"type": "string"}, "description": "Plain variables for login shells."},
1603 "secrets": {"type": "array", "items": {"type": "string"}, "description": "Org secrets delivered as /run/isb/secrets/NAME."},
1604 "labels": {"type": "object", "additionalProperties": {"type": "string"}},
1605 "token_role": {"type": "string", "enum": ["viewer", "member", "admin"], "description": "The workspace token's role in the org (default admin)."},
1606 "home_bind": {"type": "string", "description": "Superadmins only: this host directory as the home (an existing box's, when migrating), instead of the default home."},
1607 "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."}
1608 }),
1609 &[]
1610 ),
1611 write,
1612 workspace_create
1613 );
1614 tool!(
1615 "workspace_update",
1616 "Change the workspace",
1617 "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.",
1618 obj(
1619 json!({
1620 "name": name(),
1621 "image": {"type": "string"},
1622 "cpus": {"type": "integer", "minimum": 1},
1623 "memory": {"type": "string"},
1624 "root_size": {"type": "string"},
1625 "home_size": {"type": "string", "description": "Grow the home volume."},
1626 "env": {"type": "object", "additionalProperties": {"type": "string"}, "description": "Replaces the variables."},
1627 "secrets": {"type": "array", "items": {"type": "string"}, "description": "Replaces the delivered secrets."},
1628 "labels": {"type": "object", "additionalProperties": {"type": "string"}, "description": "Replaces the labels."},
1629 "token_role": {"type": "string", "enum": ["viewer", "member", "admin"]},
1630 "setup": {"type": "string", "description": "Replaces the first-boot script (\"\" removes it); it runs on the next rebuild or with workspace_setup_run."},
1631 "confirm": confirm_p()
1632 }),
1633 &[]
1634 ),
1635 write,
1636 workspace_update
1637 );
1638 tool!(
1639 "workspace_start",
1640 "Start the workspace",
1641 "Start the workspace and deliver its credentials. Org members and above.",
1642 obj(json!({"name": name()}), &[]),
1643 write,
1644 |d: &Daemon, a: Value, c: &Caller| power(d, a, c, "start")
1645 );
1646 tool!(
1647 "workspace_stop",
1648 "Stop the workspace",
1649 "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.",
1650 obj(json!({"name": name(), "confirm": confirm_p()}), &[]),
1651 write,
1652 |d: &Daemon, a: Value, c: &Caller| power(d, a, c, "stop")
1653 );
1654 tool!(
1655 "workspace_restart",
1656 "Restart the workspace",
1657 "Restart the workspace. This ends every session on it: without confirm: true it only reports the live sessions. Org members and above.",
1658 obj(json!({"name": name(), "confirm": confirm_p()}), &[]),
1659 write,
1660 |d: &Daemon, a: Value, c: &Caller| power(d, a, c, "restart")
1661 );
1662 tool!(
1663 "workspace_rebuild",
1664 "Rebuild the workspace",
1665 "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.",
1666 obj(
1667 json!({"name": name(), "image": {"type": "string", "description": "Rebuild from this image instead (it becomes the workspace's)."}, "confirm": confirm_p()}),
1668 &[]
1669 ),
1670 destructive,
1671 workspace_rebuild
1672 );
1673 tool!(
1674 "workspace_delete",
1675 "Delete the workspace",
1676 "Delete the workspace: its machine, its token (revoked at once) and, unless keep_home, its home volume. Needs confirm: true. Org admins and above.",
1677 obj(
1678 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()}),
1679 &[]
1680 ),
1681 destructive,
1682 workspace_delete
1683 );
1684 tool!(
1685 "workspace_token_rotate",
1686 "Rotate the workspace's token",
1687 "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.",
1688 obj(json!({"name": name()}), &[]),
1689 write,
1690 workspace_token_rotate
1691 );
1692 tool!(
1693 "workspace_settings",
1694 "Workspace settings",
1695 "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.",
1696 obj(
1697 json!({
1698 "max_workspaces": {"type": "integer", "minimum": 1, "maximum": 100},
1699 "sandbox_expiry": {"type": "string", "description": "e.g. 24h, 7d."},
1700 "sandbox_idle": {"type": "string", "description": "e.g. 2h, or none."},
1701 "secret_refresh": {"type": "string", "description": "e.g. 1h, 5m; at least 10s."},
1702 "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)."},
1703 "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)."}
1704 }),
1705 &[]
1706 ),
1707 write,
1708 settings::workspace_settings
1709 );
1710 tool!(
1711 "workspace_setup_run",
1712 "Run the workspace's setup script",
1713 "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.",
1714 obj(json!({"name": name()}), &[]),
1715 write,
1716 setup::workspace_setup_run
1717 );
1718 tool!(
1719 "workspace_terminals",
1720 "The workspace's terminal sessions",
1721 "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.",
1722 obj(json!({"name": name()}), &[]),
1723 ro,
1724 herdr::workspace_terminals
1725 );
1726 tool!(
1727 "workspace_terminal_update",
1728 "Rename or end a terminal session",
1729 "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.",
1730 obj(
1731 json!({
1732 "name": name(),
1733 "session": {"type": "string", "description": "The session (its tab's name)."},
1734 "rename": {"type": "string", "description": "Its new name."},
1735 "end": {"type": "boolean", "description": "Close the session and its shells."}
1736 }),
1737 &["session"]
1738 ),
1739 write,
1740 herdr::workspace_terminal_update
1741 );
1742 image_tools::register(r, d.clone())?;
1743 nesting::register(r, d.clone())?;
1744 ports::register(r, d)?;
1745 Ok(())
1746}
1747
1748#[cfg(test)]
1749mod tests {
1750 use super::*;
1751
1752 #[test]
1753 fn the_instance_spec_has_the_home_the_size_and_the_labels() {
1754 let org = OrgId::new("acme").unwrap();
1755 let mut w: Workspace = serde_json::from_value(json!({
1756 "name": "workspace", "id": "x", "image": "dev-base", "user": "dev",
1757 "home_size": "5GiB", "token_role": "admin", "created_at": 0, "created_by": "a@x.io",
1758 "cpus": 2, "memory": "2GiB", "root_size": "30GiB", "labels": {"team": "ops"}
1759 }))
1760 .unwrap();
1761 let s = Workspaces::spec(&org, &w, "default").unwrap();
1762 assert_eq!(s.name.as_deref(), Some("workspace"));
1763 assert_eq!(s.user.as_deref(), Some("dev"));
1764 assert_eq!(s.working_dir.as_deref(), Some("/home/dev"));
1765 assert_eq!(s.cpus.as_deref(), Some("2"));
1766 assert!(s.memory.is_some());
1767 assert_eq!(s.labels["isb.workspace"], "workspace");
1768 assert_eq!(s.labels["isb.owner"], "a@x.io");
1769 assert_eq!(s.labels["team"], "ops");
1770 assert_eq!(s.raw_devices["root"]["size"], "30GiB");
1771 assert_eq!(s.raw_config["boot.autostart"], "true");
1772 assert_eq!(s.volumes.len(), 1);
1773 assert_eq!(s.volumes[0].source, "acme_workspace_home");
1774 assert_eq!(s.volumes[0].target, "/home/dev");
1775 w.home_bind = Some("/srv/workspaces/acme/home".into());
1777 let s = Workspaces::spec(&org, &w, "default").unwrap();
1778 assert_eq!(s.volumes[0].source, "/srv/workspaces/acme/home");
1779 }
1780
1781 pub(super) fn manager(state: &Path, home_root: Option<PathBuf>) -> Workspaces {
1784 let keyring = Arc::new(crate::secrets::Keyring::new(
1785 age::x25519::Identity::generate(),
1786 vec![],
1787 ));
1788 Workspaces {
1789 store: Store::new(state),
1790 client: Client::with_socket(state.join("no-incus.sock")),
1791 keyring: keyring.clone(),
1792 secrets: Arc::new(crate::secrets::Secrets::new(
1793 crate::secrets::LocalDriver::new(state, keyring),
1794 )),
1795 recorder: crate::history::Recorder::start(Arc::new(
1796 crate::audit::AuditLog::open(&state.join("a.db"), Duration::from_secs(60)).unwrap(),
1797 )),
1798 port: DEFAULT_PORT,
1799 home_pool: None,
1800 home_root,
1801 tokens: Mutex::new(HashMap::new()),
1802 last_used: Mutex::new(HashMap::new()),
1803 sessions: Arc::new(Mutex::new(HashMap::new())),
1804 activity: Mutex::new(HashMap::new()),
1805 delivered: Mutex::new(HashMap::new()),
1806 secret_poll: Mutex::default(),
1807 bridges: Mutex::new(HashMap::new()),
1808 ssh: Mutex::new(HashMap::new()),
1809 serve: OnceLock::new(),
1810 lock: Mutex::new(()),
1811 started: 0,
1812 previews: Previews::default(),
1813 }
1814 }
1815
1816 pub(super) fn a_workspace() -> Workspace {
1817 serde_json::from_value(json!({
1818 "name": "workspace", "id": "x", "image": "dev-base", "user": "dev",
1819 "home_size": "1GiB", "token_role": "admin", "created_at": 0, "created_by": "a"
1820 }))
1821 .unwrap()
1822 }
1823
1824 #[test]
1825 fn workspace_tokens_authenticate_as_the_org_workspace_and_rotate() {
1826 let d = tempfile::tempdir().unwrap();
1827 let org = OrgId::new("acme").unwrap();
1828 let m = manager(d.path(), None);
1829 let store = m.store.clone();
1830 let mut w = a_workspace();
1831 m.mint(&org, &mut w).unwrap();
1832 store.put(&org, &w).unwrap();
1833 let t1 = String::from_utf8(m.token_plain(&org, "workspace").unwrap().unwrap()).unwrap();
1834 assert!(t1.starts_with("isb_ws_"));
1835 let on_disk =
1837 std::fs::read_to_string(d.path().join("orgs/acme/workspaces/workspace.json")).unwrap();
1838 assert!(!on_disk.contains(&t1));
1839 let p = m.authenticate(&t1).unwrap();
1840 assert_eq!(p.role_in(&org), Some(Role::Admin));
1841 assert!(p.is_workspace() && !p.platform_admin);
1842 assert_eq!(p.orgs.len(), 1);
1843 assert!(m.authenticate("isb_ws_nope").is_none());
1844 m.mint(&org, &mut w).unwrap();
1846 let t2 = String::from_utf8(m.token_plain(&org, "workspace").unwrap().unwrap()).unwrap();
1847 assert_ne!(t1, t2);
1848 assert!(m.authenticate(&t1).is_none());
1849 assert!(m.authenticate(&t2).is_some());
1850 store.put(&org, &w).unwrap();
1852 m.tokens.lock().unwrap().clear();
1853 m.load_tokens();
1854 assert!(m.authenticate(&t2).is_some());
1855 m.index(&org, &Workspace { token: None, ..w });
1857 assert!(m.authenticate(&t2).is_none());
1858 let g = m.session("isb-acme", "workspace");
1860 assert_eq!(m.terminals("isb-acme", "workspace"), 1);
1861 drop(g);
1862 assert_eq!(m.terminals("isb-acme", "workspace"), 0);
1863 }
1864}