Skip to main content

dvm_api/
sandbox.rs

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/// A sandbox's id: its run's, as its runner and its output know it.
10#[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    /// Its run's id.
26    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/// A sandbox: one run of its spec, recorded, started as it's made, and
38/// the replays of its recording. Its id is its run's.
39#[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    /// Its owner's GitHub login; none for one made before users signed in,
45    /// which only admins see.
46    pub owner: Option<String>,
47    pub spec: SandboxSpec,
48    /// Its run: queued as it's made, then started, paused, ended.
49    pub run: Run,
50    /// Its replays, oldest first.
51    pub replays: Vec<Run>,
52}
53
54impl Sandbox {
55    /// Whether `id` is its run or one of its replays.
56    pub fn holds(&self, id: RunId) -> bool {
57        self.member(id).is_some()
58    }
59
60    /// Its run, or its replay, of that id.
61    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    /// Whether its run may be replayed, and why not (`Run`'s rules): its
68    /// recording is whole if it exited; or, a sandbox that takes commands,
69    /// if it was stopped too, as its runner has dvm end it.
70    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    /// Whether a run of it is going, its own or a replay.
80    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    /// Takes its run or a replay as it is now, if it's of this sandbox and
87    /// no older than the copy it has: whether it's of this sandbox.
88    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/// A page of the sandboxes a user sees, `GET /api/sandboxes?before=`: the
115/// newest before the sandbox `before`, if given, newest first.
116#[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    /// Whether there are older ones than these.
122    pub older: bool,
123}
124
125impl SandboxesPage {
126    /// The most sandboxes a page holds.
127    pub const SANDBOXES: usize = 50;
128}
129
130/// An environment variable the program is given.
131#[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    /// Whether `name` is one a shell takes: `[A-Za-z_][A-Za-z0-9_]*`.
141    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/// A sandbox's spec as a person writes it, unchecked: what the panel's form
151/// holds, and what the controller takes to create a sandbox. Each field but
152/// the image may be left out, for `dvm run`'s defaults.
153#[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    /// The image's name, unless given (`ImageReference::sandbox_name`).
159    pub name: String,
160    pub image: String,
161    /// In place of the image's entrypoint.
162    pub entrypoint: Option<String>,
163    /// In place of the image's command, unless empty.
164    pub command: Vec<String>,
165    /// After the image's own.
166    pub environment: Vec<EnvironmentVariable>,
167    pub cpus: u32,
168    pub memory_mib: u32,
169    pub seed: u64,
170}
171
172impl Default for SandboxDraft {
173    /// What `dvm run` gives unless told: one vCPU, 1 GiB, seed 0.
174    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/// What a sandbox runs, checked: each value is one `dvm run` may be
189/// given as it is. Only a [`SandboxDraft`] that passes becomes one, deserialized
190/// too.
191#[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    /// The vCPUs a sandbox may have.
209    pub const CPUS: RangeInclusive<u32> = 1..=8;
210    /// The memory a sandbox may have, in MiB.
211    pub const MEMORY_MIB: RangeInclusive<u32> = 128..=16384;
212    /// The seeds a sandbox may have: those a JSON number carries exactly to
213    /// every client, a browser's too (2^53 − 1 at most), so a spec read
214    /// back runs as it was made.
215    pub const SEEDS: RangeInclusive<u64> = 0..=(1 << 53) - 1;
216    /// A name is a DNS label, so that it may name a host later.
217    const NAME_LENGTH: usize = 63;
218    /// dvm's memory over the VM's, which it backs only as the program
219    /// touches it: 14 MiB measured (`alpine:3.20 echo hi`, at 256 MiB to
220    /// 4 GiB of `--memory`), counted with room to spare.
221    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    /// What a run of the spec takes from its runner: a host CPU for each
256    /// vCPU, and the VM's memory with dvm's own.
257    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    /// Whether a sandbox of this spec takes commands (`SandboxCommand`): one
265    /// made with neither a command nor an entrypoint runs nothing until it's
266    /// sent its first, and ends as it's stopped.
267    /// One made with either runs it, and ends with it.
268    pub fn takes_commands(&self) -> bool {
269        self.entrypoint.is_none() && self.command.is_empty()
270    }
271
272    /// Lowercase letters, digits and inner `-`, as a DNS label.
273    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    /// Names a shell would take (`[A-Za-z_][A-Za-z0-9_]*`), each once.
292    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    /// A program's argument can't hold a NUL.
316    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/// Why a draft isn't a spec: the first field that's wrong, and how.
395#[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/// A field of a [`SandboxDraft`].
420#[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    /// A sandbox keeps the newer copy of its run and each replay, and
538    /// takes a new replay of its own, but no other sandbox's run.
539    #[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        // The queued copy, answered late, is older than the running one.
567        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    /// A stopped run's recording is cut short, but a sandbox's that takes
576    /// commands, which its runner has dvm end whole.
577    #[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}