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;
41pub mod workspace_image;
42#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
44#[serde(tag = "type", rename_all = "lowercase")]
45pub enum Builder {
46 Railpack,
48 Nixpacks,
49 Dockerfile {
51 #[serde(default = "default_dockerfile")]
52 path: String,
53 #[serde(default, skip_serializing_if = "Option::is_none")]
54 target: Option<String>,
55 },
56 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#[derive(Debug, Clone)]
81pub struct BuildRequest {
82 pub org: OrgId,
83 pub app: String,
86 pub context: PathBuf,
89 pub subdir: Option<String>,
91 pub builder: Builder,
92 pub args: Vec<(String, String)>,
94 pub tag: String,
96 pub untrusted: bool,
98 pub cache: Option<String>,
102}
103
104#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
106pub struct BuiltImage {
107 pub image: String,
111 pub digest: String,
114}
115
116#[derive(Debug, Clone)]
119pub struct BuildOptions {
120 pub timeout: Duration,
122 pub cpus: u32,
125 pub memory: String,
126 pub cache_size: String,
129 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
150const 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");
154const BASE_IMAGE: &str = "images:ubuntu/24.04";
156const CACHE_DEVICE: &str = "isbcache";
158
159pub 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
171pub fn run(base: &Client, req: &BuildRequest, log: &mut dyn FnMut(&str)) -> Result<BuiltImage> {
174 run_with(base, req, &BuildOptions::default(), log)
175}
176
177#[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
310fn 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
414fn 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
428pub 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
438fn 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 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 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 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
533fn 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 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 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
575fn 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
645fn 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
674fn 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
697struct 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
713static PREPARE: Mutex<()> = Mutex::new(());
715
716#[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 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
845fn 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
865pub(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 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 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 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 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 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 }
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 let l = crate::registry::oci::Layout::open(&p).unwrap();
1075 let _ = l;
1076 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}