Skip to main content

isb_apps/build/
workspace_image.rs

1//! Workspace images from recipe scripts (docs/guides/workspace-images.md).
2//!
3//! A recipe is a shell script run as root in a throwaway container made
4//! from a base image (`images:ubuntu/24.04` unless told otherwise). The
5//! container lives in the `isb-system` project, never in an org, on the
6//! host's default bridge; when the recipe succeeds it is stopped and
7//! published as a local image under the asked alias, then deleted. However
8//! the build ends, the container goes, and a failed build publishes
9//! nothing.
10//!
11//! The images isb builds carry `isb.workspace-image=1` among their
12//! properties, with the recipe's SHA-256, the base, who built it and when.
13//! Only such images can be replaced or removed through isb: an alias that
14//! names any other image (`dev-base`, say) is refused. Building again with
15//! the same recipe and base is a no-op unless forced.
16
17use std::collections::BTreeSet;
18use std::sync::Mutex;
19use std::time::{Duration, Instant};
20
21use serde_json::{Value, json};
22
23use super::{Remove, owned, ready, reap_stale, remaining, stream_lines, uplink_network};
24use crate::client::{Client, encode_segment};
25use crate::error::{Error, Result};
26use crate::exec::ExecOptions;
27use crate::sandbox::Sandbox;
28
29/// isb's default workspace recipe.
30pub const DEFAULT_RECIPE: &str = include_str!("workspace-image.sh");
31/// The alias the default recipe is published under.
32pub const DEFAULT_NAME: &str = "isb-workspace";
33/// The base a recipe runs on unless told otherwise.
34pub const DEFAULT_BASE: &str = "images:ubuntu/24.04";
35/// How long a recipe may run, by default and at most.
36pub const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30 * 60);
37pub const MAX_TIMEOUT: Duration = Duration::from_secs(2 * 3600);
38/// The largest recipe accepted.
39pub const MAX_RECIPE: usize = 256 * 1024;
40
41/// Image properties isb sets on the images it builds.
42pub const PROP_MARK: &str = "isb.workspace-image";
43pub const PROP_RECIPE: &str = "isb.recipe-sha256";
44pub const PROP_BASE: &str = "isb.base";
45pub const PROP_BUILT_BY: &str = "isb.built-by";
46pub const PROP_BUILT_AT: &str = "isb.built-at";
47
48/// Where the recipe is pushed in the build container.
49const RECIPE_PATH: &str = "/root/isb-recipe.sh";
50
51/// What to build.
52#[derive(Debug, Clone)]
53pub struct ImageBuild {
54    /// The local image alias to publish.
55    pub name: String,
56    pub recipe: String,
57    pub base: String,
58    pub description: Option<String>,
59    pub timeout: Duration,
60    /// Build even when the image is up to date.
61    pub force: bool,
62    pub built_by: String,
63}
64
65impl ImageBuild {
66    /// The default recipe under its own name.
67    pub fn default_recipe(built_by: &str) -> ImageBuild {
68        ImageBuild {
69            name: DEFAULT_NAME.into(),
70            recipe: DEFAULT_RECIPE.into(),
71            base: DEFAULT_BASE.into(),
72            description: None,
73            timeout: DEFAULT_TIMEOUT,
74            force: false,
75            built_by: built_by.into(),
76        }
77    }
78
79    pub fn recipe_sha256(&self) -> String {
80        recipe_sha256(&self.recipe)
81    }
82
83    /// The description the image gets: the one asked for, else one naming
84    /// the recipe.
85    pub fn description(&self) -> String {
86        match &self.description {
87            Some(d) if !d.trim().is_empty() => d.trim().to_string(),
88            _ if self.recipe == DEFAULT_RECIPE => format!(
89                "isb workspace: Ubuntu 24.04, dev (uid 1000), mise (node, bun, uv), Claude Code, omp, herdr (built {})",
90                today()
91            ),
92            _ => format!(
93                "workspace image from a recipe on {} (built {})",
94                self.base,
95                today()
96            ),
97        }
98    }
99}
100
101pub fn recipe_sha256(recipe: &str) -> String {
102    let d = crate::registry::oci::digest_of(recipe.as_bytes());
103    d.trim_start_matches("sha256:").to_string()
104}
105
106fn today() -> String {
107    let t = crate::stack::now_secs() as i64;
108    crate::cron::rfc3339(t)[..10].to_string()
109}
110
111/// An image alias isb may publish: a lower-case name of letters, digits,
112/// `.`, `_` and `-`, at most 63 long, not one of incus' remote forms.
113pub fn check_name(name: &str) -> Result<()> {
114    let ok = !name.is_empty()
115        && name.len() <= 63
116        && name
117            .bytes()
118            .next()
119            .is_some_and(|b| b.is_ascii_lowercase() || b.is_ascii_digit())
120        && name
121            .bytes()
122            .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b"._-".contains(&b));
123    if ok {
124        Ok(())
125    } else {
126        Err(Error::invalid(format!(
127            "image name {name:?}: lower-case letters, digits, '.', '_' and '-', starting with a letter or digit, at most 63"
128        )))
129    }
130}
131
132/// Check a build request before anything is made.
133pub fn check(b: &ImageBuild) -> Result<()> {
134    check_name(&b.name)?;
135    if b.recipe.trim().is_empty() {
136        return Err(Error::invalid("the recipe is empty"));
137    }
138    if b.recipe.len() > MAX_RECIPE {
139        return Err(Error::invalid(format!(
140            "the recipe is {} bytes; at most {MAX_RECIPE}",
141            b.recipe.len()
142        )));
143    }
144    if b.base.trim().is_empty() {
145        return Err(Error::invalid("base cannot be empty"));
146    }
147    crate::plan::ImageSource::parse(&b.base)?;
148    if b.timeout.is_zero() || b.timeout > MAX_TIMEOUT {
149        return Err(Error::invalid(format!(
150            "timeout: more than 0 and at most {} minutes",
151            MAX_TIMEOUT.as_secs() / 60
152        )));
153    }
154    Ok(())
155}
156
157/// An image an alias names now, as far as a build cares.
158#[derive(Debug, Clone, PartialEq, Eq)]
159pub struct Existing {
160    pub fingerprint: String,
161    /// Built by isb from a recipe (`isb.workspace-image`).
162    pub ours: bool,
163    pub recipe_sha256: Option<String>,
164    pub base: Option<String>,
165    /// Every alias of the image (the one asked about included).
166    pub aliases: Vec<String>,
167}
168
169impl Existing {
170    pub fn from_image(v: &Value) -> Existing {
171        let p = &v["properties"];
172        let prop = |k: &str| p[k].as_str().map(str::to_string);
173        Existing {
174            fingerprint: v["fingerprint"].as_str().unwrap_or_default().to_string(),
175            ours: p[PROP_MARK].as_str() == Some("1"),
176            recipe_sha256: prop(PROP_RECIPE),
177            base: prop(PROP_BASE),
178            aliases: v["aliases"]
179                .as_array()
180                .into_iter()
181                .flatten()
182                .filter_map(|a| a["name"].as_str().map(str::to_string))
183                .collect(),
184        }
185    }
186}
187
188/// What a build does about the alias it was asked for.
189#[derive(Debug, Clone, PartialEq, Eq)]
190pub enum Plan {
191    /// Nothing there yet: build and add the alias.
192    Create,
193    /// isb's image from the same recipe and base: nothing to do.
194    UpToDate { fingerprint: String },
195    /// isb's image from another recipe or base (or forced): build, move the
196    /// alias, and delete the old image if nothing else names it.
197    Replace { old: String },
198}
199
200/// Decide what a build does, refusing an alias isb did not make.
201pub fn plan(existing: Option<&Existing>, b: &ImageBuild) -> Result<Plan> {
202    let Some(e) = existing else {
203        return Ok(Plan::Create);
204    };
205    if !e.ours {
206        return Err(Error::AlreadyExists(format!(
207            "image {} exists and was not built by isb from a recipe; pick another name (isb replaces only the images it built)",
208            b.name
209        )));
210    }
211    let same = e.recipe_sha256.as_deref() == Some(b.recipe_sha256().as_str())
212        && e.base.as_deref() == Some(b.base.as_str());
213    if same && !b.force {
214        return Ok(Plan::UpToDate {
215            fingerprint: e.fingerprint.clone(),
216        });
217    }
218    Ok(Plan::Replace {
219        old: e.fingerprint.clone(),
220    })
221}
222
223/// Whether `isb workspace image rm` may remove this image: only isb's.
224pub fn removable(name: &str, e: &Existing) -> Result<()> {
225    if e.ours {
226        Ok(())
227    } else {
228        Err(Error::Forbidden(format!(
229            "image {name} was not built by isb from a recipe; isb removes only the images it built (use incus for others)"
230        )))
231    }
232}
233
234/// How the recipe runs: as its own program when it starts with `#!`, else
235/// with /bin/sh.
236pub fn recipe_argv(recipe: &str) -> Vec<&'static str> {
237    if recipe.starts_with("#!") {
238        vec![RECIPE_PATH]
239    } else {
240        vec!["/bin/sh", RECIPE_PATH]
241    }
242}
243
244/// The environment the recipe runs with.
245pub fn recipe_env() -> [(&'static str, &'static str); 4] {
246    [
247        (
248            "PATH",
249            "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
250        ),
251        ("DEBIAN_FRONTEND", "noninteractive"),
252        ("HOME", "/root"),
253        ("LANG", "C.UTF-8"),
254    ]
255}
256
257/// The properties a published image carries.
258pub fn properties(b: &ImageBuild) -> Value {
259    json!({
260        "description": b.description(),
261        PROP_MARK: "1",
262        PROP_RECIPE: b.recipe_sha256(),
263        PROP_BASE: b.base,
264        PROP_BUILT_BY: b.built_by,
265        PROP_BUILT_AT: crate::stack::now_secs().to_string(),
266    })
267}
268
269/// The build container's name: `wsimg-<name>-<random>`, within incus' 63.
270fn container_name(name: &str) -> String {
271    let stem: String = name
272        .chars()
273        .map(|c| if c.is_ascii_alphanumeric() { c } else { '-' })
274        .take(40)
275        .collect();
276    format!(
277        "wsimg-{}-{}{}",
278        stem.trim_end_matches('-'),
279        crate::stack::new_id(),
280        crate::stack::new_id()
281    )
282}
283
284/// What a finished build reports.
285#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
286pub struct Built {
287    pub name: String,
288    pub fingerprint: String,
289    /// The image's size in bytes, when incus said.
290    pub size: Option<u64>,
291    /// Nothing was built: the image was up to date.
292    pub up_to_date: bool,
293    /// The image the alias named before, deleted when nothing else named it.
294    pub replaced: Option<String>,
295    pub seconds: u64,
296}
297
298/// Builds in progress, by name: one at a time per name.
299static BUILDING: Mutex<BTreeSet<String>> = Mutex::new(BTreeSet::new());
300
301struct Building(String);
302
303impl Drop for Building {
304    fn drop(&mut self) {
305        BUILDING
306            .lock()
307            .unwrap_or_else(|e| e.into_inner())
308            .remove(&self.0);
309    }
310}
311
312fn host(base: &Client) -> Client {
313    base.clone().project("default")
314}
315
316/// The image an alias names on this host, if any.
317pub fn existing(base: &Client, name: &str) -> Result<Option<Existing>> {
318    let h = host(base);
319    let Some(a) = h.get_opt(&format!("/1.0/images/aliases/{}", encode_segment(name)))? else {
320        return Ok(None);
321    };
322    let fp = a["target"].as_str().unwrap_or_default();
323    let img = h.get(&format!("/1.0/images/{}", encode_segment(fp)))?;
324    Ok(Some(Existing::from_image(&img)))
325}
326
327/// The images isb built on this host, newest first: alias, description,
328/// size, fingerprint and the build's properties.
329pub fn list(base: &Client) -> Result<Vec<Value>> {
330    let all = host(base).get("/1.0/images?recursion=1")?;
331    let mut out: Vec<(u64, Value)> = all
332        .as_array()
333        .into_iter()
334        .flatten()
335        .filter(|i| i["properties"][PROP_MARK].as_str() == Some("1"))
336        .map(|i| {
337            let p = &i["properties"];
338            let at: u64 = p[PROP_BUILT_AT]
339                .as_str()
340                .and_then(|s| s.parse().ok())
341                .unwrap_or(0);
342            let aliases: Vec<&str> = i["aliases"]
343                .as_array()
344                .into_iter()
345                .flatten()
346                .filter_map(|a| a["name"].as_str())
347                .collect();
348            let v = json!({
349                "name": aliases.first(),
350                "aliases": aliases,
351                "fingerprint": i["fingerprint"],
352                "size": i["size"],
353                "description": p["description"],
354                "base": p[PROP_BASE],
355                "recipe_sha256": p[PROP_RECIPE],
356                "default_recipe": p[PROP_RECIPE].as_str() == Some(recipe_sha256(DEFAULT_RECIPE).as_str()),
357                "built_by": p[PROP_BUILT_BY],
358                "built_at": at,
359            });
360            (at, v)
361        })
362        .collect();
363    out.sort_by_key(|a| std::cmp::Reverse(a.0));
364    Ok(out.into_iter().map(|(_, v)| v).collect())
365}
366
367/// Remove an image isb built, by alias: the alias, and the image when no
368/// other alias names it.
369pub fn remove(base: &Client, name: &str) -> Result<Existing> {
370    check_name(name)?;
371    let e = existing(base, name)?.ok_or_else(|| Error::NotFound(format!("image {name}")))?;
372    removable(name, &e)?;
373    let h = host(base);
374    let t = h.timeouts.other;
375    h.mutate(
376        "DELETE",
377        &format!("/1.0/images/aliases/{}", encode_segment(name)),
378        None,
379        &format!("remove alias {name}"),
380        t,
381    )?;
382    if e.aliases.iter().all(|a| a == name) {
383        h.mutate(
384            "DELETE",
385            &format!("/1.0/images/{}", encode_segment(&e.fingerprint)),
386            None,
387            &format!("delete image {name}"),
388            t,
389        )?;
390    }
391    Ok(e)
392}
393
394/// The `isb-system` project, made with no registry in it when the host has
395/// none yet: isb's own instances live there, never in an org.
396fn system_project(base: &Client) -> Result<Client> {
397    let h = host(base);
398    let p = crate::registry::PROJECT;
399    if h.get_opt(&format!("/1.0/projects/{p}"))?.is_none() {
400        h.mutate(
401            "POST",
402            "/1.0/projects",
403            Some(&json!({
404                "name": p,
405                "description": "isb system services (not an org)",
406                "config": {
407                    "features.images": "false",
408                    "features.profiles": "true",
409                    "features.storage.volumes": "true",
410                    "features.networks": "false",
411                },
412            })),
413            &format!("create project {p}"),
414            h.timeouts.other,
415        )?;
416    }
417    Ok(base.clone().project(p))
418}
419
420/// Build (or find up to date) the image `b` describes, logging each step
421/// and every line the recipe prints.
422pub fn build(base: &Client, b: &ImageBuild, log: &mut dyn FnMut(&str)) -> Result<Built> {
423    check(b)?;
424    let started = Instant::now();
425    let deadline = started + b.timeout;
426    if !BUILDING
427        .lock()
428        .unwrap_or_else(|e| e.into_inner())
429        .insert(b.name.clone())
430    {
431        return Err(Error::invalid(format!(
432            "image {} is being built already",
433            b.name
434        )));
435    }
436    let _busy = Building(b.name.clone());
437    let plan = plan(existing(base, &b.name)?.as_ref(), b)?;
438    if let Plan::UpToDate { fingerprint } = plan {
439        log(&format!(
440            "image {} is up to date (same recipe and base); force rebuilds it",
441            b.name
442        ));
443        return Ok(Built {
444            name: b.name.clone(),
445            fingerprint,
446            size: None,
447            up_to_date: true,
448            replaced: None,
449            seconds: 0,
450        });
451    }
452    let s = system_project(base)?;
453    let fingerprint = run_recipe(base, &s, b, deadline, log)?;
454    let replaced = publish_alias(base, b, &fingerprint, &plan, log)?;
455    let size = host(base)
456        .get(&format!("/1.0/images/{}", encode_segment(&fingerprint)))
457        .ok()
458        .and_then(|i| i["size"].as_u64());
459    let seconds = started.elapsed().as_secs();
460    log(&format!(
461        "published {} ({}) in {seconds}s",
462        b.name,
463        &fingerprint[..fingerprint.len().min(12)]
464    ));
465    Ok(Built {
466        name: b.name.clone(),
467        fingerprint,
468        size,
469        up_to_date: false,
470        replaced,
471        seconds,
472    })
473}
474
475/// Launch the container, run the recipe, stop it and publish it without an
476/// alias. Returns the new image's fingerprint; the container is deleted
477/// whatever happens.
478fn run_recipe(
479    base: &Client,
480    s: &Client,
481    b: &ImageBuild,
482    deadline: Instant,
483    log: &mut dyn FnMut(&str),
484) -> Result<String> {
485    let h = host(base);
486    let name = container_name(&b.name);
487    reap_stale(s, false, log);
488    let _guard = Remove {
489        client: s.clone(),
490        name: name.clone(),
491    };
492    let pool = crate::sandbox::host_facts(&h)?.pick_pool(None)?;
493    let net = uplink_network(&h)?;
494    let src = crate::plan::ImageSource::parse(&b.base)?;
495    log(&format!(
496        "launching {name} from {} in project {} (network {net})",
497        b.base,
498        crate::registry::PROJECT
499    ));
500    s.mutate(
501        "POST",
502        "/1.0/instances",
503        Some(&json!({
504            "name": name,
505            "type": "container",
506            "source": src.to_api(None),
507            "config": owned(json!({"limits.cpu": "4", "limits.memory": "4GiB", "user.isb.workspace-image-build": b.name})),
508            "devices": {
509                "root": {"type": "disk", "path": "/", "pool": pool},
510                "eth0": {"type": "nic", "name": "eth0", "network": net},
511            },
512            "profiles": ["default"],
513        })),
514        &format!("create {name}"),
515        remaining(deadline, "creating the build container")?.min(Duration::from_secs(1200)),
516    )?;
517    let state = format!("/1.0/instances/{}/state", encode_segment(&name));
518    s.mutate(
519        "PUT",
520        &state,
521        Some(&json!({"action": "start", "timeout": 60})),
522        &format!("start {name}"),
523        Duration::from_secs(300),
524    )?;
525    let sb = Sandbox::get(s, &name)?;
526    ready::exec(&sb, deadline)?;
527    ready::network(s, &name, &net, deadline, log)?;
528    ready::resolver(&sb, deadline)?;
529    s.push_file(&name, RECIPE_PATH, b.recipe.as_bytes(), 0, 0, 0o700)?;
530    log(&format!(
531        "running the recipe ({} bytes, sha256 {})",
532        b.recipe.len(),
533        &b.recipe_sha256()[..12]
534    ));
535    let mut opts = ExecOptions::default().timeout(remaining(deadline, "running the recipe")?);
536    for (k, v) in recipe_env() {
537        opts = opts.env(k, v);
538    }
539    let code = stream_lines(&sb, &recipe_argv(&b.recipe), opts, log)?;
540    if code != 0 {
541        return Err(Error::invalid(format!(
542            "the recipe failed (exit {code}); nothing was published"
543        )));
544    }
545    let _ = sb.exec_with(
546        ["rm", "-f", RECIPE_PATH],
547        ExecOptions::default().timeout(Duration::from_secs(30)),
548    );
549    let stop = |force: bool| {
550        s.mutate(
551            "PUT",
552            &state,
553            Some(&json!({"action": "stop", "timeout": 120, "force": force})),
554            &format!("stop {name}"),
555            Duration::from_secs(180),
556        )
557    };
558    if let Err(e) = stop(false) {
559        log(&format!("a clean stop failed ({e}); forcing it"));
560        stop(true)?;
561    }
562    log(&format!("publishing {}", b.name));
563    let op = s.mutate(
564        "POST",
565        "/1.0/images",
566        Some(&json!({
567            "source": {"type": "instance", "name": name},
568            "properties": properties(b),
569        })),
570        &format!("publish {}", b.name),
571        remaining(deadline, "publishing the image")?.min(Duration::from_secs(1800)),
572    )?;
573    op["fingerprint"]
574        .as_str()
575        .map(str::to_string)
576        .ok_or_else(|| Error::invalid("incus published the image without saying its fingerprint"))
577}
578
579/// Point the alias at the new image; delete the image it named before when
580/// nothing else names that. A failure here deletes the new image.
581fn publish_alias(
582    base: &Client,
583    b: &ImageBuild,
584    fingerprint: &str,
585    plan: &Plan,
586    log: &mut dyn FnMut(&str),
587) -> Result<Option<String>> {
588    let h = host(base);
589    let t = h.timeouts.other;
590    let desc = b.description();
591    let r = match plan {
592        Plan::Replace { .. } => h.mutate(
593            "PUT",
594            &format!("/1.0/images/aliases/{}", encode_segment(&b.name)),
595            Some(&json!({"target": fingerprint, "description": desc})),
596            &format!("move alias {}", b.name),
597            t,
598        ),
599        _ => h.mutate(
600            "POST",
601            "/1.0/images/aliases",
602            Some(&json!({"name": b.name, "target": fingerprint, "description": desc})),
603            &format!("add alias {}", b.name),
604            t,
605        ),
606    };
607    if let Err(e) = r {
608        let _ = h.mutate(
609            "DELETE",
610            &format!("/1.0/images/{}", encode_segment(fingerprint)),
611            None,
612            "delete the unaliased image",
613            t,
614        );
615        return Err(e);
616    }
617    let Plan::Replace { old } = plan else {
618        return Ok(None);
619    };
620    if old == fingerprint {
621        return Ok(None);
622    }
623    let still_named = h
624        .get_opt(&format!("/1.0/images/{}", encode_segment(old)))?
625        .is_some_and(|i| !Existing::from_image(&i).aliases.is_empty());
626    if !still_named {
627        match h.mutate(
628            "DELETE",
629            &format!("/1.0/images/{}", encode_segment(old)),
630            None,
631            "delete the replaced image",
632            t,
633        ) {
634            Ok(_) => log(&format!(
635                "deleted the image it replaced ({})",
636                &old[..old.len().min(12)]
637            )),
638            Err(e) if e.is_not_found() => {}
639            Err(e) => log(&format!("the image it replaced was not deleted: {e}")),
640        }
641    }
642    Ok(Some(old.clone()))
643}
644
645#[cfg(test)]
646mod tests {
647    use super::*;
648
649    fn req(recipe: &str) -> ImageBuild {
650        ImageBuild {
651            name: "team-box".into(),
652            recipe: recipe.into(),
653            base: DEFAULT_BASE.into(),
654            description: None,
655            timeout: DEFAULT_TIMEOUT,
656            force: false,
657            built_by: "a@x.io".into(),
658        }
659    }
660
661    fn image(ours: bool, recipe: &str, aliases: &[&str]) -> Existing {
662        Existing {
663            fingerprint: "f00d".into(),
664            ours,
665            recipe_sha256: Some(recipe_sha256(recipe)),
666            base: Some(DEFAULT_BASE.into()),
667            aliases: aliases.iter().map(|a| a.to_string()).collect(),
668        }
669    }
670
671    #[test]
672    fn the_plan_creates_skips_replaces_and_never_takes_another_image() {
673        let b = req("apt-get install -y git\n");
674        assert_eq!(plan(None, &b).unwrap(), Plan::Create);
675        // isb's image from the same recipe and base: nothing to do.
676        let same = image(true, &b.recipe, &["team-box"]);
677        assert_eq!(
678            plan(Some(&same), &b).unwrap(),
679            Plan::UpToDate {
680                fingerprint: "f00d".into()
681            }
682        );
683        // Forced, or another recipe, or another base: replace it.
684        let forced = ImageBuild {
685            force: true,
686            ..b.clone()
687        };
688        assert_eq!(
689            plan(Some(&same), &forced).unwrap(),
690            Plan::Replace { old: "f00d".into() }
691        );
692        let other = image(true, "echo old\n", &["team-box"]);
693        assert!(matches!(
694            plan(Some(&other), &b).unwrap(),
695            Plan::Replace { .. }
696        ));
697        let on_debian = ImageBuild {
698            base: "images:debian/12".into(),
699            ..b.clone()
700        };
701        assert!(matches!(
702            plan(Some(&same), &on_debian).unwrap(),
703            Plan::Replace { .. }
704        ));
705        // An image isb did not build (dev-base): refused, whatever is asked.
706        let theirs = image(false, &b.recipe, &["team-box"]);
707        assert!(plan(Some(&theirs), &forced).is_err());
708    }
709
710    #[test]
711    fn only_isbs_images_can_be_removed() {
712        let ours = Existing::from_image(&json!({
713            "fingerprint": "abc",
714            "aliases": [{"name": "isb-workspace"}],
715            "properties": {PROP_MARK: "1", PROP_RECIPE: "x"}
716        }));
717        assert!(ours.ours);
718        assert_eq!(ours.aliases, ["isb-workspace"]);
719        assert!(removable("isb-workspace", &ours).is_ok());
720        let dev_base = Existing::from_image(&json!({
721            "fingerprint": "def",
722            "aliases": [{"name": "dev-base"}],
723            "properties": {"description": "dev-base: Ubuntu 24.04"}
724        }));
725        assert!(!dev_base.ours);
726        let e = removable("dev-base", &dev_base).unwrap_err().to_string();
727        assert!(e.contains("not built by isb"), "{e}");
728        // A property that only looks like the mark is not it.
729        let fake = Existing::from_image(&json!({"properties": {PROP_MARK: "true"}}));
730        assert!(!fake.ours);
731    }
732
733    #[test]
734    fn the_recipe_runs_as_root_with_a_plain_environment() {
735        assert_eq!(recipe_argv("#!/bin/bash\necho hi\n"), [RECIPE_PATH]);
736        assert_eq!(recipe_argv("echo hi\n"), ["/bin/sh", RECIPE_PATH]);
737        let env: Vec<&str> = recipe_env().iter().map(|(k, _)| *k).collect();
738        assert_eq!(env, ["PATH", "DEBIAN_FRONTEND", "HOME", "LANG"]);
739        let b = req("echo hi\n");
740        let p = properties(&b);
741        assert_eq!(p[PROP_MARK], "1");
742        assert_eq!(p[PROP_RECIPE], recipe_sha256("echo hi\n"));
743        assert_eq!(p[PROP_BASE], DEFAULT_BASE);
744        assert_eq!(p[PROP_BUILT_BY], "a@x.io");
745        assert!(p["description"].as_str().unwrap().contains(DEFAULT_BASE));
746        let n = container_name("team.box");
747        assert!(n.starts_with("wsimg-team-box-") && n.len() <= 63, "{n}");
748    }
749
750    #[test]
751    fn requests_are_checked_before_anything_is_made() {
752        assert!(check(&req("echo hi")).is_ok());
753        assert!(check(&ImageBuild::default_recipe("x")).is_ok());
754        for bad in ["", "Box", "-x", "a/b", "images:ubuntu", &"a".repeat(64)] {
755            assert!(check_name(bad).is_err(), "{bad}");
756        }
757        assert!(check(&req("  \n")).is_err());
758        assert!(check(&req(&"x".repeat(MAX_RECIPE + 1))).is_err());
759        let slow = ImageBuild {
760            timeout: MAX_TIMEOUT + Duration::from_secs(1),
761            ..req("echo")
762        };
763        assert!(check(&slow).is_err());
764    }
765
766    #[test]
767    fn the_default_recipe_installs_outside_the_home_and_bakes_in_no_credentials() {
768        let r = DEFAULT_RECIPE;
769        assert!(r.starts_with("#!/bin/sh"));
770        for tool in [
771            "useradd -m -u 1000",
772            "openssh-server",
773            "build-essential",
774            "MISE_INSTALL_PATH=/usr/local/bin/mise",
775            "mise install --system",
776            "/usr/local/bin/claude",
777            "PI_INSTALL_DIR=/usr/local/bin",
778            "HERDR_INSTALL_DIR=/usr/local/bin",
779            "rm -f /etc/ssh/ssh_host_",
780        ] {
781            assert!(r.contains(tool), "{tool}");
782        }
783        for secret in ["API_KEY", "TOKEN=", "PASSWORD", "authorized_keys"] {
784            assert!(!r.contains(secret), "{secret}");
785        }
786    }
787}