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, ready, 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    let _guard = Remove {
488        client: s.clone(),
489        name: name.clone(),
490    };
491    let pool = crate::sandbox::host_facts(&h)?.pick_pool(None)?;
492    let net = uplink_network(&h)?;
493    let src = crate::plan::ImageSource::parse(&b.base)?;
494    log(&format!(
495        "launching {name} from {} in project {} (network {net})",
496        b.base,
497        crate::registry::PROJECT
498    ));
499    s.mutate(
500        "POST",
501        "/1.0/instances",
502        Some(&json!({
503            "name": name,
504            "type": "container",
505            "source": src.to_api(None),
506            "config": {"limits.cpu": "4", "limits.memory": "4GiB", "user.isb.workspace-image-build": b.name},
507            "devices": {
508                "root": {"type": "disk", "path": "/", "pool": pool},
509                "eth0": {"type": "nic", "name": "eth0", "network": net},
510            },
511            "profiles": ["default"],
512        })),
513        &format!("create {name}"),
514        remaining(deadline, "creating the build container")?.min(Duration::from_secs(1200)),
515    )?;
516    let state = format!("/1.0/instances/{}/state", encode_segment(&name));
517    s.mutate(
518        "PUT",
519        &state,
520        Some(&json!({"action": "start", "timeout": 60})),
521        &format!("start {name}"),
522        Duration::from_secs(300),
523    )?;
524    let sb = Sandbox::get(s, &name)?;
525    ready::exec(&sb, deadline)?;
526    ready::network(s, &name, &net, deadline, log)?;
527    s.push_file(&name, RECIPE_PATH, b.recipe.as_bytes(), 0, 0, 0o700)?;
528    log(&format!(
529        "running the recipe ({} bytes, sha256 {})",
530        b.recipe.len(),
531        &b.recipe_sha256()[..12]
532    ));
533    let mut opts = ExecOptions::default().timeout(remaining(deadline, "running the recipe")?);
534    for (k, v) in recipe_env() {
535        opts = opts.env(k, v);
536    }
537    let code = stream_lines(&sb, &recipe_argv(&b.recipe), opts, log)?;
538    if code != 0 {
539        return Err(Error::invalid(format!(
540            "the recipe failed (exit {code}); nothing was published"
541        )));
542    }
543    let _ = sb.exec_with(
544        ["rm", "-f", RECIPE_PATH],
545        ExecOptions::default().timeout(Duration::from_secs(30)),
546    );
547    let stop = |force: bool| {
548        s.mutate(
549            "PUT",
550            &state,
551            Some(&json!({"action": "stop", "timeout": 120, "force": force})),
552            &format!("stop {name}"),
553            Duration::from_secs(180),
554        )
555    };
556    if let Err(e) = stop(false) {
557        log(&format!("a clean stop failed ({e}); forcing it"));
558        stop(true)?;
559    }
560    log(&format!("publishing {}", b.name));
561    let op = s.mutate(
562        "POST",
563        "/1.0/images",
564        Some(&json!({
565            "source": {"type": "instance", "name": name},
566            "properties": properties(b),
567        })),
568        &format!("publish {}", b.name),
569        remaining(deadline, "publishing the image")?.min(Duration::from_secs(1800)),
570    )?;
571    op["fingerprint"]
572        .as_str()
573        .map(str::to_string)
574        .ok_or_else(|| Error::invalid("incus published the image without saying its fingerprint"))
575}
576
577/// Point the alias at the new image; delete the image it named before when
578/// nothing else names that. A failure here deletes the new image.
579fn publish_alias(
580    base: &Client,
581    b: &ImageBuild,
582    fingerprint: &str,
583    plan: &Plan,
584    log: &mut dyn FnMut(&str),
585) -> Result<Option<String>> {
586    let h = host(base);
587    let t = h.timeouts.other;
588    let desc = b.description();
589    let r = match plan {
590        Plan::Replace { .. } => h.mutate(
591            "PUT",
592            &format!("/1.0/images/aliases/{}", encode_segment(&b.name)),
593            Some(&json!({"target": fingerprint, "description": desc})),
594            &format!("move alias {}", b.name),
595            t,
596        ),
597        _ => h.mutate(
598            "POST",
599            "/1.0/images/aliases",
600            Some(&json!({"name": b.name, "target": fingerprint, "description": desc})),
601            &format!("add alias {}", b.name),
602            t,
603        ),
604    };
605    if let Err(e) = r {
606        let _ = h.mutate(
607            "DELETE",
608            &format!("/1.0/images/{}", encode_segment(fingerprint)),
609            None,
610            "delete the unaliased image",
611            t,
612        );
613        return Err(e);
614    }
615    let Plan::Replace { old } = plan else {
616        return Ok(None);
617    };
618    if old == fingerprint {
619        return Ok(None);
620    }
621    let still_named = h
622        .get_opt(&format!("/1.0/images/{}", encode_segment(old)))?
623        .is_some_and(|i| !Existing::from_image(&i).aliases.is_empty());
624    if !still_named {
625        match h.mutate(
626            "DELETE",
627            &format!("/1.0/images/{}", encode_segment(old)),
628            None,
629            "delete the replaced image",
630            t,
631        ) {
632            Ok(_) => log(&format!(
633                "deleted the image it replaced ({})",
634                &old[..old.len().min(12)]
635            )),
636            Err(e) if e.is_not_found() => {}
637            Err(e) => log(&format!("the image it replaced was not deleted: {e}")),
638        }
639    }
640    Ok(Some(old.clone()))
641}
642
643#[cfg(test)]
644mod tests {
645    use super::*;
646
647    fn req(recipe: &str) -> ImageBuild {
648        ImageBuild {
649            name: "team-box".into(),
650            recipe: recipe.into(),
651            base: DEFAULT_BASE.into(),
652            description: None,
653            timeout: DEFAULT_TIMEOUT,
654            force: false,
655            built_by: "a@x.io".into(),
656        }
657    }
658
659    fn image(ours: bool, recipe: &str, aliases: &[&str]) -> Existing {
660        Existing {
661            fingerprint: "f00d".into(),
662            ours,
663            recipe_sha256: Some(recipe_sha256(recipe)),
664            base: Some(DEFAULT_BASE.into()),
665            aliases: aliases.iter().map(|a| a.to_string()).collect(),
666        }
667    }
668
669    #[test]
670    fn the_plan_creates_skips_replaces_and_never_takes_another_image() {
671        let b = req("apt-get install -y git\n");
672        assert_eq!(plan(None, &b).unwrap(), Plan::Create);
673        // isb's image from the same recipe and base: nothing to do.
674        let same = image(true, &b.recipe, &["team-box"]);
675        assert_eq!(
676            plan(Some(&same), &b).unwrap(),
677            Plan::UpToDate {
678                fingerprint: "f00d".into()
679            }
680        );
681        // Forced, or another recipe, or another base: replace it.
682        let forced = ImageBuild {
683            force: true,
684            ..b.clone()
685        };
686        assert_eq!(
687            plan(Some(&same), &forced).unwrap(),
688            Plan::Replace { old: "f00d".into() }
689        );
690        let other = image(true, "echo old\n", &["team-box"]);
691        assert!(matches!(
692            plan(Some(&other), &b).unwrap(),
693            Plan::Replace { .. }
694        ));
695        let on_debian = ImageBuild {
696            base: "images:debian/12".into(),
697            ..b.clone()
698        };
699        assert!(matches!(
700            plan(Some(&same), &on_debian).unwrap(),
701            Plan::Replace { .. }
702        ));
703        // An image isb did not build (dev-base): refused, whatever is asked.
704        let theirs = image(false, &b.recipe, &["team-box"]);
705        assert!(plan(Some(&theirs), &forced).is_err());
706    }
707
708    #[test]
709    fn only_isbs_images_can_be_removed() {
710        let ours = Existing::from_image(&json!({
711            "fingerprint": "abc",
712            "aliases": [{"name": "isb-workspace"}],
713            "properties": {PROP_MARK: "1", PROP_RECIPE: "x"}
714        }));
715        assert!(ours.ours);
716        assert_eq!(ours.aliases, ["isb-workspace"]);
717        assert!(removable("isb-workspace", &ours).is_ok());
718        let dev_base = Existing::from_image(&json!({
719            "fingerprint": "def",
720            "aliases": [{"name": "dev-base"}],
721            "properties": {"description": "dev-base: Ubuntu 24.04"}
722        }));
723        assert!(!dev_base.ours);
724        let e = removable("dev-base", &dev_base).unwrap_err().to_string();
725        assert!(e.contains("not built by isb"), "{e}");
726        // A property that only looks like the mark is not it.
727        let fake = Existing::from_image(&json!({"properties": {PROP_MARK: "true"}}));
728        assert!(!fake.ours);
729    }
730
731    #[test]
732    fn the_recipe_runs_as_root_with_a_plain_environment() {
733        assert_eq!(recipe_argv("#!/bin/bash\necho hi\n"), [RECIPE_PATH]);
734        assert_eq!(recipe_argv("echo hi\n"), ["/bin/sh", RECIPE_PATH]);
735        let env: Vec<&str> = recipe_env().iter().map(|(k, _)| *k).collect();
736        assert_eq!(env, ["PATH", "DEBIAN_FRONTEND", "HOME", "LANG"]);
737        let b = req("echo hi\n");
738        let p = properties(&b);
739        assert_eq!(p[PROP_MARK], "1");
740        assert_eq!(p[PROP_RECIPE], recipe_sha256("echo hi\n"));
741        assert_eq!(p[PROP_BASE], DEFAULT_BASE);
742        assert_eq!(p[PROP_BUILT_BY], "a@x.io");
743        assert!(p["description"].as_str().unwrap().contains(DEFAULT_BASE));
744        let n = container_name("team.box");
745        assert!(n.starts_with("wsimg-team-box-") && n.len() <= 63, "{n}");
746    }
747
748    #[test]
749    fn requests_are_checked_before_anything_is_made() {
750        assert!(check(&req("echo hi")).is_ok());
751        assert!(check(&ImageBuild::default_recipe("x")).is_ok());
752        for bad in ["", "Box", "-x", "a/b", "images:ubuntu", &"a".repeat(64)] {
753            assert!(check_name(bad).is_err(), "{bad}");
754        }
755        assert!(check(&req("  \n")).is_err());
756        assert!(check(&req(&"x".repeat(MAX_RECIPE + 1))).is_err());
757        let slow = ImageBuild {
758            timeout: MAX_TIMEOUT + Duration::from_secs(1),
759            ..req("echo")
760        };
761        assert!(check(&slow).is_err());
762    }
763
764    #[test]
765    fn the_default_recipe_installs_outside_the_home_and_bakes_in_no_credentials() {
766        let r = DEFAULT_RECIPE;
767        assert!(r.starts_with("#!/bin/sh"));
768        for tool in [
769            "useradd -m -u 1000",
770            "openssh-server",
771            "build-essential",
772            "MISE_INSTALL_PATH=/usr/local/bin/mise",
773            "mise install --system",
774            "/usr/local/bin/claude",
775            "PI_INSTALL_DIR=/usr/local/bin",
776            "HERDR_INSTALL_DIR=/usr/local/bin",
777            "rm -f /etc/ssh/ssh_host_",
778        ] {
779            assert!(r.contains(tool), "{tool}");
780        }
781        for secret in ["API_KEY", "TOKEN=", "PASSWORD", "authorized_keys"] {
782            assert!(!r.contains(secret), "{secret}");
783        }
784    }
785}