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