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 = "default_secret_refresh")]
360 pub secret_refresh: String,
361 #[serde(default, skip_serializing_if = "Option::is_none")]
364 pub home_kind: Option<String>,
365 #[serde(default, skip_serializing_if = "Option::is_none")]
368 pub home_pool: Option<String>,
369}
370
371fn one() -> u32 {
372 1
373}
374
375fn default_expiry() -> String {
376 "24h".into()
377}
378
379fn default_idle() -> String {
380 "2h".into()
381}
382
383fn default_secret_refresh() -> String {
384 "1h".into()
385}
386
387impl Default for Settings {
388 fn default() -> Self {
389 Settings {
390 max_workspaces: one(),
391 sandbox_expiry: default_expiry(),
392 sandbox_idle: default_idle(),
393 secret_refresh: default_secret_refresh(),
394 home_kind: None,
395 home_pool: None,
396 }
397 }
398}
399
400impl Settings {
401 pub fn validate(&self) -> Result<()> {
403 if self.max_workspaces == 0 || self.max_workspaces > 100 {
404 return Err(Error::invalid("max_workspaces: 1 to 100"));
405 }
406 lifetime(&self.sandbox_expiry)?;
407 idle(&self.sandbox_idle)?;
408 secret_refresh(&self.secret_refresh)?;
409 Ok(())
410 }
411}
412
413pub fn lifetime(s: &str) -> Result<Duration> {
415 let d = crate::flex::parse_duration(s.trim())
416 .map_err(|e| Error::invalid(format!("expiry {s:?}: {e}")))?;
417 if d.is_zero() || d > MAX_SANDBOX_LIFETIME {
418 return Err(Error::invalid(format!(
419 "expiry {s:?}: between a second and 30 days"
420 )));
421 }
422 Ok(d)
423}
424
425pub fn secret_refresh(s: &str) -> Result<Duration> {
427 let d = crate::flex::parse_duration(s.trim())
428 .map_err(|e| Error::invalid(format!("secret_refresh {s:?}: {e}")))?;
429 if d < Duration::from_secs(10) {
430 return Err(Error::invalid(format!(
431 "secret_refresh {s:?}: at least 10s"
432 )));
433 }
434 Ok(d)
435}
436
437pub fn idle(s: &str) -> Result<Option<Duration>> {
439 let s = s.trim();
440 if matches!(s, "none" | "never" | "off") {
441 return Ok(None);
442 }
443 let d = crate::flex::parse_duration(s)
444 .map_err(|e| Error::invalid(format!("idle timeout {s:?}: {e}")))?;
445 if d < Duration::from_secs(60) || d > MAX_SANDBOX_LIFETIME {
446 return Err(Error::invalid(format!(
447 "idle timeout {s:?}: between a minute and 30 days, or none"
448 )));
449 }
450 Ok(Some(d))
451}
452
453pub fn sandbox_deadlines(
456 settings: &Settings,
457 expires: Option<&str>,
458 idle_timeout: Option<&str>,
459 now: u64,
460) -> Result<(u64, Option<u64>)> {
461 let life = lifetime(expires.unwrap_or(&settings.sandbox_expiry))?;
462 let idle = idle(idle_timeout.unwrap_or(&settings.sandbox_idle))?;
463 Ok((now + life.as_secs(), idle.map(|d| d.as_secs())))
464}
465
466pub fn extended(current: Option<u64>, by: Duration, now: u64) -> Result<u64> {
469 if by.is_zero() {
470 return Err(Error::invalid("extend by a positive duration"));
471 }
472 let from = current.unwrap_or(now).max(now);
473 let want = from.saturating_add(by.as_secs());
474 let cap = now + MAX_SANDBOX_LIFETIME.as_secs();
475 if want > cap {
476 return Err(Error::invalid(format!(
477 "a sandbox lives at most 30 days from now; the most it can be extended by is {}",
478 human(cap.saturating_sub(from))
479 )));
480 }
481 Ok(want)
482}
483
484pub fn human(secs: u64) -> String {
486 let units = [(86400, "d"), (3600, "h"), (60, "m"), (1, "s")];
487 let mut parts = Vec::new();
488 let mut rest = secs;
489 for (n, u) in units {
490 if rest >= n {
491 parts.push(format!("{}{u}", rest / n));
492 rest %= n;
493 }
494 if parts.len() == 2 {
495 break;
496 }
497 }
498 if parts.is_empty() {
499 "0s".into()
500 } else {
501 parts.join(" ")
502 }
503}
504
505pub fn kind_of(labels: &BTreeMap<String, String>) -> &'static str {
508 if labels.contains_key("isb.workspace") {
509 "workspace"
510 } else if labels.contains_key("isb.stack") {
511 "replica"
512 } else if labels.contains_key("isb.build") {
513 "build"
514 } else {
515 "sandbox"
516 }
517}
518
519pub fn reap_reason(
524 labels: &BTreeMap<String, String>,
525 now: u64,
526 last_active: u64,
527) -> Option<String> {
528 if kind_of(labels) != "sandbox" {
529 return None;
530 }
531 let num = |k: &str| labels.get(k).and_then(|v| v.trim().parse::<u64>().ok());
532 if let Some(at) = num("isb.expires_at") {
533 if now >= at {
534 return Some(format!("expired {} ago", human(now - at)));
535 }
536 }
537 if let Some(idle) = num("isb.idle_timeout").filter(|s| *s > 0) {
538 if now.saturating_sub(last_active) >= idle {
539 return Some(format!(
540 "idle for {}",
541 human(now.saturating_sub(last_active))
542 ));
543 }
544 }
545 None
546}
547
548pub fn dir(state: &Path, org: &OrgId) -> PathBuf {
550 crate::app::org_root(state, org).join("workspaces")
551}
552
553#[derive(Debug, Clone)]
555pub struct Store {
556 state: PathBuf,
557}
558
559impl Store {
560 pub fn new(state: &Path) -> Store {
561 Store {
562 state: state.to_path_buf(),
563 }
564 }
565
566 fn path(&self, org: &OrgId, name: &str) -> PathBuf {
567 dir(&self.state, org).join(format!("{name}.json"))
568 }
569
570 pub fn token_path(&self, org: &OrgId, name: &str) -> PathBuf {
571 dir(&self.state, org).join(format!("{name}.token.age"))
572 }
573
574 pub fn list(&self, org: &OrgId) -> Result<Vec<Workspace>> {
575 let d = dir(&self.state, org);
576 let mut out = Vec::new();
577 let rd = match std::fs::read_dir(&d) {
578 Ok(rd) => rd,
579 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(out),
580 Err(e) => return Err(e.into()),
581 };
582 for e in rd.flatten() {
583 let p = e.path();
584 let Some(stem) = p
585 .file_name()
586 .and_then(|n| n.to_str())
587 .and_then(|n| n.strip_suffix(".json"))
588 else {
589 continue;
590 };
591 if stem == "settings" || check_name(stem).is_err() {
592 continue;
593 }
594 match self.get(org, stem) {
595 Ok(Some(w)) => out.push(w),
596 Ok(None) => {}
597 Err(e) => eprintln!("isb serve: workspace {}: {e}", p.display()),
598 }
599 }
600 out.sort_by(|a, b| a.name.cmp(&b.name));
601 Ok(out)
602 }
603
604 pub fn get(&self, org: &OrgId, name: &str) -> Result<Option<Workspace>> {
605 check_name(name)?;
606 match std::fs::read(self.path(org, name)) {
607 Ok(b) => Ok(Some(serde_json::from_slice(&b).map_err(|e| {
608 Error::invalid(format!("workspace {name} in {org}: {e}"))
609 })?)),
610 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
611 Err(e) => Err(e.into()),
612 }
613 }
614
615 pub fn put(&self, org: &OrgId, w: &Workspace) -> Result<()> {
616 check_name(&w.name)?;
617 crate::app::write_atomic(&self.path(org, &w.name), &serde_json::to_vec_pretty(w)?)
618 }
619
620 pub fn delete(&self, org: &OrgId, name: &str) -> Result<()> {
621 for p in [self.path(org, name), self.token_path(org, name)] {
622 match std::fs::remove_file(&p) {
623 Ok(()) => {}
624 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
625 Err(e) => return Err(e.into()),
626 }
627 }
628 Ok(())
629 }
630
631 pub fn settings(&self, org: &OrgId) -> Result<Settings> {
632 match std::fs::read(dir(&self.state, org).join("settings.json")) {
633 Ok(b) => serde_json::from_slice(&b)
634 .map_err(|e| Error::invalid(format!("workspace settings of {org}: {e}"))),
635 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Settings::default()),
636 Err(e) => Err(e.into()),
637 }
638 }
639
640 pub fn put_settings(&self, org: &OrgId, s: &Settings) -> Result<()> {
641 s.validate()?;
642 crate::app::write_atomic(
643 &dir(&self.state, org).join("settings.json"),
644 &serde_json::to_vec_pretty(s)?,
645 )
646 }
647
648 pub fn orgs(&self) -> Vec<OrgId> {
651 let mut out = Vec::new();
652 if dir(&self.state, &OrgId::default_org()).is_dir() {
653 out.push(OrgId::default_org());
654 }
655 if let Ok(rd) = std::fs::read_dir(self.state.join("orgs")) {
656 for e in rd.flatten() {
657 let Some(n) = e.file_name().to_str().map(String::from) else {
658 continue;
659 };
660 if let Ok(o) = OrgId::new(n) {
661 if !o.is_default() && dir(&self.state, &o).is_dir() {
662 out.push(o);
663 }
664 }
665 }
666 }
667 out.sort_by(|a, b| a.as_str().cmp(b.as_str()));
668 out
669 }
670}
671
672pub fn sh_quote(s: &str) -> String {
674 format!("'{}'", s.replace('\'', r"'\''"))
675}
676
677pub fn profile(url: Option<&str>, org: &OrgId, w: &Workspace) -> String {
680 let mut s = String::from(
681 "# Written by isb for this workspace (docs/concepts/workspaces.md); rewritten on every start.\n",
682 );
683 for (k, v) in &w.env {
684 s.push_str(&format!("export {k}={}\n", sh_quote(v)));
685 }
686 if let Some(u) = url {
687 s.push_str(&format!("export ISB_URL={}\n", sh_quote(u)));
688 }
689 s.push_str(&format!("export ISB_ORG={}\n", sh_quote(org.as_str())));
690 s.push_str(&format!("export ISB_WORKSPACE={}\n", sh_quote(&w.name)));
691 s.push_str(&format!(
692 "if [ -r {TOKEN_PATH} ]; then ISB_TOKEN=$(cat {TOKEN_PATH}); export ISB_TOKEN; fi\n"
693 ));
694 s
695}
696
697#[cfg(test)]
698mod tests {
699 use super::*;
700
701 fn labels(kv: &[(&str, &str)]) -> BTreeMap<String, String> {
702 kv.iter()
703 .map(|(k, v)| (k.to_string(), v.to_string()))
704 .collect()
705 }
706
707 #[test]
708 fn host_folder_homes() {
709 let org = OrgId::new("ocai").unwrap();
710 let root = Path::new("/srv/workspaces");
711 assert_eq!(
712 host_home(root, &org, DEFAULT_NAME),
713 Path::new("/srv/workspaces/ocai/home")
714 );
715 assert_eq!(
716 host_home(root, &org, "lab"),
717 Path::new("/srv/workspaces/ocai/lab/home")
718 );
719 }
720
721 #[test]
722 fn human_durations() {
723 assert_eq!(human(0), "0s");
724 assert_eq!(human(59), "59s");
725 assert_eq!(human(3600 + 120 + 5), "1h 2m");
726 assert_eq!(human(90061), "1d 1h");
727 }
728
729 #[test]
730 fn names_users_and_env() {
731 assert!(check_name("workspace").is_ok());
732 assert!(check_name("ws-2").is_ok());
733 for bad in ["", "2ws", "Ws", "ws_", "ws-", &"a".repeat(31)] {
734 assert!(check_name(bad).is_err(), "{bad}");
735 }
736 assert!(check_user("dev").is_ok());
737 assert!(check_user("Dev").is_err());
738 assert!(check_env_name("EDITOR").is_ok());
739 assert!(check_env_name("ISB_TOKEN").is_err());
740 assert!(check_env_name("1X").is_err());
741 }
742
743 #[test]
744 fn settings_default_and_bounds() {
745 let s = Settings::default();
746 assert_eq!(s.max_workspaces, 1);
747 assert_eq!(s.sandbox_expiry, "24h");
748 assert_eq!(s.sandbox_idle, "2h");
749 s.validate().unwrap();
750 let mut b = s.clone();
751 b.sandbox_expiry = "31d".into();
752 assert!(b.validate().is_err());
753 b.sandbox_expiry = "7d".into();
754 b.sandbox_idle = "none".into();
755 b.validate().unwrap();
756 b.max_workspaces = 0;
757 assert!(b.validate().is_err());
758 let old: Settings = serde_json::from_str(r#"{"max_workspaces": 2}"#).unwrap();
760 assert_eq!(old.sandbox_idle, "2h");
761 }
762
763 #[test]
764 fn deadlines_and_extension() {
765 let s = Settings::default();
766 let (exp, idle) = sandbox_deadlines(&s, None, None, 1000).unwrap();
767 assert_eq!(exp, 1000 + 86400);
768 assert_eq!(idle, Some(7200));
769 let (exp, idle) = sandbox_deadlines(&s, Some("1h"), Some("none"), 0).unwrap();
770 assert_eq!((exp, idle), (3600, None));
771 assert!(sandbox_deadlines(&s, Some("40d"), None, 0).is_err());
772 assert!(sandbox_deadlines(&s, None, Some("10s"), 0).is_err());
773 assert_eq!(
775 extended(Some(500), Duration::from_secs(100), 1000).unwrap(),
776 1100
777 );
778 assert_eq!(
779 extended(Some(2000), Duration::from_secs(100), 1000).unwrap(),
780 2100
781 );
782 assert!(extended(None, Duration::from_secs(31 * 86400), 0).is_err());
783 assert!(extended(None, Duration::ZERO, 0).is_err());
784 }
785
786 #[test]
787 fn the_reaper_takes_only_sandboxes_past_their_deadlines() {
788 let now = 10_000;
789 let exp = labels(&[("isb.expires_at", "9000")]);
790 assert!(reap_reason(&exp, now, now).unwrap().starts_with("expired"));
791 let live = labels(&[("isb.expires_at", "20000"), ("isb.idle_timeout", "600")]);
792 assert_eq!(reap_reason(&live, now, now - 100), None);
793 assert!(
794 reap_reason(&live, now, now - 600)
795 .unwrap()
796 .starts_with("idle")
797 );
798 for extra in ["isb.workspace", "isb.stack", "isb.build"] {
801 let mut l = exp.clone();
802 l.insert(extra.into(), "x".into());
803 assert_eq!(reap_reason(&l, now, 0), None, "{extra}");
804 }
805 assert_eq!(
806 reap_reason(&labels(&[("isb.owner", "mcp:a")]), now, 0),
807 None
808 );
809 assert_eq!(
810 reap_reason(&labels(&[("isb.expires_at", "soon")]), now, 0),
811 None
812 );
813 }
814
815 #[test]
816 fn the_store_round_trips() {
817 let d = tempfile::tempdir().unwrap();
818 let st = Store::new(d.path());
819 let org = OrgId::new("acme").unwrap();
820 assert!(st.list(&org).unwrap().is_empty());
821 assert_eq!(st.settings(&org).unwrap(), Settings::default());
822 let w = Workspace {
823 name: "workspace".into(),
824 id: "abc".into(),
825 image: "dev-base".into(),
826 user: "dev".into(),
827 cpus: Some(2),
828 memory: None,
829 root_size: None,
830 home_size: "10GiB".into(),
831 env: BTreeMap::new(),
832 secrets: vec![],
833 labels: BTreeMap::new(),
834 token_role: Role::Admin,
835 home_bind: None,
836 pool: None,
837 created_at: 1,
838 created_by: "a@x.io".into(),
839 updated_at: 1,
840 rebuilt_at: None,
841 token: None,
842 setup: None,
843 setup_state: None,
844 ports: vec![],
845 };
846 st.put(&org, &w).unwrap();
847 st.put_settings(&org, &Settings::default()).unwrap();
848 assert_eq!(st.list(&org).unwrap(), vec![w.clone()]);
849 assert_eq!(st.orgs(), vec![org.clone()]);
850 assert_eq!(w.home_volume(&org), "acme_workspace_home");
851 assert_eq!(w.home_dir(), "/home/dev");
852 st.delete(&org, "workspace").unwrap();
853 assert!(st.get(&org, "workspace").unwrap().is_none());
854 }
855
856 #[test]
857 fn the_profile_quotes_values_and_never_holds_the_token() {
858 let org = OrgId::new("acme").unwrap();
859 let mut w: Workspace = serde_json::from_value(serde_json::json!({
860 "name": "workspace", "id": "x", "image": "dev-base", "user": "dev",
861 "home_size": "1GiB", "token_role": "admin", "created_at": 0, "created_by": "a"
862 }))
863 .unwrap();
864 w.env.insert("GREETING".into(), "it's".into());
865 let p = profile(Some("http://10.0.0.1:8481"), &org, &w);
866 assert!(p.contains("export GREETING='it'\\''s'\n"));
867 assert!(p.contains("export ISB_URL='http://10.0.0.1:8481'\n"));
868 assert!(p.contains("export ISB_ORG='acme'\n"));
869 assert!(p.contains("ISB_TOKEN=$(cat /run/isb/token)"));
870 }
871
872 #[test]
873 fn the_setup_script_runs_once_per_build_and_again_on_request() {
874 use SetupStatus::*;
875 let next = |cur: Option<&SetupState>, ev| setup_next(true, cur, ev, 7).unwrap();
876 let p = next(None, SetupEvent::Built).unwrap();
878 assert_eq!((p.status, p.runs), (Pending, 0));
879 let r = next(Some(&p), SetupEvent::Started).unwrap();
880 assert_eq!((r.status, r.runs), (Running, 1));
881 let ok = next(Some(&r), SetupEvent::Finished(0)).unwrap();
882 assert_eq!((ok.status, ok.exit_code), (Succeeded, Some(0)));
883 let bad = next(Some(&r), SetupEvent::Finished(3)).unwrap();
884 assert_eq!((bad.status, bad.exit_code), (Failed, Some(3)));
885 let err = next(Some(&r), SetupEvent::Error("timed out".into())).unwrap();
886 assert_eq!(
887 (err.status, err.message.as_deref()),
888 (Failed, Some("timed out"))
889 );
890 let again = next(Some(&ok), SetupEvent::Requested).unwrap();
892 assert_eq!((again.status, again.runs), (Pending, 1));
893 assert_eq!(next(Some(&bad), SetupEvent::Built).unwrap().status, Pending);
894 assert!(setup_next(true, Some(&r), SetupEvent::Requested, 7).is_err());
896 assert!(setup_next(true, Some(&ok), SetupEvent::Started, 7).is_err());
897 assert!(setup_next(true, Some(&p), SetupEvent::Finished(0), 7).is_err());
898 let lost = next(Some(&r), SetupEvent::DaemonStarted).unwrap();
900 assert_eq!(lost.status, Failed);
901 assert!(lost.message.unwrap().contains("restarted"));
902 assert_eq!(next(Some(&ok), SetupEvent::DaemonStarted), Some(ok.clone()));
903 assert_eq!(
905 setup_next(false, Some(&p), SetupEvent::Built, 7).unwrap(),
906 None
907 );
908 assert!(setup_next(false, None, SetupEvent::Requested, 7).is_err());
909 let mut w: Workspace = serde_json::from_value(serde_json::json!({
911 "name": "workspace", "id": "x", "image": "dev-base", "user": "dev",
912 "home_size": "1GiB", "token_role": "admin", "created_at": 0, "created_by": "a",
913 "setup": "apt-get install -y htop", "setup_state": {"status": "pending", "at": 1}
914 }))
915 .unwrap();
916 assert!(setup_due(&w));
917 w.setup_state = Some(ok);
918 assert!(!setup_due(&w));
919 }
920}