Skip to main content

isb_apps/build/
mod.rs

1//! Builds: turn a source tree into an OCI image in the org's registry.
2//!
3//! Every build runs in a fresh sandbox in the org's own incus project, never
4//! on the host, and the sandbox is deleted afterwards (also on failure and
5//! timeout):
6//!
7//! - **A container by default.** BuildKit runs as root inside an ordinary
8//!   unprivileged org container (its own uid range, the org's network and
9//!   ACL, the org's quota); its `RUN` steps get their own namespaces
10//!   without `security.nesting`, which the org project keeps blocked.
11//! - **A VM when the source is untrusted** ([`BuildRequest::untrusted`]):
12//!   the build gets its own kernel. The org project allows VMs; the host
13//!   needs KVM.
14//!
15//! The source is copied in (the host tree is only read), the image is
16//! exported as an OCI layout inside the sandbox, and the daemon copies it
17//! out and pushes it to the local registry ([`crate::registry`]): build
18//! sandboxes have neither a route to the registry nor credentials for it.
19//! BuildKit's state lives on a per-app volume in the org
20//! (`build-cache-<app>`), so the next build of the app reuses layers and
21//! cache mounts.
22//!
23//! The builder image (BuildKit, railpack, nixpacks; see `builder-image.sh`)
24//! is prepared once per recipe in the `isb-system` project and cached as a
25//! local incus image, `isb-builder/<recipe hash>`.
26
27use std::io::Write;
28use std::path::{Path, PathBuf};
29use std::sync::Mutex;
30use std::time::{Duration, Instant};
31
32use serde::{Deserialize, Serialize};
33use serde_json::{Value, json};
34
35use crate::client::{Client, encode_segment};
36use crate::error::{Error, Result};
37use crate::exec::{ExecEvent, ExecOptions, Stdin};
38use crate::org::OrgId;
39use crate::sandbox::Sandbox;
40mod ready;
41mod reap;
42pub mod workspace_image;
43use reap::owned;
44pub use reap::{reap_stale, reap_stale_everywhere};
45/// How a source tree becomes an image.
46#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
47#[serde(tag = "type", rename_all = "lowercase")]
48pub enum Builder {
49    /// Railpack detects the language and builds without a Dockerfile.
50    Railpack,
51    Nixpacks,
52    /// A Dockerfile, relative to the context.
53    Dockerfile {
54        #[serde(default = "default_dockerfile")]
55        path: String,
56        #[serde(default, skip_serializing_if = "Option::is_none")]
57        target: Option<String>,
58    },
59    /// Cloud Native Buildpacks with the given builder image. Not supported
60    /// yet: `pack` drives a docker daemon (see docs/guides/builds.md).
61    Buildpacks {
62        #[serde(default, skip_serializing_if = "Option::is_none")]
63        builder: Option<String>,
64    },
65}
66
67fn default_dockerfile() -> String {
68    "Dockerfile".into()
69}
70
71impl Builder {
72    fn name(&self) -> &'static str {
73        match self {
74            Builder::Railpack => "railpack",
75            Builder::Nixpacks => "nixpacks",
76            Builder::Dockerfile { .. } => "dockerfile",
77            Builder::Buildpacks { .. } => "buildpacks",
78        }
79    }
80}
81
82/// One build.
83#[derive(Debug, Clone)]
84pub struct BuildRequest {
85    pub org: OrgId,
86    /// The app the image belongs to: names the repository in the registry
87    /// (`<org>/<app>`) and the build cache volume.
88    pub app: String,
89    /// A checked-out source tree on the host. The build reads it, never
90    /// writes it.
91    pub context: PathBuf,
92    /// A subdirectory of `context` to build from.
93    pub subdir: Option<String>,
94    pub builder: Builder,
95    /// Build-time variables (Dockerfile `ARG`s, buildpack env).
96    pub args: Vec<(String, String)>,
97    /// The tag to push, e.g. the commit SHA.
98    pub tag: String,
99    /// Build in a VM rather than a container.
100    pub untrusted: bool,
101    /// Whose build cache volume to use (default: the app's). Previews
102    /// build with their own, so a pull request cannot poison the cache
103    /// production builds read.
104    pub cache: Option<String>,
105}
106
107/// What a build produced.
108#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
109pub struct BuiltImage {
110    /// What a compose `image:` takes to run it: `registry:<app>:<tag>@<digest>`,
111    /// resolved in the org the stack runs in (so pinned to this build even
112    /// if the tag moves later).
113    pub image: String,
114    /// The manifest digest (`sha256:...`), for rollbacks that must not
115    /// follow a moved tag.
116    pub digest: String,
117}
118
119/// Limits for a build. [`BuildOptions::default`] reads `ISB_BUILD_TIMEOUT`,
120/// `ISB_BUILD_CPUS`, `ISB_BUILD_MEMORY` and `ISB_BUILD_CACHE_SIZE`.
121#[derive(Debug, Clone)]
122pub struct BuildOptions {
123    /// The whole build, sandbox creation to push (default 30 minutes).
124    pub timeout: Duration,
125    /// The build sandbox's CPUs (default 2) and memory (default 4GiB),
126    /// counted against the org's quota.
127    pub cpus: u32,
128    pub memory: String,
129    /// A VM build's cache disk (default 20GiB); a container's cache volume
130    /// is a directory, bounded by BuildKit's own garbage collection.
131    pub cache_size: String,
132    /// Largest source tree copied in (default 2 GiB).
133    pub max_context: u64,
134}
135
136impl Default for BuildOptions {
137    fn default() -> Self {
138        let env = |k: &str| std::env::var(k).ok().filter(|v| !v.is_empty());
139        BuildOptions {
140            timeout: env("ISB_BUILD_TIMEOUT")
141                .and_then(|t| crate::flex::parse_duration(&t).ok())
142                .unwrap_or(Duration::from_secs(30 * 60)),
143            cpus: env("ISB_BUILD_CPUS")
144                .and_then(|c| c.parse().ok())
145                .unwrap_or(2),
146            memory: env("ISB_BUILD_MEMORY").unwrap_or_else(|| "4GiB".into()),
147            cache_size: env("ISB_BUILD_CACHE_SIZE").unwrap_or_else(|| "20GiB".into()),
148            max_context: 2 << 30,
149        }
150    }
151}
152
153/// The railpack BuildKit frontend, matching the railpack in the image.
154const RAILPACK_FRONTEND: &str = "ghcr.io/railwayapp/railpack-frontend:v0.40.1@sha256:f1973377693af30c9b37a92c97c661c07b277ccdc6be909213c74c771f8d2d6d";
155const RECIPE: &str = include_str!("builder-image.sh");
156const DRIVER: &str = include_str!("build.sh");
157/// The base the builder image is made from.
158const BASE_IMAGE: &str = "images:ubuntu/24.04";
159/// The VM's cache disk, as its by-id name ends.
160const CACHE_DEVICE: &str = "isbcache";
161
162/// The builder image's alias: `isb-builder/<hash>` (containers) or
163/// `isb-builder-vm/<hash>`. A new recipe is a new alias.
164pub fn builder_alias(vm: bool) -> String {
165    let d = crate::registry::oci::digest_of(RECIPE.as_bytes());
166    let h = &d[7..19];
167    if vm {
168        format!("isb-builder-vm/{h}")
169    } else {
170        format!("isb-builder/{h}")
171    }
172}
173
174/// Run a build with `base` (an unscoped client; the build's sandbox lives
175/// in the request's org), streaming its log lines to `log`.
176pub fn run(base: &Client, req: &BuildRequest, log: &mut dyn FnMut(&str)) -> Result<BuiltImage> {
177    run_with(base, req, &BuildOptions::default(), log)
178}
179
180/// [`run`] with explicit limits.
181#[expect(
182    clippy::too_many_lines,
183    reason = "predates the lint ratchet; split it when next changed"
184)]
185pub fn run_with(
186    base: &Client,
187    req: &BuildRequest,
188    opts: &BuildOptions,
189    log: &mut dyn FnMut(&str),
190) -> Result<BuiltImage> {
191    let started = Instant::now();
192    let deadline = started + opts.timeout;
193    let ctx_dir = check(req)?;
194    let reg = crate::registry::Registry::shared(base)?;
195    let vm = req.untrusted;
196    let oc = crate::org::client(base, &req.org);
197    crate::org::get(base, &req.org)?;
198    log(&format!(
199        "building {}/{}:{} with {} in a {}",
200        req.org,
201        req.app,
202        req.tag,
203        req.builder.name(),
204        if vm { "VM" } else { "container" }
205    ));
206    let image = ensure_builder_image(base, vm, deadline, log)?;
207    let pool = crate::sandbox::host_facts(&oc)?.pick_pool(None)?;
208    let cache = ensure_cache(
209        &oc,
210        &pool,
211        req.cache.as_deref().unwrap_or(&req.app),
212        vm,
213        &opts.cache_size,
214        log,
215    )?;
216    // A sandbox left by a build whose process died holds the cache volume.
217    reap_stale(&oc, false, log);
218
219    let name = sandbox_name(&req.app);
220    let _guard = Remove {
221        client: oc.clone(),
222        name: name.clone(),
223    };
224    log(&format!("creating build sandbox {name}"));
225    create_sandbox(
226        &oc, &name, &image, vm, &pool, &cache, opts, &req.app, deadline,
227    )?;
228    let sb = Sandbox::get(&oc, &name)?;
229    ready::exec(&sb, deadline)?;
230    ready::resolver(&sb, deadline)?;
231
232    let sent = send_context(&sb, &ctx_dir, opts.max_context, deadline)?;
233    log(&format!("copied the source in ({})", human(sent)));
234    oc.push_file(&name, "/build/build.sh", DRIVER.as_bytes(), 0, 0, 0o755)?;
235    let mut args = String::new();
236    for (k, v) in &req.args {
237        args.push_str(&format!("{k}={v}\n"));
238    }
239    oc.push_file(&name, "/build/args", args.as_bytes(), 0, 0, 0o600)?;
240
241    let mut env = ExecOptions::default()
242        .env("ISB_BUILDER", req.builder.name())
243        .env("ISB_RAILPACK_FRONTEND", RAILPACK_FRONTEND)
244        .env("HOME", "/root")
245        .env(
246            "PATH",
247            "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
248        )
249        .env(
250            "ISB_CONTEXT",
251            match &req.subdir {
252                Some(s) => format!("/build/src/{s}"),
253                None => "/build/src".into(),
254            },
255        );
256    if let Builder::Dockerfile { path, target } = &req.builder {
257        env = env.env("ISB_DOCKERFILE", path.clone());
258        if let Some(t) = target {
259            env = env.env("ISB_TARGET", t.clone());
260        }
261    }
262    if vm {
263        env = env.env("ISB_CACHE_DISK", CACHE_DEVICE);
264    }
265    let code = stream_lines(
266        &sb,
267        &["/bin/bash", "/build/build.sh"],
268        env.timeout(remaining(deadline, "the build")?),
269        log,
270    )
271    .map_err(|e| {
272        if e.is_timeout() || Instant::now() >= deadline {
273            Error::invalid(format!(
274                "build of {}/{} timed out after {:?} (killed)",
275                req.org, req.app, opts.timeout
276            ))
277        } else {
278            e
279        }
280    })?;
281    if code != 0 {
282        return Err(if Instant::now() >= deadline {
283            Error::invalid(format!(
284                "build of {}/{} timed out after {:?}",
285                req.org, req.app, opts.timeout
286            ))
287        } else {
288            Error::invalid(format!(
289                "build of {}/{} failed (exit {code}); see the log above",
290                req.org, req.app
291            ))
292        });
293    }
294
295    let tmp = scratch_file(&req.app)?;
296    let _rm = RemoveFile(tmp.clone());
297    let n = copy_out(&sb, "/build/out/image.tar", &tmp, deadline)?;
298    log(&format!("copied the image out ({})", human(n)));
299    let digest = reg.push(&req.org, &req.app, &req.tag, &tmp, log)?;
300    let image = format!(
301        "registry:{}",
302        crate::registry::ImageRef {
303            app: req.app.clone(),
304            tag: Some(req.tag.clone()),
305            digest: Some(digest.clone()),
306        }
307        .render()
308    );
309    log(&format!(
310        "built {image} in {:.0}s",
311        started.elapsed().as_secs_f64()
312    ));
313    Ok(BuiltImage { image, digest })
314}
315
316/// Validate a request; returns the directory to copy in.
317fn check(req: &BuildRequest) -> Result<PathBuf> {
318    if !crate::registry::valid_app(&req.app) || req.app.len() > 40 {
319        return Err(Error::invalid(format!(
320            "app {:?}: up to 40 characters of [a-z0-9._-], starting with a letter or digit",
321            req.app
322        )));
323    }
324    if !crate::registry::valid_tag(&req.tag) {
325        return Err(Error::invalid(format!(
326            "tag {:?}: [A-Za-z0-9_][A-Za-z0-9_.-]*, at most 128 characters",
327            req.tag
328        )));
329    }
330    let rel_ok = |p: &str| {
331        !p.is_empty()
332            && !p.starts_with('/')
333            && Path::new(p)
334                .components()
335                .all(|c| matches!(c, std::path::Component::Normal(_)))
336    };
337    if let Some(s) = &req.subdir {
338        if !rel_ok(s) {
339            return Err(Error::invalid(format!(
340                "subdir {s:?}: a relative path inside the context"
341            )));
342        }
343    }
344    match &req.builder {
345        Builder::Dockerfile { path, target } => {
346            if !rel_ok(path) {
347                return Err(Error::invalid(format!(
348                    "dockerfile {path:?}: a relative path inside the context"
349                )));
350            }
351            if target.as_deref().is_some_and(|t| {
352                t.is_empty()
353                    || !t
354                        .chars()
355                        .all(|c| c.is_ascii_alphanumeric() || "_.-".contains(c))
356            }) {
357                return Err(Error::invalid("target: a stage name"));
358            }
359        }
360        Builder::Buildpacks { .. } => {
361            return Err(Error::invalid(
362                "buildpacks are not supported yet: pack needs a docker daemon; use railpack (it detects the same languages) or a Dockerfile",
363            ));
364        }
365        _ => {}
366    }
367    for (k, v) in &req.args {
368        let ok = !k.is_empty()
369            && !k.starts_with(|c: char| c.is_ascii_digit())
370            && k.chars().all(|c| c.is_ascii_alphanumeric() || c == '_');
371        if !ok {
372            return Err(Error::invalid(format!(
373                "build argument {k:?}: [A-Za-z_][A-Za-z0-9_]*"
374            )));
375        }
376        if v.contains(['\n', '\r', '\0']) {
377            return Err(Error::invalid(format!(
378                "build argument {k}: values are one line"
379            )));
380        }
381    }
382    let dir = match &req.subdir {
383        Some(s) => req.context.join(s),
384        None => req.context.clone(),
385    };
386    let md = std::fs::metadata(&req.context)
387        .map_err(|e| Error::invalid(format!("context {}: {e}", req.context.display())))?;
388    if !md.is_dir() || !req.context.is_absolute() {
389        return Err(Error::invalid(format!(
390            "context {}: an absolute directory",
391            req.context.display()
392        )));
393    }
394    if !dir.is_dir() {
395        return Err(Error::invalid(format!(
396            "{} is not a directory",
397            dir.display()
398        )));
399    }
400    Ok(req.context.clone())
401}
402
403fn remaining(deadline: Instant, what: &str) -> Result<Duration> {
404    let r = deadline.saturating_duration_since(Instant::now());
405    if r.is_zero() {
406        return Err(Error::invalid(format!("build timed out before {what}")));
407    }
408    Ok(r)
409}
410
411fn human(n: u64) -> String {
412    match n {
413        n if n >= 1 << 30 => format!("{:.1} GiB", n as f64 / (1u64 << 30) as f64),
414        n if n >= 1 << 20 => format!("{:.1} MiB", n as f64 / (1u64 << 20) as f64),
415        n if n >= 1 << 10 => format!("{:.1} KiB", n as f64 / 1024.0),
416        n => format!("{n} B"),
417    }
418}
419
420/// `build-<app>-<random>`, an instance name.
421fn sandbox_name(app: &str) -> String {
422    let a: String = app
423        .chars()
424        .map(|c| if c.is_ascii_alphanumeric() { c } else { '-' })
425        .collect();
426    let a = a.trim_matches('-');
427    format!(
428        "build-{a}-{}{}",
429        crate::stack::new_id(),
430        crate::stack::new_id()
431    )
432}
433
434/// The name of a build cache volume: `build-cache-<key>`, `-vm` for VMs.
435pub fn cache_volume(key: &str, vm: bool) -> String {
436    let a = key.replace('.', "-");
437    if vm {
438        format!("build-cache-{a}-vm")
439    } else {
440        format!("build-cache-{a}")
441    }
442}
443
444/// The build cache volume of an app: `build-cache-<app>`, a filesystem
445/// volume for containers, a block volume (`-vm`) for VMs, whose overlay
446/// snapshots cannot live on a shared filesystem.
447fn ensure_cache(
448    oc: &Client,
449    pool: &str,
450    app: &str,
451    vm: bool,
452    size: &str,
453    log: &mut dyn FnMut(&str),
454) -> Result<String> {
455    let name = cache_volume(app, vm);
456    let path = format!(
457        "/1.0/storage-pools/{}/volumes/custom/{}",
458        encode_segment(pool),
459        encode_segment(&name)
460    );
461    if oc.get_opt(&path)?.is_none() {
462        log(&format!("creating build cache volume {name}"));
463        let mut body = json!({"name": name, "type": "custom", "config": {}});
464        body["content_type"] = json!(if vm { "block" } else { "filesystem" });
465        // A block volume needs a size; so does any volume under a disk limit.
466        if vm || crate::org::disk::disk_limited(oc) {
467            body["config"]["size"] = json!(size);
468        }
469        match oc.mutate(
470            "POST",
471            &format!("/1.0/storage-pools/{}/volumes/custom", encode_segment(pool)),
472            Some(&body),
473            &format!("create volume {name}"),
474            oc.get_timeouts().other,
475        ) {
476            Ok(_) => {}
477            // Another build of the app made it first.
478            Err(e) if e.is_conflict() => {}
479            Err(e) => return Err(e),
480        }
481    }
482    Ok(name)
483}
484
485#[expect(clippy::too_many_arguments)]
486fn create_sandbox(
487    oc: &Client,
488    name: &str,
489    image: &str,
490    vm: bool,
491    pool: &str,
492    cache: &str,
493    opts: &BuildOptions,
494    app: &str,
495    deadline: Instant,
496) -> Result<()> {
497    let mut disk = json!({"type": "disk", "pool": pool, "source": cache});
498    let mut root = json!({"type": "disk", "path": "/", "pool": pool});
499    // A VM gets a raw disk, which build.sh finds by this device name and formats
500    // once, and a root with room for the source, export and BuildKit's scratch;
501    // a container, the directory itself and a root sized only under a disk limit.
502    if vm {
503        root["size"] = json!("20GiB");
504    } else {
505        disk["path"] = json!("/var/lib/buildkit");
506    }
507    let devices = json!({ CACHE_DEVICE: disk, "root": root });
508    let body = json!({
509        "name": name,
510        "type": if vm { "virtual-machine" } else { "container" },
511        "source": {"type": "image", "alias": image},
512        "config": owned(json!({
513            "limits.cpu": opts.cpus.to_string(),
514            "limits.memory": opts.memory,
515            "user.isb.build": app,
516        })),
517        "devices": devices,
518        "profiles": ["default"],
519    });
520    let t = remaining(deadline, "creating the build sandbox")?;
521    oc.mutate(
522        "POST",
523        "/1.0/instances",
524        Some(&crate::org::disk::sized_root(oc, body)),
525        &format!("create build sandbox {name}"),
526        t.min(oc.get_timeouts().create.max(Duration::from_secs(600))),
527    )?;
528    let t = remaining(deadline, "starting the build sandbox")?;
529    oc.mutate(
530        "PUT",
531        &format!("/1.0/instances/{}/state", encode_segment(name)),
532        Some(&json!({"action": "start", "timeout": 60})),
533        &format!("start build sandbox {name}"),
534        t.min(Duration::from_secs(300)),
535    )?;
536    Ok(())
537}
538
539/// Run argv, handing each output line to `log`. Returns the exit code.
540fn stream_lines(
541    sb: &Sandbox,
542    argv: &[&str],
543    opts: ExecOptions,
544    log: &mut dyn FnMut(&str),
545) -> Result<i32> {
546    let mut s = sb.exec_stream(argv.iter().copied(), opts)?;
547    let (mut out, mut err) = (Vec::new(), Vec::new());
548    let emit = |buf: &mut Vec<u8>, chunk: Vec<u8>, log: &mut dyn FnMut(&str)| {
549        buf.extend(chunk);
550        while let Some(i) = buf.iter().position(|b| *b == b'\n') {
551            let line: Vec<u8> = buf.drain(..=i).collect();
552            let text = String::from_utf8_lossy(&line[..line.len() - 1]);
553            log(text.trim_end_matches('\r'));
554        }
555        // A runaway line without newlines is cut rather than buffered.
556        if buf.len() > 64 << 10 {
557            log(&String::from_utf8_lossy(buf));
558            buf.clear();
559        }
560    };
561    while let Some(ev) = s.next_event() {
562        match ev {
563            ExecEvent::Stdout(b) => emit(&mut out, b, log),
564            ExecEvent::Stderr(b) => emit(&mut err, b, log),
565        }
566    }
567    for b in [out, err] {
568        if !b.is_empty() {
569            log(&String::from_utf8_lossy(&b));
570        }
571    }
572    // incus fails the operation, rather than reporting the code, when the
573    // command ends with 126 or 127, which a script can pass on.
574    match s.wait() {
575        Err(e) if e.to_string().contains("Command not found") => Ok(127),
576        Err(e) if e.to_string().contains("Permission denied") => Ok(126),
577        r => r,
578    }
579}
580
581/// Copy `dir` into the sandbox at `/build/src` as a tar on stdin. Returns
582/// the bytes sent.
583fn send_context(sb: &Sandbox, dir: &Path, max: u64, deadline: Instant) -> Result<u64> {
584    let mut s = sb.exec_stream(
585        [
586            "/bin/sh",
587            "-c",
588            "mkdir -p /build/src && tar -x --no-same-owner -C /build/src",
589        ],
590        ExecOptions::default()
591            .stdin(Stdin::Piped)
592            .timeout(remaining(deadline, "copying the source")?),
593    )?;
594    let mut w = ChunkWriter {
595        s: &s,
596        buf: Vec::with_capacity(CHUNK),
597        sent: 0,
598        max,
599    };
600    let r = tar::write_dir(&mut w, dir).and_then(|_| w.flush().map_err(Error::from));
601    let sent = w.sent;
602    s.close_stdin();
603    let out = s.collect_output()?;
604    r?;
605    if !out.success() {
606        return Err(Error::invalid(format!(
607            "copying the source in failed (exit {}): {}",
608            out.exit_code,
609            out.stderr_text().trim()
610        )));
611    }
612    Ok(sent)
613}
614
615const CHUNK: usize = 256 << 10;
616
617struct ChunkWriter<'a> {
618    s: &'a crate::exec::ExecStream,
619    buf: Vec<u8>,
620    sent: u64,
621    max: u64,
622}
623
624impl Write for ChunkWriter<'_> {
625    fn write(&mut self, b: &[u8]) -> std::io::Result<usize> {
626        self.sent += b.len() as u64;
627        if self.sent > self.max {
628            return Err(std::io::Error::other(format!(
629                "the source is over {} (ISB build limit)",
630                human(self.max)
631            )));
632        }
633        self.buf.extend_from_slice(b);
634        if self.buf.len() >= CHUNK {
635            self.flush()?;
636        }
637        Ok(b.len())
638    }
639
640    fn flush(&mut self) -> std::io::Result<()> {
641        if !self.buf.is_empty() {
642            self.s
643                .write_stdin(&self.buf)
644                .map_err(|e| std::io::Error::other(e.to_string()))?;
645            self.buf.clear();
646        }
647        Ok(())
648    }
649}
650
651/// Copy a file out of the sandbox through `cat`. Returns its size.
652fn copy_out(sb: &Sandbox, path: &str, to: &Path, deadline: Instant) -> Result<u64> {
653    let mut f = std::fs::File::create(to)?;
654    let mut s = sb.exec_stream(
655        ["/bin/cat", path],
656        ExecOptions::default().timeout(remaining(deadline, "copying the image out")?),
657    )?;
658    let mut n = 0u64;
659    let mut err = Vec::new();
660    while let Some(ev) = s.next_event() {
661        match ev {
662            ExecEvent::Stdout(b) => {
663                n += b.len() as u64;
664                f.write_all(&b)?;
665            }
666            ExecEvent::Stderr(b) => err.extend(b),
667        }
668    }
669    let code = s.wait()?;
670    if code != 0 {
671        return Err(Error::invalid(format!(
672            "copying {path} out failed (exit {code}): {}",
673            String::from_utf8_lossy(&err).trim()
674        )));
675    }
676    f.sync_all()?;
677    Ok(n)
678}
679
680/// Where the image is staged between the sandbox and the registry: the
681/// daemon's state directory (images can be large; /tmp may be a tmpfs).
682fn scratch_file(app: &str) -> Result<PathBuf> {
683    let dir = std::env::var_os("ISB_SERVE_STATE_DIR")
684        .map(PathBuf::from)
685        .unwrap_or_else(crate::stack::Store::default_dir)
686        .join("builds");
687    std::fs::create_dir_all(&dir)?;
688    Ok(dir.join(format!(
689        "{app}-{}{}.tar",
690        crate::stack::new_id(),
691        crate::stack::new_id()
692    )))
693}
694
695struct RemoveFile(PathBuf);
696
697impl Drop for RemoveFile {
698    fn drop(&mut self) {
699        let _ = std::fs::remove_file(&self.0);
700    }
701}
702
703/// Deletes the build sandbox however the build ends.
704struct Remove {
705    client: Client,
706    name: String,
707}
708
709impl Drop for Remove {
710    fn drop(&mut self) {
711        match Sandbox::remove(&self.client, &self.name, true) {
712            Ok(()) => {}
713            Err(e) if e.is_not_found() => {}
714            Err(e) => eprintln!("isb: build sandbox {}: not deleted: {e}", self.name),
715        }
716    }
717}
718
719/// One preparation at a time per process; the image is shared by all orgs.
720static PREPARE: Mutex<()> = Mutex::new(());
721
722/// The builder image's alias, made from `builder-image.sh` when missing: in the
723/// `isb-system` project, never in an org (an org could otherwise tamper
724/// with an image every org builds with).
725#[expect(
726    clippy::too_many_lines,
727    reason = "predates the lint ratchet; split it when next changed"
728)]
729pub fn ensure_builder_image(
730    base: &Client,
731    vm: bool,
732    deadline: Instant,
733    log: &mut dyn FnMut(&str),
734) -> Result<String> {
735    let alias = builder_alias(vm);
736    let h = base.clone().project("default");
737    let alias_path = format!("/1.0/images/aliases/{}", encode_segment(&alias));
738    if h.get_opt(&alias_path)?.is_some() {
739        return Ok(alias);
740    }
741    let _g = PREPARE.lock().unwrap();
742    if h.get_opt(&alias_path)?.is_some() {
743        return Ok(alias);
744    }
745    log(&format!(
746        "preparing the builder image {alias} (once per recipe; a few minutes)"
747    ));
748    let s = base.clone().project(crate::registry::PROJECT);
749    if crate::registry::info(base)?.is_none() {
750        return Err(Error::invalid(
751            "no isb-system project yet: run `isb registry setup` first",
752        ));
753    }
754    let pool = crate::sandbox::host_facts(&h)?.pick_pool(None)?;
755    let net = uplink_network(&h)?;
756    let name = format!(
757        "builder-prep-{}{}",
758        crate::stack::new_id(),
759        crate::stack::new_id()
760    );
761    reap_stale(&s, false, log);
762    let _guard = Remove {
763        client: s.clone(),
764        name: name.clone(),
765    };
766    let src = crate::plan::ImageSource::parse(BASE_IMAGE)?;
767    let config = owned(json!({"limits.cpu": "4", "limits.memory": "4GiB"}));
768    s.mutate(
769        "POST",
770        "/1.0/instances",
771        Some(&json!({
772            "name": name,
773            "type": if vm { "virtual-machine" } else { "container" },
774            "source": src.to_api(None),
775            "config": config,
776            "devices": {
777                "root": {"type": "disk", "path": "/", "pool": pool},
778                "eth0": {"type": "nic", "name": "eth0", "network": net},
779            },
780            "profiles": ["default"],
781        })),
782        &format!("create {name}"),
783        remaining(deadline, "creating the builder image")?.min(Duration::from_secs(1200)),
784    )?;
785    s.mutate(
786        "PUT",
787        &format!("/1.0/instances/{name}/state"),
788        Some(&json!({"action": "start", "timeout": 60})),
789        &format!("start {name}"),
790        Duration::from_secs(300),
791    )?;
792    let sb = Sandbox::get(&s, &name)?;
793    ready::exec(&sb, deadline)?;
794    ready::network(&s, &name, &net, deadline, log)?;
795    ready::resolver(&sb, deadline)?;
796    s.push_file(
797        &name,
798        "/root/builder-image.sh",
799        RECIPE.as_bytes(),
800        0,
801        0,
802        0o755,
803    )?;
804    let code = stream_lines(
805        &sb,
806        &["/bin/sh", "/root/builder-image.sh"],
807        ExecOptions::default()
808            .env(
809                "PATH",
810                "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
811            )
812            .timeout(remaining(deadline, "preparing the builder image")?),
813        &mut |l| log(&format!("prepare: {l}")),
814    )?;
815    if code != 0 {
816        return Err(Error::invalid(format!(
817            "preparing the builder image failed (exit {code})"
818        )));
819    }
820    // A clean shutdown, so a VM's disk has everything the recipe wrote.
821    let stop = |force: bool| {
822        s.mutate(
823            "PUT",
824            &format!("/1.0/instances/{name}/state"),
825            Some(&json!({"action": "stop", "timeout": 120, "force": force})),
826            &format!("stop {name}"),
827            Duration::from_secs(180),
828        )
829    };
830    if let Err(e) = stop(false) {
831        log(&format!("prepare: a clean stop failed ({e}); forcing it"));
832        stop(true)?;
833    }
834    log(&format!("publishing {alias}"));
835    let versions: Value = json!({
836        "description": format!("isb builder ({})", if vm { "VM" } else { "container" }),
837        "isb.recipe": alias,
838    });
839    s.mutate(
840        "POST",
841        "/1.0/images",
842        Some(&json!({
843            "source": {"type": "instance", "name": name},
844            "properties": versions,
845            "aliases": [{"name": alias, "description": "isb builder image"}],
846        })),
847        &format!("publish {alias}"),
848        remaining(deadline, "publishing the builder image")?.min(Duration::from_secs(1800)),
849    )?;
850    Ok(alias)
851}
852
853/// The network a builder image is prepared on: the host's default managed
854/// bridge (`incusbr0` if there is one), never an org's.
855fn uplink_network(h: &Client) -> Result<String> {
856    let nets = h.get("/1.0/networks?recursion=1")?;
857    let managed: Vec<&str> = nets
858        .as_array()
859        .into_iter()
860        .flatten()
861        .filter(|n| n["managed"].as_bool() == Some(true) && n["type"] == "bridge")
862        .filter_map(|n| n["name"].as_str())
863        .filter(|n| !n.starts_with("isbbr"))
864        .collect();
865    managed
866        .iter()
867        .find(|n| **n == "incusbr0")
868        .or(managed.first())
869        .map(|s| s.to_string())
870        .ok_or_else(|| Error::invalid("no managed bridge to prepare the builder image on"))
871}
872
873/// A tar writer for a source tree: regular files, directories and
874/// symlinks (as links, never followed), with pax headers for long names.
875pub(crate) mod tar;
876
877#[cfg(test)]
878mod tests {
879    use super::*;
880
881    fn req(builder: Builder) -> BuildRequest {
882        BuildRequest {
883            org: OrgId::new("acme").unwrap(),
884            app: "web".into(),
885            context: std::env::temp_dir(),
886            subdir: None,
887            builder,
888            args: vec![],
889            tag: "v1".into(),
890            untrusted: false,
891            cache: None,
892        }
893    }
894
895    #[test]
896    fn requests_are_checked() {
897        assert!(check(&req(Builder::Railpack)).is_ok());
898        let mut r = req(Builder::Railpack);
899        r.app = "Web".into();
900        assert!(check(&r).is_err());
901        r = req(Builder::Railpack);
902        r.tag = "bad tag".into();
903        assert!(check(&r).is_err());
904        r = req(Builder::Railpack);
905        r.subdir = Some("../etc".into());
906        assert!(check(&r).is_err());
907        r = req(Builder::Dockerfile {
908            path: "/etc/passwd".into(),
909            target: None,
910        });
911        assert!(check(&r).is_err());
912        r = req(Builder::Railpack);
913        r.args = vec![("A B".into(), "x".into())];
914        assert!(check(&r).is_err());
915        r.args = vec![("A".into(), "x\ny".into())];
916        assert!(check(&r).is_err());
917        assert!(check(&req(Builder::Buildpacks { builder: None })).is_err());
918        r = req(Builder::Railpack);
919        r.context = "relative".into();
920        assert!(check(&r).is_err());
921    }
922
923    #[test]
924    fn builder_json_matches_the_contract() {
925        let b: Builder = serde_json::from_str(r#"{"type":"dockerfile"}"#).unwrap();
926        assert_eq!(
927            b,
928            Builder::Dockerfile {
929                path: "Dockerfile".into(),
930                target: None
931            }
932        );
933        let b: Builder = serde_json::from_str(r#"{"type":"railpack"}"#).unwrap();
934        assert_eq!(b.name(), "railpack");
935        assert!(builder_alias(false).starts_with("isb-builder/"));
936        assert!(builder_alias(true).starts_with("isb-builder-vm/"));
937        assert!(sandbox_name("my.app").starts_with("build-my-app-"));
938        assert!(sandbox_name(&"a".repeat(40)).len() <= 63);
939    }
940
941    #[test]
942    fn source_tars_round_trip_through_the_layout_reader() {
943        let d = tempfile::tempdir().unwrap();
944        let root = d.path().join("src");
945        std::fs::create_dir_all(root.join("sub")).unwrap();
946        std::fs::write(root.join("a.txt"), "hello").unwrap();
947        let long = "x".repeat(150);
948        std::fs::write(root.join("sub").join(&long), "long").unwrap();
949        std::os::unix::fs::symlink("/etc/shadow", root.join("link")).unwrap();
950        let mut buf = Vec::new();
951        tar::write_dir(&mut buf, &root).unwrap();
952        let p = d.path().join("x.tar");
953        std::fs::write(&p, &buf).unwrap();
954        // The registry's tar reader is the same format's other half.
955        let l = crate::registry::oci::Layout::open(&p).unwrap();
956        let _ = l;
957        // And the system tar agrees, when there is one.
958        if let Ok(out) = std::process::Command::new("tar")
959            .arg("-tvf")
960            .arg(&p)
961            .output()
962        {
963            if out.status.success() {
964                let t = String::from_utf8_lossy(&out.stdout);
965                assert!(t.contains("a.txt"), "{t}");
966                assert!(t.contains(&format!("sub/{long}")), "{t}");
967                assert!(
968                    t.contains("link -> /etc/shadow"),
969                    "symlinks stay links: {t}"
970                );
971            }
972        }
973    }
974}