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`.
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}