1use std::collections::HashSet;
2use std::fmt;
3use std::ops::RangeInclusive;
4
5use serde::{Deserialize, Serialize};
6
7use crate::{Capacity, ImageReference, Run, RunId, RunState};
8
9#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
11#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
12#[cfg_attr(feature = "typescript", ts(export_to = "controller-api.ts"))]
13#[serde(transparent)]
14pub struct SandboxId(i64);
15
16impl SandboxId {
17 pub fn new(id: i64) -> Self {
18 Self(id)
19 }
20
21 pub fn get(self) -> i64 {
22 self.0
23 }
24
25 pub fn run(self) -> RunId {
27 RunId::new(self.0)
28 }
29}
30
31impl fmt::Display for SandboxId {
32 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
33 self.0.fmt(f)
34 }
35}
36
37#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
40#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
41#[cfg_attr(feature = "typescript", ts(export_to = "controller-api.ts"))]
42pub struct Sandbox {
43 pub id: SandboxId,
44 pub owner: Option<String>,
47 pub spec: SandboxSpec,
48 pub run: Run,
50 pub replays: Vec<Run>,
52}
53
54impl Sandbox {
55 pub fn holds(&self, id: RunId) -> bool {
57 self.member(id).is_some()
58 }
59
60 pub fn member(&self, id: RunId) -> Option<&Run> {
62 std::iter::once(&self.run)
63 .chain(&self.replays)
64 .find(|member| member.id == id)
65 }
66
67 pub fn replayable(&self) -> Result<(), String> {
71 let whole = match self.run.state {
72 RunState::Exited { .. } => true,
73 RunState::Stopped => self.spec.takes_commands(),
74 _ => false,
75 };
76 self.run.replayable_if_whole(whole)
77 }
78
79 pub fn is_going(&self) -> bool {
81 std::iter::once(&self.run)
82 .chain(&self.replays)
83 .any(|member| member.state.is_active())
84 }
85
86 pub fn take(&mut self, changed: &Run) -> bool {
89 if changed.sandbox() != self.id {
90 return false;
91 }
92 if changed.id == self.run.id {
93 if changed.supersedes(&self.run) {
94 self.run = changed.clone();
95 }
96 return true;
97 }
98 match self
99 .replays
100 .iter_mut()
101 .find(|replay| replay.id == changed.id)
102 {
103 Some(replay) if changed.supersedes(replay) => *replay = changed.clone(),
104 Some(_) => {}
105 None => {
106 self.replays.push(changed.clone());
107 self.replays.sort_by_key(|replay| replay.id.get());
108 }
109 }
110 true
111 }
112}
113
114#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
117#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
118#[cfg_attr(feature = "typescript", ts(export_to = "controller-api.ts"))]
119pub struct SandboxesPage {
120 pub sandboxes: Vec<Sandbox>,
121 pub older: bool,
123}
124
125impl SandboxesPage {
126 pub const SANDBOXES: usize = 50;
128}
129
130#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
132#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
133#[cfg_attr(feature = "typescript", ts(export_to = "controller-api.ts"))]
134pub struct EnvironmentVariable {
135 pub name: String,
136 pub value: String,
137}
138
139impl EnvironmentVariable {
140 pub fn is_name(name: &str) -> bool {
142 let mut chars = name.chars();
143 chars
144 .next()
145 .is_some_and(|c| c.is_ascii_alphabetic() || c == '_')
146 && chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
147 }
148}
149
150#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
154#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
155#[cfg_attr(feature = "typescript", ts(export_to = "controller-api.ts"))]
156#[serde(default)]
157pub struct SandboxDraft {
158 pub name: String,
160 pub image: String,
161 pub entrypoint: Option<String>,
163 pub command: Vec<String>,
165 pub environment: Vec<EnvironmentVariable>,
167 pub cpus: u32,
168 pub memory_mib: u32,
169 pub seed: u64,
170}
171
172impl Default for SandboxDraft {
173 fn default() -> Self {
175 Self {
176 name: String::new(),
177 image: String::new(),
178 entrypoint: None,
179 command: Vec::new(),
180 environment: Vec::new(),
181 cpus: 1,
182 memory_mib: 1024,
183 seed: 0,
184 }
185 }
186}
187
188#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
192#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
193#[cfg_attr(feature = "typescript", ts(export_to = "controller-api.ts"))]
194#[serde(try_from = "SandboxDraft", into = "SandboxDraft")]
195#[cfg_attr(feature = "typescript", ts(as = "SandboxDraft"))]
196pub struct SandboxSpec {
197 name: String,
198 image: ImageReference,
199 entrypoint: Option<String>,
200 command: Vec<String>,
201 environment: Vec<EnvironmentVariable>,
202 cpus: u32,
203 memory_mib: u32,
204 seed: u64,
205}
206
207impl SandboxSpec {
208 pub const CPUS: RangeInclusive<u32> = 1..=8;
210 pub const MEMORY_MIB: RangeInclusive<u32> = 128..=16384;
212 pub const SEEDS: RangeInclusive<u64> = 0..=(1 << 53) - 1;
216 const NAME_LENGTH: usize = 63;
218 const DVM_OWN_MEMORY_MIB: u64 = 32;
222
223 pub fn name(&self) -> &str {
224 &self.name
225 }
226
227 pub fn image(&self) -> &ImageReference {
228 &self.image
229 }
230
231 pub fn entrypoint(&self) -> Option<&str> {
232 self.entrypoint.as_deref()
233 }
234
235 pub fn command(&self) -> &[String] {
236 &self.command
237 }
238
239 pub fn environment(&self) -> &[EnvironmentVariable] {
240 &self.environment
241 }
242
243 pub fn cpus(&self) -> u32 {
244 self.cpus
245 }
246
247 pub fn memory_mib(&self) -> u32 {
248 self.memory_mib
249 }
250
251 pub fn seed(&self) -> u64 {
252 self.seed
253 }
254
255 pub fn footprint(&self) -> Capacity {
258 Capacity {
259 cpus: self.cpus,
260 memory_mib: u64::from(self.memory_mib) + Self::DVM_OWN_MEMORY_MIB,
261 }
262 }
263
264 pub fn takes_commands(&self) -> bool {
269 self.entrypoint.is_none() && self.command.is_empty()
270 }
271
272 fn check_name(name: &str) -> Result<(), SpecError> {
274 let valid = !name.is_empty()
275 && name.len() <= Self::NAME_LENGTH
276 && !name.starts_with('-')
277 && !name.ends_with('-')
278 && name
279 .chars()
280 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-');
281 if valid {
282 Ok(())
283 } else {
284 Err(SpecError::new(
285 SpecField::Name,
286 "up to 63 lowercase letters, digits and '-', not starting or ending with '-'",
287 ))
288 }
289 }
290
291 fn check_environment(environment: &[EnvironmentVariable]) -> Result<(), SpecError> {
293 let mut names = HashSet::new();
294 for variable in environment {
295 if !EnvironmentVariable::is_name(&variable.name) {
296 return Err(SpecError::new(
297 SpecField::Environment,
298 format!(
299 "{:?} isn't a variable name: letters, digits and '_', not starting with a digit",
300 variable.name
301 ),
302 ));
303 }
304 if !names.insert(variable.name.as_str()) {
305 return Err(SpecError::new(
306 SpecField::Environment,
307 format!("{} is given twice", variable.name),
308 ));
309 }
310 Self::check_argument(SpecField::Environment, &variable.value)?;
311 }
312 Ok(())
313 }
314
315 fn check_argument(field: SpecField, argument: &str) -> Result<(), SpecError> {
317 if argument.contains('\0') {
318 Err(SpecError::new(field, "can't contain a NUL character"))
319 } else {
320 Ok(())
321 }
322 }
323
324 fn check_range<T: PartialOrd + fmt::Display>(
325 field: SpecField,
326 value: T,
327 range: RangeInclusive<T>,
328 ) -> Result<(), SpecError> {
329 if range.contains(&value) {
330 Ok(())
331 } else {
332 Err(SpecError::new(
333 field,
334 format!("{value} is outside {}..={}", range.start(), range.end()),
335 ))
336 }
337 }
338}
339
340impl TryFrom<SandboxDraft> for SandboxSpec {
341 type Error = SpecError;
342
343 fn try_from(draft: SandboxDraft) -> Result<Self, Self::Error> {
344 if !draft.name.is_empty() {
345 Self::check_name(&draft.name)?;
346 }
347 let image = ImageReference::try_from(draft.image)
348 .map_err(|error| SpecError::new(SpecField::Image, error.to_string()))?;
349 let name = match draft.name {
350 name if name.is_empty() => image.sandbox_name(),
351 name => name,
352 };
353 if let Some(entrypoint) = &draft.entrypoint {
354 if entrypoint.is_empty() {
355 return Err(SpecError::new(SpecField::Entrypoint, "is empty"));
356 }
357 Self::check_argument(SpecField::Entrypoint, entrypoint)?;
358 }
359 for argument in &draft.command {
360 Self::check_argument(SpecField::Command, argument)?;
361 }
362 Self::check_environment(&draft.environment)?;
363 Self::check_range(SpecField::Cpus, draft.cpus, Self::CPUS)?;
364 Self::check_range(SpecField::Memory, draft.memory_mib, Self::MEMORY_MIB)?;
365 Self::check_range(SpecField::Seed, draft.seed, Self::SEEDS)?;
366 Ok(Self {
367 name,
368 image,
369 entrypoint: draft.entrypoint,
370 command: draft.command,
371 environment: draft.environment,
372 cpus: draft.cpus,
373 memory_mib: draft.memory_mib,
374 seed: draft.seed,
375 })
376 }
377}
378
379impl From<SandboxSpec> for SandboxDraft {
380 fn from(spec: SandboxSpec) -> Self {
381 Self {
382 name: spec.name,
383 image: spec.image.into(),
384 entrypoint: spec.entrypoint,
385 command: spec.command,
386 environment: spec.environment,
387 cpus: spec.cpus,
388 memory_mib: spec.memory_mib,
389 seed: spec.seed,
390 }
391 }
392}
393
394#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)]
396#[error("{field}: {problem}")]
397pub struct SpecError {
398 field: SpecField,
399 problem: String,
400}
401
402impl SpecError {
403 fn new(field: SpecField, problem: impl Into<String>) -> Self {
404 Self {
405 field,
406 problem: problem.into(),
407 }
408 }
409
410 pub fn field(&self) -> SpecField {
411 self.field
412 }
413
414 pub fn problem(&self) -> &str {
415 &self.problem
416 }
417}
418
419#[derive(Clone, Copy, Debug, PartialEq, Eq)]
421pub enum SpecField {
422 Name,
423 Image,
424 Entrypoint,
425 Command,
426 Environment,
427 Cpus,
428 Memory,
429 Seed,
430}
431
432impl fmt::Display for SpecField {
433 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
434 f.write_str(match self {
435 Self::Name => "name",
436 Self::Image => "image",
437 Self::Entrypoint => "entrypoint",
438 Self::Command => "command",
439 Self::Environment => "environment",
440 Self::Cpus => "cpus",
441 Self::Memory => "memory",
442 Self::Seed => "seed",
443 })
444 }
445}
446
447#[cfg(test)]
448mod tests {
449 use super::*;
450
451 fn draft() -> SandboxDraft {
452 SandboxDraft {
453 name: "hello".to_owned(),
454 image: "alpine:3.20".to_owned(),
455 command: vec!["echo".to_owned(), "hi".to_owned()],
456 environment: vec![EnvironmentVariable {
457 name: "RUST_LOG".to_owned(),
458 value: "debug".to_owned(),
459 }],
460 ..SandboxDraft::default()
461 }
462 }
463
464 fn refused_field(change: impl FnOnce(&mut SandboxDraft)) -> SpecField {
465 let mut draft = draft();
466 change(&mut draft);
467 SandboxSpec::try_from(draft).unwrap_err().field()
468 }
469
470 #[test]
471 fn a_draft_becomes_a_spec_and_back() {
472 let spec = SandboxSpec::try_from(draft()).unwrap();
473 assert_eq!(spec.image().as_str(), "alpine:3.20");
474 assert_eq!(SandboxDraft::from(spec), draft());
475 }
476
477 #[test]
478 fn a_draft_needs_only_its_image() {
479 let draft: SandboxDraft =
480 serde_json::from_str(r#"{"image":"ghcr.io/owner/my_app:1"}"#).unwrap();
481 let spec = SandboxSpec::try_from(draft).unwrap();
482 assert_eq!(
483 SandboxDraft::from(spec.clone()),
484 SandboxDraft {
485 name: "my-app".to_owned(),
486 image: "ghcr.io/owner/my_app:1".to_owned(),
487 ..SandboxDraft::default()
488 }
489 );
490 assert!(spec.takes_commands());
491 }
492
493 #[test]
494 fn each_field_is_checked() {
495 assert_eq!(refused_field(|d| d.name = "Hello".into()), SpecField::Name);
496 assert_eq!(refused_field(|d| d.name = "-a".into()), SpecField::Name);
497 assert_eq!(refused_field(|d| d.name = "a".repeat(64)), SpecField::Name);
498 assert_eq!(
499 refused_field(|d| d.image = "--volume=/:/h".into()),
500 SpecField::Image
501 );
502 assert_eq!(
503 refused_field(|d| d.entrypoint = Some(String::new())),
504 SpecField::Entrypoint
505 );
506 assert_eq!(
507 refused_field(|d| d.command.push("a\0b".into())),
508 SpecField::Command
509 );
510 assert_eq!(
511 refused_field(|d| d.environment[0].name = "1A".into()),
512 SpecField::Environment
513 );
514 assert_eq!(
515 refused_field(|d| d.environment.push(d.environment[0].clone())),
516 SpecField::Environment
517 );
518 assert_eq!(refused_field(|d| d.cpus = 0), SpecField::Cpus);
519 assert_eq!(refused_field(|d| d.memory_mib = 64), SpecField::Memory);
520 assert_eq!(refused_field(|d| d.seed = 1 << 53), SpecField::Seed);
521 }
522
523 #[test]
524 fn deserializing_checks() {
525 let json = serde_json::to_string(&draft()).unwrap();
526 let spec: SandboxSpec = serde_json::from_str(&json).unwrap();
527 assert_eq!(spec, SandboxSpec::try_from(draft()).unwrap());
528
529 let invalid = serde_json::to_string(&SandboxDraft {
530 cpus: 64,
531 ..draft()
532 })
533 .unwrap();
534 assert!(serde_json::from_str::<SandboxSpec>(&invalid).is_err());
535 }
536
537 #[test]
540 fn a_sandbox_keeps_the_newer_copy_of_each_of_its_runs() {
541 use crate::Timestamp;
542 let run = |id: i64, replay_of: Option<i64>, state: RunState, version: u64| Run {
543 id: RunId::new(id),
544 replay_of: replay_of.map(RunId::new),
545 state,
546 runner: None,
547 created_at: Timestamp::from_unix_millis(0),
548 started_at: None,
549 ended_at: None,
550 log_bytes: None,
551 log_outdated: false,
552 verdict: None,
553 version,
554 };
555 let exited = RunState::Exited { code: 0 };
556 let mut sandbox = Sandbox {
557 id: SandboxId::new(1),
558 owner: None,
559 spec: SandboxSpec::try_from(draft()).unwrap(),
560 run: run(1, None, exited.clone(), 3),
561 replays: vec![],
562 };
563 let running = run(2, Some(1), RunState::Running, 2);
564 assert!(sandbox.take(&run(2, Some(1), RunState::Queued, 0)));
565 assert!(sandbox.take(&running));
566 assert!(sandbox.take(&run(2, Some(1), RunState::Queued, 1)));
568 assert_eq!(sandbox.replays, [running]);
569 assert!(sandbox.is_going());
570 assert!(!sandbox.take(&run(3, None, RunState::Queued, 0)));
571 assert!(sandbox.holds(RunId::new(2)) && !sandbox.holds(RunId::new(3)));
572 assert_eq!(run(2, Some(1), exited, 0).sandbox(), sandbox.id);
573 }
574
575 #[test]
578 fn a_stopped_sandbox_that_takes_commands_replays() {
579 use crate::Timestamp;
580 let stopped = |draft: SandboxDraft| Sandbox {
581 id: SandboxId::new(1),
582 owner: None,
583 spec: SandboxSpec::try_from(draft).unwrap(),
584 run: Run {
585 id: RunId::new(1),
586 replay_of: None,
587 state: RunState::Stopped,
588 runner: Some("test".to_owned()),
589 created_at: Timestamp::from_unix_millis(0),
590 started_at: None,
591 ended_at: None,
592 log_bytes: Some(1),
593 log_outdated: false,
594 verdict: None,
595 version: 0,
596 },
597 replays: vec![],
598 };
599 assert!(
600 stopped(draft())
601 .replayable()
602 .unwrap_err()
603 .contains("cut short")
604 );
605 let taking = stopped(SandboxDraft {
606 command: Vec::new(),
607 ..draft()
608 });
609 assert_eq!(taking.replayable(), Ok(()));
610 }
611}