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`.
19 #[serde(skip_serializing_if = "Option::is_none")]
20 pub base: Option<BaseImage>,
21 /// Your own image used as the root filesystem; mutually exclusive with
22 /// `base`. Names a Debian- or Ubuntu-based image on a supported public
23 /// registry (`docker.io`, `ghcr.io`, `public.ecr.aws`, or `quay.io`) by
24 /// tag, digest, or bare name (a bare name means the `latest` tag). Once
25 /// Sail has a built image for a tag, later builds keep using that image
26 /// after the tag moves. If no image was ever built from a lookup, Sail
27 /// may look the tag up again later. A forced build
28 /// ([`BuildMode::ForceBuild`](crate::imagebuild::BuildMode)) looks the
29 /// tag up again and builds the version it points at now. A digest names
30 /// exactly one image, so it never moves.
31 #[serde(skip_serializing_if = "Option::is_none")]
32 pub oci: Option<OciImage>,
33 /// Ordered build steps applied on top of the image source.
34 #[serde(skip_serializing_if = "Vec::is_empty")]
35 pub build_steps: Vec<ImageBuildStep>,
36 /// Environment variables baked into the image.
37 #[serde(skip_serializing_if = "HashMap::is_empty")]
38 pub env: HashMap<String, String>,
39 /// Target CPU architecture. Unset means amd64 with `base`, and with `oci`
40 /// means whichever architecture the registry image was built for (amd64
41 /// when it was built for both). Setting it with `oci` requires the image
42 /// to provide that architecture.
43 #[serde(skip_serializing_if = "ImageArchitecture::is_unspecified")]
44 pub architecture: ImageArchitecture,
45 /// Exact Python version to install as `python3`; empty uses the builder
46 /// default. Not accepted with `oci`: a registry image keeps its own
47 /// `python3`.
48 #[serde(skip_serializing_if = "String::is_empty")]
49 pub python_version: String,
50 /// Writable root filesystem; unspecified preserves the ext4 default.
51 #[serde(skip_serializing_if = "ImageFilesystem::is_unspecified")]
52 pub filesystem: ImageFilesystem,
53}
54
55/// Your own image from a registry, named by reference.
56#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
57#[serde(default)]
58pub struct OciImage {
59 /// Registry reference, e.g. `docker.io/library/ubuntu:24.04` or
60 /// `docker.io/library/ubuntu@sha256:<64 hex>`.
61 #[serde(rename = "ref", skip_serializing_if = "String::is_empty")]
62 pub reference: String,
63}
64
65/// A supported base image. Absence of a base is `None`, not a variant here.
66#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
67pub enum BaseImage {
68 /// Debian.
69 #[serde(rename = "BASE_IMAGE_DEBIAN")]
70 Debian,
71 /// Debian plus a baked dev layer (node LTS, build tools, editor-server OS
72 /// prerequisites). Prebuilt only: supports no python_version, build steps,
73 /// or env.
74 #[serde(rename = "BASE_IMAGE_DEVBOX")]
75 Devbox,
76}
77
78/// A target CPU architecture.
79#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
80pub enum ImageArchitecture {
81 /// Unset; see [`ImageSpec::architecture`] for what each source defaults to.
82 #[default]
83 #[serde(rename = "IMAGE_ARCHITECTURE_UNSPECIFIED")]
84 Unspecified,
85 /// x86-64.
86 #[serde(rename = "IMAGE_ARCHITECTURE_AMD64")]
87 Amd64,
88 /// ARM64.
89 #[serde(rename = "IMAGE_ARCHITECTURE_ARM64")]
90 Arm64,
91}
92
93impl ImageArchitecture {
94 // Takes `&self` because serde's `skip_serializing_if` requires a `fn(&T)`.
95 #[allow(clippy::trivially_copy_pass_by_ref)]
96 fn is_unspecified(&self) -> bool {
97 matches!(self, ImageArchitecture::Unspecified)
98 }
99}
100
101/// Writable root filesystem for the image artifact.
102#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
103pub enum ImageFilesystem {
104 /// Unset; equivalent to ext4 for backward compatibility.
105 #[default]
106 #[serde(rename = "IMAGE_FILESYSTEM_UNSPECIFIED")]
107 Unspecified,
108 /// ext4 writable root filesystem.
109 #[serde(rename = "IMAGE_FILESYSTEM_EXT4")]
110 Ext4,
111 /// Btrfs writable root filesystem.
112 #[serde(rename = "IMAGE_FILESYSTEM_BTRFS")]
113 Btrfs,
114}
115
116impl ImageFilesystem {
117 #[allow(clippy::trivially_copy_pass_by_ref)]
118 fn is_unspecified(&self) -> bool {
119 matches!(self, ImageFilesystem::Unspecified)
120 }
121}
122
123/// One build step: exactly one operation (the proto's `step` oneof).
124#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
125#[serde(rename_all = "camelCase")]
126pub enum ImageBuildStep {
127 /// Install system packages with apt.
128 AptInstall(PackageInstall),
129 /// Install Python packages with pip.
130 PipInstall(PackageInstall),
131 /// Run a shell command.
132 RunCommand(RunCommand),
133 /// Add one local file, referenced by its content hash.
134 AddLocalFile(AddLocalFile),
135 /// Add a tree of local files.
136 AddLocalDir(AddLocalDir),
137}
138
139/// A set of packages to install.
140#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
141#[serde(default)]
142pub struct PackageInstall {
143 /// Package names.
144 #[serde(skip_serializing_if = "Vec::is_empty")]
145 pub packages: Vec<String>,
146}
147
148/// A shell command to run during the build.
149#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
150#[serde(default)]
151pub struct RunCommand {
152 /// The command line.
153 #[serde(skip_serializing_if = "String::is_empty")]
154 pub command: String,
155}
156
157/// One local file copied into the image at `remote_path`.
158#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
159#[serde(rename_all = "camelCase", default)]
160pub struct AddLocalFile {
161 /// Lowercase hex sha256 of the file contents.
162 #[serde(skip_serializing_if = "String::is_empty")]
163 pub content_sha256: String,
164 /// Absolute path inside the rootfs.
165 #[serde(skip_serializing_if = "String::is_empty")]
166 pub remote_path: String,
167 /// Permission bits (low 9); `0` means the builder default (0644).
168 #[serde(skip_serializing_if = "is_zero")]
169 pub mode: u32,
170}
171
172/// A tree of local files copied into the image under `remote_path`.
173#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
174#[serde(rename_all = "camelCase", default)]
175pub struct AddLocalDir {
176 /// Absolute path inside the rootfs where the files land.
177 #[serde(skip_serializing_if = "String::is_empty")]
178 pub remote_path: String,
179 /// The files in the tree.
180 #[serde(skip_serializing_if = "Vec::is_empty")]
181 pub files: Vec<AddLocalDirFile>,
182}
183
184/// One file within an [`AddLocalDir`].
185#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
186#[serde(rename_all = "camelCase", default)]
187pub struct AddLocalDirFile {
188 /// Path relative to the dir's `remote_path`.
189 #[serde(skip_serializing_if = "String::is_empty")]
190 pub relative_path: String,
191 /// Lowercase hex sha256 of the file contents.
192 #[serde(skip_serializing_if = "String::is_empty")]
193 pub content_sha256: String,
194 /// Permission bits (low 9); `0` means the builder default (0644).
195 #[serde(skip_serializing_if = "is_zero")]
196 pub mode: u32,
197}
198
199// Takes `&u32` because serde's `skip_serializing_if` requires a `fn(&T)`.
200#[allow(clippy::trivially_copy_pass_by_ref)]
201fn is_zero(n: &u32) -> bool {
202 *n == 0
203}