Skip to main content

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}