1use 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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
47#[serde(tag = "type", rename_all = "lowercase")]
48pub enum Builder {
49 Railpack,
51 Nixpacks,
52 Dockerfile {
54 #[serde(default = "default_dockerfile")]
55 path: String,
56 #[serde(default, skip_serializing_if = "Option::is_none")]
57 target: Option<String>,
58 },
59 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#[derive(Debug, Clone)]
84pub struct BuildRequest {
85 pub org: OrgId,
86 pub app: String,
89 pub context: PathBuf,
92 pub subdir: Option<String>,
94 pub builder: Builder,
95 pub args: Vec<(String, String)>,
97 pub tag: String,
99 pub untrusted: bool,
101 pub cache: Option<String>,
105}
106
107#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
109pub struct BuiltImage {
110 pub image: String,
114 pub digest: String,
117}
118
119#[derive(Debug, Clone)]
122pub struct BuildOptions {
123 pub timeout: Duration,
125 pub cpus: u32,
128 pub memory: String,
129 pub cache_size: String,
132 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
153const 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");
157const BASE_IMAGE: &str = "images:ubuntu/24.04";
159const CACHE_DEVICE: &str = "isbcache";
161
162pub 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
174pub fn run(base: &Client, req: &BuildRequest, log: &mut dyn FnMut(&str)) -> Result<BuiltImage> {
177 run_with(base, req, &BuildOptions::default(), log)
178}
179
180#[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 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
316fn 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
420fn 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
434pub 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
444fn 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 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 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 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
539fn 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 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 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
581fn 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
651fn 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
680fn 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
703struct 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
719static PREPARE: Mutex<()> = Mutex::new(());
721
722#[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 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
853fn 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
873pub(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 let l = crate::registry::oci::Layout::open(&p).unwrap();
956 let _ = l;
957 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}