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