sail/image.rs
1//! Typed image specification for
2//! [`CreateSailboxRequest`](crate::sailbox::types::CreateSailboxRequest).
3//!
4//! These mirror the `image.v1.ImageSpec` proto and serialize to the canonical
5//! proto-JSON the backend accepts: camelCase field names, enum value names, and
6//! each oneof arm as a direct field. The higher-level image-building DSL
7//! (reading local files, hashing contents) lives in the language wrapper; this
8//! is the typed wire spec the core sends.
9
10use std::collections::HashMap;
11
12use serde::{Deserialize, Serialize};
13
14/// A Sailbox image: a base or registry image plus ordered build steps.
15#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
16#[serde(rename_all = "camelCase", default)]
17pub struct ImageSpec {
18 /// Base image to build on; mutually exclusive with `oci` and
19 /// `dockerfile`.
20 #[serde(skip_serializing_if = "Option::is_none")]
21 pub base: Option<BaseImage>,
22 /// Your own image used as the root filesystem; mutually exclusive with
23 /// `base` and `dockerfile`. Names a Debian- or Ubuntu-based image on a
24 /// supported public
25 /// registry (`docker.io`, `ghcr.io`, `public.ecr.aws`, or `quay.io`) by
26 /// tag, digest, or bare name (a bare name means the `latest` tag). A tag
27 /// is pinned for your organization once an image has been built from
28 /// it: later builds keep getting that version, even if the tag moves
29 /// upstream. A forced build
30 /// ([`BuildMode::ForceBuild`](crate::imagebuild::BuildMode)) looks the
31 /// tag up again and moves the pin for your whole organization. If
32 /// forced builds of the same tag overlap, the last-requested one
33 /// that succeeds decides what the tag means, no matter which build
34 /// finishes first. A
35 /// digest names exactly one image, so it never moves.
36 #[serde(skip_serializing_if = "Option::is_none")]
37 pub oci: Option<OciImage>,
38 /// Your own Dockerfile built into the image; mutually exclusive with
39 /// `base` and `oci`. Every image its `FROM` (and `COPY --from`)
40 /// instructions name must live on a supported public registry
41 /// (`docker.io`, `ghcr.io`, `public.ecr.aws`, or `quay.io`; a short name
42 /// like `python:3.12` means `docker.io/library/python:3.12`). Each named
43 /// image is pinned to the version its tag pointed at the first time your
44 /// organization used it, and those pinned versions become part of the
45 /// built image's identity, so rebuilding the same spec reuses the same
46 /// image even after a tag moves. A forced build
47 /// ([`BuildMode::ForceBuild`](crate::imagebuild::BuildMode)) looks the
48 /// tags up again and builds what they point at now.
49 #[serde(skip_serializing_if = "Option::is_none")]
50 pub dockerfile: Option<DockerfileImage>,
51 /// Ordered build steps applied on top of the image source.
52 #[serde(skip_serializing_if = "Vec::is_empty")]
53 pub build_steps: Vec<ImageBuildStep>,
54 /// Environment variables baked into the image.
55 #[serde(skip_serializing_if = "HashMap::is_empty")]
56 pub env: HashMap<String, String>,
57 /// Target CPU architecture. Unset means amd64 with `base` and
58 /// `dockerfile`, and with `oci` means whichever architecture the registry
59 /// image was built for (amd64 when it was built for both). Setting it
60 /// with `oci` requires the image to provide that architecture.
61 #[serde(skip_serializing_if = "ImageArchitecture::is_unspecified")]
62 pub architecture: ImageArchitecture,
63 /// Exact Python version to install as `python3`; empty uses the builder
64 /// default. Not accepted with `oci` or `dockerfile`: a pinned
65 /// interpreter would shadow the Python the image was built around.
66 #[serde(skip_serializing_if = "String::is_empty")]
67 pub python_version: String,
68 /// Writable root filesystem; unspecified preserves the ext4 default.
69 #[serde(skip_serializing_if = "ImageFilesystem::is_unspecified")]
70 pub filesystem: ImageFilesystem,
71}
72
73/// Your own image from a registry, named by reference.
74#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
75#[serde(default)]
76pub struct OciImage {
77 /// Registry reference, e.g. `docker.io/library/ubuntu:24.04` or
78 /// `docker.io/library/ubuntu@sha256:<64 hex>`.
79 #[serde(rename = "ref", skip_serializing_if = "String::is_empty")]
80 pub reference: String,
81}
82
83/// Your own Dockerfile built into an image, with its resolved build context.
84#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
85#[serde(rename_all = "camelCase", default)]
86pub struct DockerfileImage {
87 /// Full Dockerfile text.
88 #[serde(skip_serializing_if = "String::is_empty")]
89 pub dockerfile: String,
90 /// Content manifest of the build context the Dockerfile's `COPY` and
91 /// `ADD` instructions read from; empty builds without a context.
92 #[serde(skip_serializing_if = "Vec::is_empty")]
93 pub context_files: Vec<AddLocalDirFile>,
94 /// Values for the Dockerfile's `ARG` instructions, like `--build-arg`.
95 #[serde(skip_serializing_if = "HashMap::is_empty")]
96 pub build_args: HashMap<String, String>,
97 /// Every directory in the context with its mode, so `COPY` of an empty
98 /// directory works and directory modes survive like they do in a docker
99 /// build.
100 #[serde(skip_serializing_if = "Vec::is_empty")]
101 pub context_dirs: Vec<DockerfileContextDir>,
102 /// Symbolic links in the context, carried as links the way a docker
103 /// build context carries them.
104 #[serde(skip_serializing_if = "Vec::is_empty")]
105 pub context_symlinks: Vec<DockerfileContextSymlink>,
106 /// The version each external image reference resolved to when this spec
107 /// was built, filled in on the spec a completed build returns. A spec
108 /// carrying these keeps naming the image its build produced, even after
109 /// a forced build moves what the references mean for your organization;
110 /// a forced build looks every reference up again instead. Empty means
111 /// the build looks the references up.
112 #[serde(skip_serializing_if = "Vec::is_empty")]
113 pub pinned_from: Vec<DockerfileFromResolution>,
114}
115
116/// What one external image reference in a Dockerfile resolved to when an
117/// image was built.
118#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
119#[serde(rename_all = "camelCase", default)]
120pub struct DockerfileFromResolution {
121 /// The reference as the Dockerfile's `FROM` or `COPY --from` names it.
122 #[serde(skip_serializing_if = "String::is_empty")]
123 pub reference: String,
124 /// The digest-pinned form of the same reference the build used.
125 #[serde(skip_serializing_if = "String::is_empty")]
126 pub digest_ref: String,
127}
128
129/// One directory of a Dockerfile build context.
130#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
131#[serde(rename_all = "camelCase", default)]
132pub struct DockerfileContextDir {
133 /// Slash-separated path relative to the context root.
134 #[serde(skip_serializing_if = "String::is_empty")]
135 pub relative_path: String,
136 /// Directory mode permission bits; zero is "unset".
137 #[serde(skip_serializing_if = "is_zero")]
138 pub mode: u32,
139}
140
141/// One symbolic link of a Dockerfile build context.
142#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
143#[serde(rename_all = "camelCase", default)]
144pub struct DockerfileContextSymlink {
145 /// Slash-separated path relative to the context root.
146 #[serde(skip_serializing_if = "String::is_empty")]
147 pub relative_path: String,
148 /// Raw link target, exactly as the link stores it; resolved only inside
149 /// the built image.
150 #[serde(skip_serializing_if = "String::is_empty")]
151 pub target: String,
152}
153
154/// A supported base image. Absence of a base is `None`, not a variant here.
155#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
156pub enum BaseImage {
157 /// Debian.
158 #[serde(rename = "BASE_IMAGE_DEBIAN")]
159 Debian,
160 /// Debian plus a baked dev layer (node LTS, build tools, Docker,
161 /// editor-server OS prerequisites). The Docker daemon starts
162 /// automatically when the Sailbox boots and keeps running across
163 /// sleeps, and can take a few seconds to accept commands right after
164 /// boot. If it stops, it is not restarted automatically. Prebuilt
165 /// only: supports no python_version, build steps, or env.
166 #[serde(rename = "BASE_IMAGE_DEVBOX")]
167 Devbox,
168}
169
170/// A target CPU architecture.
171#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
172pub enum ImageArchitecture {
173 /// Unset; see [`ImageSpec::architecture`] for what each source defaults to.
174 #[default]
175 #[serde(rename = "IMAGE_ARCHITECTURE_UNSPECIFIED")]
176 Unspecified,
177 /// x86-64.
178 #[serde(rename = "IMAGE_ARCHITECTURE_AMD64")]
179 Amd64,
180 /// ARM64.
181 #[serde(rename = "IMAGE_ARCHITECTURE_ARM64")]
182 Arm64,
183}
184
185impl ImageArchitecture {
186 // Takes `&self` because serde's `skip_serializing_if` requires a `fn(&T)`.
187 #[allow(clippy::trivially_copy_pass_by_ref)]
188 fn is_unspecified(&self) -> bool {
189 matches!(self, ImageArchitecture::Unspecified)
190 }
191}
192
193/// Writable root filesystem for the image artifact.
194#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
195pub enum ImageFilesystem {
196 /// Unset; equivalent to ext4 for backward compatibility.
197 #[default]
198 #[serde(rename = "IMAGE_FILESYSTEM_UNSPECIFIED")]
199 Unspecified,
200 /// ext4 writable root filesystem.
201 #[serde(rename = "IMAGE_FILESYSTEM_EXT4")]
202 Ext4,
203 /// Btrfs writable root filesystem.
204 #[serde(rename = "IMAGE_FILESYSTEM_BTRFS")]
205 Btrfs,
206}
207
208impl ImageFilesystem {
209 #[allow(clippy::trivially_copy_pass_by_ref)]
210 fn is_unspecified(&self) -> bool {
211 matches!(self, ImageFilesystem::Unspecified)
212 }
213}
214
215/// One build step: exactly one operation (the proto's `step` oneof).
216#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
217#[serde(rename_all = "camelCase")]
218pub enum ImageBuildStep {
219 /// Install system packages with apt.
220 AptInstall(PackageInstall),
221 /// Install Python packages with pip.
222 PipInstall(PackageInstall),
223 /// Run a shell command.
224 RunCommand(RunCommand),
225 /// Add one local file, referenced by its content hash.
226 AddLocalFile(AddLocalFile),
227 /// Add a tree of local files.
228 AddLocalDir(AddLocalDir),
229}
230
231/// A set of packages to install.
232#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
233#[serde(default)]
234pub struct PackageInstall {
235 /// Package names.
236 #[serde(skip_serializing_if = "Vec::is_empty")]
237 pub packages: Vec<String>,
238}
239
240/// A shell command to run during the build.
241#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
242#[serde(default)]
243pub struct RunCommand {
244 /// The command line.
245 #[serde(skip_serializing_if = "String::is_empty")]
246 pub command: String,
247}
248
249/// One local file copied into the image at `remote_path`.
250#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
251#[serde(rename_all = "camelCase", default)]
252pub struct AddLocalFile {
253 /// Lowercase hex sha256 of the file contents.
254 #[serde(skip_serializing_if = "String::is_empty")]
255 pub content_sha256: String,
256 /// Absolute path inside the rootfs.
257 #[serde(skip_serializing_if = "String::is_empty")]
258 pub remote_path: String,
259 /// Permission bits (low 9); `0` means the builder default (0644).
260 #[serde(skip_serializing_if = "is_zero")]
261 pub mode: u32,
262}
263
264/// A tree of local files copied into the image under `remote_path`.
265#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
266#[serde(rename_all = "camelCase", default)]
267pub struct AddLocalDir {
268 /// Absolute path inside the rootfs where the files land.
269 #[serde(skip_serializing_if = "String::is_empty")]
270 pub remote_path: String,
271 /// The files in the tree.
272 #[serde(skip_serializing_if = "Vec::is_empty")]
273 pub files: Vec<AddLocalDirFile>,
274}
275
276/// One file within an [`AddLocalDir`].
277#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
278#[serde(rename_all = "camelCase", default)]
279pub struct AddLocalDirFile {
280 /// Path relative to the dir's `remote_path`.
281 #[serde(skip_serializing_if = "String::is_empty")]
282 pub relative_path: String,
283 /// Lowercase hex sha256 of the file contents.
284 #[serde(skip_serializing_if = "String::is_empty")]
285 pub content_sha256: String,
286 /// Permission bits (low 9); `0` means the builder default (0644).
287 #[serde(skip_serializing_if = "is_zero")]
288 pub mode: u32,
289}
290
291// Takes `&u32` because serde's `skip_serializing_if` requires a `fn(&T)`.
292#[allow(clippy::trivially_copy_pass_by_ref)]
293fn is_zero(n: &u32) -> bool {
294 *n == 0
295}