1use std::collections::BTreeMap;
21use std::path::{Path, PathBuf};
22use std::time::Duration;
23
24use serde::{Deserialize, Serialize};
25
26use crate::auth::Role;
27use crate::error::{Error, Result};
28use crate::org::OrgId;
29
30pub const DEFAULT_NAME: &str = "workspace";
33
34pub const DEFAULT_USER: &str = "dev";
36
37pub const DEFAULT_HOME_SIZE: &str = "20GiB";
39
40pub const KEY_WORKSPACE: &str = "user.isb.workspace";
42pub const KEY_EXPIRES_AT: &str = "user.isb.expires_at";
43pub const KEY_IDLE_TIMEOUT: &str = "user.isb.idle_timeout";
44
45pub const MAX_SANDBOX_LIFETIME: Duration = Duration::from_secs(30 * 86400);
47
48pub const TOKEN_PATH: &str = "/run/isb/token";
50pub const SECRETS_DIR: &str = "/run/isb/secrets";
52pub const PROFILE_PATH: &str = "/etc/profile.d/isb.sh";
55
56#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
58pub struct Workspace {
59 pub name: String,
60 pub id: String,
63 pub image: String,
66 pub user: String,
68 #[serde(default, skip_serializing_if = "Option::is_none")]
69 pub cpus: Option<u32>,
70 #[serde(default, skip_serializing_if = "Option::is_none")]
71 pub memory: Option<String>,
72 #[serde(default, skip_serializing_if = "Option::is_none")]
74 pub root_size: Option<String>,
75 pub home_size: String,
76 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
78 pub env: BTreeMap<String, String>,
79 #[serde(default, skip_serializing_if = "Vec::is_empty")]
81 pub secrets: Vec<String>,
82 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
83 pub labels: BTreeMap<String, String>,
84 pub token_role: Role,
86 #[serde(default, skip_serializing_if = "Option::is_none")]
90 pub home_bind: Option<String>,
91 #[serde(default, skip_serializing_if = "Option::is_none")]
93 pub pool: Option<String>,
94 pub created_at: u64,
95 pub created_by: String,
96 #[serde(default)]
97 pub updated_at: u64,
98 #[serde(default, skip_serializing_if = "Option::is_none")]
99 pub rebuilt_at: Option<u64>,
100 #[serde(default, skip_serializing_if = "Option::is_none")]
101 pub token: Option<TokenMeta>,
102 #[serde(default, skip_serializing_if = "Option::is_none")]
106 pub setup: Option<String>,
107 #[serde(default, skip_serializing_if = "Option::is_none")]
109 pub setup_state: Option<SetupState>,
110 #[serde(default, skip_serializing_if = "Vec::is_empty")]
112 pub ports: Vec<PublishedPort>,
113}
114
115pub const MAX_SETUP: usize = 64 * 1024;
117
118#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
120#[serde(rename_all = "lowercase")]
121pub enum SetupStatus {
122 Pending,
124 Running,
125 Succeeded,
126 Failed,
127}
128
129#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
131pub struct SetupState {
132 pub status: SetupStatus,
133 pub at: u64,
135 #[serde(default)]
137 pub runs: u32,
138 #[serde(default, skip_serializing_if = "Option::is_none")]
139 pub exit_code: Option<i32>,
140 #[serde(default, skip_serializing_if = "Option::is_none")]
141 pub message: Option<String>,
142}
143
144#[derive(Debug, Clone, PartialEq, Eq)]
146pub enum SetupEvent {
147 Built,
149 Requested,
151 Started,
153 Finished(i32),
155 Error(String),
157 DaemonStarted,
159}
160
161pub fn setup_next(
163 script: bool,
164 cur: Option<&SetupState>,
165 ev: SetupEvent,
166 now: u64,
167) -> Result<Option<SetupState>> {
168 use SetupStatus::*;
169 if !script {
170 return match ev {
171 SetupEvent::Requested => Err(Error::invalid(
172 "the workspace has no setup script (set one with workspace_update)",
173 )),
174 _ => Ok(None),
175 };
176 }
177 let runs = cur.map_or(0, |c| c.runs);
178 let status = cur.map(|c| c.status);
179 let st = |status, exit_code, message| SetupState {
180 status,
181 at: now,
182 runs,
183 exit_code,
184 message,
185 };
186 Ok(Some(match (ev, status) {
187 (SetupEvent::Requested, Some(Running)) => {
188 return Err(Error::invalid(
189 "the setup script is running; wait for it to end",
190 ));
191 }
192 (SetupEvent::Built | SetupEvent::Requested, _) => st(Pending, None, None),
193 (SetupEvent::Started, Some(Pending)) => SetupState {
194 runs: runs + 1,
195 ..st(Running, None, None)
196 },
197 (SetupEvent::Finished(0), Some(Running)) => st(Succeeded, Some(0), None),
198 (SetupEvent::Finished(c), Some(Running)) => st(Failed, Some(c), None),
199 (SetupEvent::Error(m), Some(Running)) => st(Failed, None, Some(m)),
200 (SetupEvent::DaemonStarted, Some(Running)) => st(
201 Failed,
202 None,
203 Some("interrupted: the daemon restarted while it ran".into()),
204 ),
205 (ev @ (SetupEvent::Started | SetupEvent::Finished(_) | SetupEvent::Error(_)), _) => {
206 return Err(Error::invalid(format!(
207 "setup: {ev:?} while it is {}",
208 status.map_or("not set up".into(), |s| format!("{s:?}").to_lowercase())
209 )));
210 }
211 (SetupEvent::DaemonStarted, _) => return Ok(cur.cloned()),
212 }))
213}
214
215pub fn setup_due(w: &Workspace) -> bool {
217 w.setup.is_some()
218 && w.setup_state
219 .as_ref()
220 .is_some_and(|s| s.status == SetupStatus::Pending)
221}
222
223#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
227pub struct PublishedPort {
228 pub port: u16,
229 #[serde(default, skip_serializing_if = "Option::is_none")]
231 pub host: Option<String>,
232 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
234 pub auto: bool,
235 pub added_by: String,
236 pub added_at: u64,
237}
238
239pub const MAX_PORTS: usize = 20;
241
242#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
244pub struct TokenMeta {
245 pub id: String,
247 pub hash: String,
249 pub created_at: u64,
250}
251
252impl Workspace {
253 pub fn instance(&self) -> &str {
255 &self.name
256 }
257
258 pub fn home_volume(&self, org: &OrgId) -> String {
260 home_volume(org, &self.name)
261 }
262
263 pub fn home_dir(&self) -> String {
265 if self.user == "root" {
266 "/root".into()
267 } else {
268 format!("/home/{}", self.user)
269 }
270 }
271}
272
273pub fn host_home(root: &Path, org: &OrgId, name: &str) -> PathBuf {
276 if name == DEFAULT_NAME {
277 root.join(org.as_str()).join("home")
278 } else {
279 root.join(org.as_str()).join(name).join("home")
280 }
281}
282
283pub fn home_volume(org: &OrgId, name: &str) -> String {
285 format!("{}_{name}_home", org.as_str())
286}
287
288pub fn check_name(name: &str) -> Result<()> {
291 let ok = !name.is_empty()
292 && name.len() <= 30
293 && name.starts_with(|c: char| c.is_ascii_lowercase())
294 && name
295 .bytes()
296 .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
297 && !name.ends_with('-');
298 if ok {
299 Ok(())
300 } else {
301 Err(Error::invalid(format!(
302 "workspace name {name:?}: [a-z0-9-], starting with a letter, at most 30 characters"
303 )))
304 }
305}
306
307pub fn check_user(user: &str) -> Result<()> {
309 let ok = !user.is_empty()
310 && user.len() <= 32
311 && user.starts_with(|c: char| c.is_ascii_lowercase() || c == '_')
312 && user
313 .bytes()
314 .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-' || b == b'_');
315 if ok {
316 Ok(())
317 } else {
318 Err(Error::invalid(format!(
319 "user {user:?}: a lowercase user name ([a-z_][a-z0-9_-]*)"
320 )))
321 }
322}
323
324pub fn check_env_name(k: &str) -> Result<()> {
326 let ok = !k.is_empty()
327 && k.starts_with(|c: char| c.is_ascii_alphabetic() || c == '_')
328 && k.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'_');
329 if !ok {
330 return Err(Error::invalid(format!(
331 "environment variable {k:?}: letters, digits and _, not starting with a digit"
332 )));
333 }
334 if k.starts_with("ISB_") {
335 return Err(Error::invalid(format!(
336 "environment variable {k}: ISB_* are set by isb"
337 )));
338 }
339 Ok(())
340}
341
342#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
344pub struct Settings {
345 #[serde(default = "one")]
348 pub max_workspaces: u32,
349 #[serde(default = "default_expiry")]
351 pub sandbox_expiry: String,
352 #[serde(default = "default_idle")]
355 pub sandbox_idle: String,
356 #[serde(default, skip_serializing_if = "Option::is_none")]
359 pub home_kind: Option<String>,
360 #[serde(default, skip_serializing_if = "Option::is_none")]
363 pub home_pool: Option<String>,
364}
365
366fn one() -> u32 {
367 1
368}
369
370fn default_expiry() -> String {
371 "24h".into()
372}
373
374fn default_idle() -> String {
375 "2h".into()
376}
377
378impl Default for Settings {
379 fn default() -> Self {
380 Settings {
381 max_workspaces: one(),
382 sandbox_expiry: default_expiry(),
383 sandbox_idle: default_idle(),
384 home_kind: None,
385 home_pool: None,
386 }
387 }
388}
389
390impl Settings {
391 pub fn validate(&self) -> Result<()> {
393 if self.max_workspaces == 0 || self.max_workspaces > 100 {
394 return Err(Error::invalid("max_workspaces: 1 to 100"));
395 }
396 lifetime(&self.sandbox_expiry)?;
397 idle(&self.sandbox_idle)?;
398 Ok(())
399 }
400}
401
402pub fn lifetime(s: &str) -> Result<Duration> {
404 let d = crate::flex::parse_duration(s.trim())
405 .map_err(|e| Error::invalid(format!("expiry {s:?}: {e}")))?;
406 if d.is_zero() || d > MAX_SANDBOX_LIFETIME {
407 return Err(Error::invalid(format!(
408 "expiry {s:?}: between a second and 30 days"
409 )));
410 }
411 Ok(d)
412}
413
414pub fn idle(s: &str) -> Result<Option<Duration>> {
416 let s = s.trim();
417 if matches!(s, "none" | "never" | "off") {
418 return Ok(None);
419 }
420 let d = crate::flex::parse_duration(s)
421 .map_err(|e| Error::invalid(format!("idle timeout {s:?}: {e}")))?;
422 if d < Duration::from_secs(60) || d > MAX_SANDBOX_LIFETIME {
423 return Err(Error::invalid(format!(
424 "idle timeout {s:?}: between a minute and 30 days, or none"
425 )));
426 }
427 Ok(Some(d))
428}
429
430pub fn sandbox_deadlines(
433 settings: &Settings,
434 expires: Option<&str>,
435 idle_timeout: Option<&str>,
436 now: u64,
437) -> Result<(u64, Option<u64>)> {
438 let life = lifetime(expires.unwrap_or(&settings.sandbox_expiry))?;
439 let idle = idle(idle_timeout.unwrap_or(&settings.sandbox_idle))?;
440 Ok((now + life.as_secs(), idle.map(|d| d.as_secs())))
441}
442
443pub fn extended(current: Option<u64>, by: Duration, now: u64) -> Result<u64> {
446 if by.is_zero() {
447 return Err(Error::invalid("extend by a positive duration"));
448 }
449 let from = current.unwrap_or(now).max(now);
450 let want = from.saturating_add(by.as_secs());
451 let cap = now + MAX_SANDBOX_LIFETIME.as_secs();
452 if want > cap {
453 return Err(Error::invalid(format!(
454 "a sandbox lives at most 30 days from now; the most it can be extended by is {}",
455 human(cap.saturating_sub(from))
456 )));
457 }
458 Ok(want)
459}
460
461pub fn human(secs: u64) -> String {
463 let units = [(86400, "d"), (3600, "h"), (60, "m"), (1, "s")];
464 let mut parts = Vec::new();
465 let mut rest = secs;
466 for (n, u) in units {
467 if rest >= n {
468 parts.push(format!("{}{u}", rest / n));
469 rest %= n;
470 }
471 if parts.len() == 2 {
472 break;
473 }
474 }
475 if parts.is_empty() {
476 "0s".into()
477 } else {
478 parts.join(" ")
479 }
480}
481
482pub fn kind_of(labels: &BTreeMap<String, String>) -> &'static str {
485 if labels.contains_key("isb.workspace") {
486 "workspace"
487 } else if labels.contains_key("isb.stack") {
488 "replica"
489 } else if labels.contains_key("isb.build") {
490 "build"
491 } else {
492 "sandbox"
493 }
494}
495
496pub fn reap_reason(
501 labels: &BTreeMap<String, String>,
502 now: u64,
503 last_active: u64,
504) -> Option<String> {
505 if kind_of(labels) != "sandbox" {
506 return None;
507 }
508 let num = |k: &str| labels.get(k).and_then(|v| v.trim().parse::<u64>().ok());
509 if let Some(at) = num("isb.expires_at") {
510 if now >= at {
511 return Some(format!("expired {} ago", human(now - at)));
512 }
513 }
514 if let Some(idle) = num("isb.idle_timeout").filter(|s| *s > 0) {
515 if now.saturating_sub(last_active) >= idle {
516 return Some(format!(
517 "idle for {}",
518 human(now.saturating_sub(last_active))
519 ));
520 }
521 }
522 None
523}
524
525pub fn dir(state: &Path, org: &OrgId) -> PathBuf {
527 crate::app::org_root(state, org).join("workspaces")
528}
529
530#[derive(Debug, Clone)]
532pub struct Store {
533 state: PathBuf,
534}
535
536impl Store {
537 pub fn new(state: &Path) -> Store {
538 Store {
539 state: state.to_path_buf(),
540 }
541 }
542
543 fn path(&self, org: &OrgId, name: &str) -> PathBuf {
544 dir(&self.state, org).join(format!("{name}.json"))
545 }
546
547 pub fn token_path(&self, org: &OrgId, name: &str) -> PathBuf {
548 dir(&self.state, org).join(format!("{name}.token.age"))
549 }
550
551 pub fn list(&self, org: &OrgId) -> Result<Vec<Workspace>> {
552 let d = dir(&self.state, org);
553 let mut out = Vec::new();
554 let rd = match std::fs::read_dir(&d) {
555 Ok(rd) => rd,
556 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(out),
557 Err(e) => return Err(e.into()),
558 };
559 for e in rd.flatten() {
560 let p = e.path();
561 let Some(stem) = p
562 .file_name()
563 .and_then(|n| n.to_str())
564 .and_then(|n| n.strip_suffix(".json"))
565 else {
566 continue;
567 };
568 if stem == "settings" || check_name(stem).is_err() {
569 continue;
570 }
571 match self.get(org, stem) {
572 Ok(Some(w)) => out.push(w),
573 Ok(None) => {}
574 Err(e) => eprintln!("isb serve: workspace {}: {e}", p.display()),
575 }
576 }
577 out.sort_by(|a, b| a.name.cmp(&b.name));
578 Ok(out)
579 }
580
581 pub fn get(&self, org: &OrgId, name: &str) -> Result<Option<Workspace>> {
582 check_name(name)?;
583 match std::fs::read(self.path(org, name)) {
584 Ok(b) => Ok(Some(serde_json::from_slice(&b).map_err(|e| {
585 Error::invalid(format!("workspace {name} in {org}: {e}"))
586 })?)),
587 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
588 Err(e) => Err(e.into()),
589 }
590 }
591
592 pub fn put(&self, org: &OrgId, w: &Workspace) -> Result<()> {
593 check_name(&w.name)?;
594 crate::app::write_atomic(&self.path(org, &w.name), &serde_json::to_vec_pretty(w)?)
595 }
596
597 pub fn delete(&self, org: &OrgId, name: &str) -> Result<()> {
598 for p in [self.path(org, name), self.token_path(org, name)] {
599 match std::fs::remove_file(&p) {
600 Ok(()) => {}
601 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
602 Err(e) => return Err(e.into()),
603 }
604 }
605 Ok(())
606 }
607
608 pub fn settings(&self, org: &OrgId) -> Result<Settings> {
609 match std::fs::read(dir(&self.state, org).join("settings.json")) {
610 Ok(b) => serde_json::from_slice(&b)
611 .map_err(|e| Error::invalid(format!("workspace settings of {org}: {e}"))),
612 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Settings::default()),
613 Err(e) => Err(e.into()),
614 }
615 }
616
617 pub fn put_settings(&self, org: &OrgId, s: &Settings) -> Result<()> {
618 s.validate()?;
619 crate::app::write_atomic(
620 &dir(&self.state, org).join("settings.json"),
621 &serde_json::to_vec_pretty(s)?,
622 )
623 }
624
625 pub fn orgs(&self) -> Vec<OrgId> {
628 let mut out = Vec::new();
629 if dir(&self.state, &OrgId::default_org()).is_dir() {
630 out.push(OrgId::default_org());
631 }
632 if let Ok(rd) = std::fs::read_dir(self.state.join("orgs")) {
633 for e in rd.flatten() {
634 let Some(n) = e.file_name().to_str().map(String::from) else {
635 continue;
636 };
637 if let Ok(o) = OrgId::new(n) {
638 if !o.is_default() && dir(&self.state, &o).is_dir() {
639 out.push(o);
640 }
641 }
642 }
643 }
644 out.sort_by(|a, b| a.as_str().cmp(b.as_str()));
645 out
646 }
647}
648
649pub fn sh_quote(s: &str) -> String {
651 format!("'{}'", s.replace('\'', r"'\''"))
652}
653
654pub fn profile(url: Option<&str>, org: &OrgId, w: &Workspace) -> String {
657 let mut s = String::from(
658 "# Written by isb for this workspace (docs/concepts/workspaces.md); rewritten on every start.\n",
659 );
660 for (k, v) in &w.env {
661 s.push_str(&format!("export {k}={}\n", sh_quote(v)));
662 }
663 if let Some(u) = url {
664 s.push_str(&format!("export ISB_URL={}\n", sh_quote(u)));
665 }
666 s.push_str(&format!("export ISB_ORG={}\n", sh_quote(org.as_str())));
667 s.push_str(&format!("export ISB_WORKSPACE={}\n", sh_quote(&w.name)));
668 s.push_str(&format!(
669 "if [ -r {TOKEN_PATH} ]; then ISB_TOKEN=$(cat {TOKEN_PATH}); export ISB_TOKEN; fi\n"
670 ));
671 s
672}
673
674#[cfg(test)]
675mod tests {
676 use super::*;
677
678 fn labels(kv: &[(&str, &str)]) -> BTreeMap<String, String> {
679 kv.iter()
680 .map(|(k, v)| (k.to_string(), v.to_string()))
681 .collect()
682 }
683
684 #[test]
685 fn host_folder_homes() {
686 let org = OrgId::new("ocai").unwrap();
687 let root = Path::new("/srv/workspaces");
688 assert_eq!(
689 host_home(root, &org, DEFAULT_NAME),
690 Path::new("/srv/workspaces/ocai/home")
691 );
692 assert_eq!(
693 host_home(root, &org, "lab"),
694 Path::new("/srv/workspaces/ocai/lab/home")
695 );
696 }
697
698 #[test]
699 fn human_durations() {
700 assert_eq!(human(0), "0s");
701 assert_eq!(human(59), "59s");
702 assert_eq!(human(3600 + 120 + 5), "1h 2m");
703 assert_eq!(human(90061), "1d 1h");
704 }
705
706 #[test]
707 fn names_users_and_env() {
708 assert!(check_name("workspace").is_ok());
709 assert!(check_name("ws-2").is_ok());
710 for bad in ["", "2ws", "Ws", "ws_", "ws-", &"a".repeat(31)] {
711 assert!(check_name(bad).is_err(), "{bad}");
712 }
713 assert!(check_user("dev").is_ok());
714 assert!(check_user("Dev").is_err());
715 assert!(check_env_name("EDITOR").is_ok());
716 assert!(check_env_name("ISB_TOKEN").is_err());
717 assert!(check_env_name("1X").is_err());
718 }
719
720 #[test]
721 fn settings_default_and_bounds() {
722 let s = Settings::default();
723 assert_eq!(s.max_workspaces, 1);
724 assert_eq!(s.sandbox_expiry, "24h");
725 assert_eq!(s.sandbox_idle, "2h");
726 s.validate().unwrap();
727 let mut b = s.clone();
728 b.sandbox_expiry = "31d".into();
729 assert!(b.validate().is_err());
730 b.sandbox_expiry = "7d".into();
731 b.sandbox_idle = "none".into();
732 b.validate().unwrap();
733 b.max_workspaces = 0;
734 assert!(b.validate().is_err());
735 let old: Settings = serde_json::from_str(r#"{"max_workspaces": 2}"#).unwrap();
737 assert_eq!(old.sandbox_idle, "2h");
738 }
739
740 #[test]
741 fn deadlines_and_extension() {
742 let s = Settings::default();
743 let (exp, idle) = sandbox_deadlines(&s, None, None, 1000).unwrap();
744 assert_eq!(exp, 1000 + 86400);
745 assert_eq!(idle, Some(7200));
746 let (exp, idle) = sandbox_deadlines(&s, Some("1h"), Some("none"), 0).unwrap();
747 assert_eq!((exp, idle), (3600, None));
748 assert!(sandbox_deadlines(&s, Some("40d"), None, 0).is_err());
749 assert!(sandbox_deadlines(&s, None, Some("10s"), 0).is_err());
750 assert_eq!(
752 extended(Some(500), Duration::from_secs(100), 1000).unwrap(),
753 1100
754 );
755 assert_eq!(
756 extended(Some(2000), Duration::from_secs(100), 1000).unwrap(),
757 2100
758 );
759 assert!(extended(None, Duration::from_secs(31 * 86400), 0).is_err());
760 assert!(extended(None, Duration::ZERO, 0).is_err());
761 }
762
763 #[test]
764 fn the_reaper_takes_only_sandboxes_past_their_deadlines() {
765 let now = 10_000;
766 let exp = labels(&[("isb.expires_at", "9000")]);
767 assert!(reap_reason(&exp, now, now).unwrap().starts_with("expired"));
768 let live = labels(&[("isb.expires_at", "20000"), ("isb.idle_timeout", "600")]);
769 assert_eq!(reap_reason(&live, now, now - 100), None);
770 assert!(
771 reap_reason(&live, now, now - 600)
772 .unwrap()
773 .starts_with("idle")
774 );
775 for extra in ["isb.workspace", "isb.stack", "isb.build"] {
778 let mut l = exp.clone();
779 l.insert(extra.into(), "x".into());
780 assert_eq!(reap_reason(&l, now, 0), None, "{extra}");
781 }
782 assert_eq!(
783 reap_reason(&labels(&[("isb.owner", "mcp:a")]), now, 0),
784 None
785 );
786 assert_eq!(
787 reap_reason(&labels(&[("isb.expires_at", "soon")]), now, 0),
788 None
789 );
790 }
791
792 #[test]
793 fn the_store_round_trips() {
794 let d = tempfile::tempdir().unwrap();
795 let st = Store::new(d.path());
796 let org = OrgId::new("acme").unwrap();
797 assert!(st.list(&org).unwrap().is_empty());
798 assert_eq!(st.settings(&org).unwrap(), Settings::default());
799 let w = Workspace {
800 name: "workspace".into(),
801 id: "abc".into(),
802 image: "dev-base".into(),
803 user: "dev".into(),
804 cpus: Some(2),
805 memory: None,
806 root_size: None,
807 home_size: "10GiB".into(),
808 env: BTreeMap::new(),
809 secrets: vec![],
810 labels: BTreeMap::new(),
811 token_role: Role::Admin,
812 home_bind: None,
813 pool: None,
814 created_at: 1,
815 created_by: "a@x.io".into(),
816 updated_at: 1,
817 rebuilt_at: None,
818 token: None,
819 setup: None,
820 setup_state: None,
821 ports: vec![],
822 };
823 st.put(&org, &w).unwrap();
824 st.put_settings(&org, &Settings::default()).unwrap();
825 assert_eq!(st.list(&org).unwrap(), vec![w.clone()]);
826 assert_eq!(st.orgs(), vec![org.clone()]);
827 assert_eq!(w.home_volume(&org), "acme_workspace_home");
828 assert_eq!(w.home_dir(), "/home/dev");
829 st.delete(&org, "workspace").unwrap();
830 assert!(st.get(&org, "workspace").unwrap().is_none());
831 }
832
833 #[test]
834 fn the_profile_quotes_values_and_never_holds_the_token() {
835 let org = OrgId::new("acme").unwrap();
836 let mut w: Workspace = serde_json::from_value(serde_json::json!({
837 "name": "workspace", "id": "x", "image": "dev-base", "user": "dev",
838 "home_size": "1GiB", "token_role": "admin", "created_at": 0, "created_by": "a"
839 }))
840 .unwrap();
841 w.env.insert("GREETING".into(), "it's".into());
842 let p = profile(Some("http://10.0.0.1:8481"), &org, &w);
843 assert!(p.contains("export GREETING='it'\\''s'\n"));
844 assert!(p.contains("export ISB_URL='http://10.0.0.1:8481'\n"));
845 assert!(p.contains("export ISB_ORG='acme'\n"));
846 assert!(p.contains("ISB_TOKEN=$(cat /run/isb/token)"));
847 }
848
849 #[test]
850 fn the_setup_script_runs_once_per_build_and_again_on_request() {
851 use SetupStatus::*;
852 let next = |cur: Option<&SetupState>, ev| setup_next(true, cur, ev, 7).unwrap();
853 let p = next(None, SetupEvent::Built).unwrap();
855 assert_eq!((p.status, p.runs), (Pending, 0));
856 let r = next(Some(&p), SetupEvent::Started).unwrap();
857 assert_eq!((r.status, r.runs), (Running, 1));
858 let ok = next(Some(&r), SetupEvent::Finished(0)).unwrap();
859 assert_eq!((ok.status, ok.exit_code), (Succeeded, Some(0)));
860 let bad = next(Some(&r), SetupEvent::Finished(3)).unwrap();
861 assert_eq!((bad.status, bad.exit_code), (Failed, Some(3)));
862 let err = next(Some(&r), SetupEvent::Error("timed out".into())).unwrap();
863 assert_eq!(
864 (err.status, err.message.as_deref()),
865 (Failed, Some("timed out"))
866 );
867 let again = next(Some(&ok), SetupEvent::Requested).unwrap();
869 assert_eq!((again.status, again.runs), (Pending, 1));
870 assert_eq!(next(Some(&bad), SetupEvent::Built).unwrap().status, Pending);
871 assert!(setup_next(true, Some(&r), SetupEvent::Requested, 7).is_err());
873 assert!(setup_next(true, Some(&ok), SetupEvent::Started, 7).is_err());
874 assert!(setup_next(true, Some(&p), SetupEvent::Finished(0), 7).is_err());
875 let lost = next(Some(&r), SetupEvent::DaemonStarted).unwrap();
877 assert_eq!(lost.status, Failed);
878 assert!(lost.message.unwrap().contains("restarted"));
879 assert_eq!(next(Some(&ok), SetupEvent::DaemonStarted), Some(ok.clone()));
880 assert_eq!(
882 setup_next(false, Some(&p), SetupEvent::Built, 7).unwrap(),
883 None
884 );
885 assert!(setup_next(false, None, SetupEvent::Requested, 7).is_err());
886 let mut w: Workspace = serde_json::from_value(serde_json::json!({
888 "name": "workspace", "id": "x", "image": "dev-base", "user": "dev",
889 "home_size": "1GiB", "token_role": "admin", "created_at": 0, "created_by": "a",
890 "setup": "apt-get install -y htop", "setup_state": {"status": "pending", "at": 1}
891 }))
892 .unwrap();
893 assert!(setup_due(&w));
894 w.setup_state = Some(ok);
895 assert!(!setup_due(&w));
896 }
897}