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 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 (the proto's `source` oneof; one arm today).
19    #[serde(skip_serializing_if = "Option::is_none")]
20    pub base: Option<BaseImage>,
21    /// Ordered build steps applied on top of the base image.
22    #[serde(skip_serializing_if = "Vec::is_empty")]
23    pub build_steps: Vec<ImageBuildStep>,
24    /// Environment variables baked into the image.
25    #[serde(skip_serializing_if = "HashMap::is_empty")]
26    pub env: HashMap<String, String>,
27    /// Target CPU architecture; unset lets the backend choose.
28    #[serde(skip_serializing_if = "ImageArchitecture::is_unspecified")]
29    pub architecture: ImageArchitecture,
30    /// Exact Python version to install as `python3`; empty uses the builder default.
31    #[serde(skip_serializing_if = "String::is_empty")]
32    pub python_version: String,
33    /// Writable root filesystem; unspecified preserves the ext4 default.
34    #[serde(skip_serializing_if = "ImageFilesystem::is_unspecified")]
35    pub filesystem: ImageFilesystem,
36}
37
38/// A supported base image. Absence of a base is `None`, not a variant here.
39#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
40pub enum BaseImage {
41    /// Debian.
42    #[serde(rename = "BASE_IMAGE_DEBIAN")]
43    Debian,
44    /// Debian plus a baked dev layer (node LTS, build tools, editor-server OS
45    /// prerequisites). Prebuilt only: supports no python_version, build steps,
46    /// or env.
47    #[serde(rename = "BASE_IMAGE_DEVBOX")]
48    Devbox,
49}
50
51/// A target CPU architecture.
52#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
53pub enum ImageArchitecture {
54    /// Unset; the backend picks a default.
55    #[default]
56    #[serde(rename = "IMAGE_ARCHITECTURE_UNSPECIFIED")]
57    Unspecified,
58    /// x86-64.
59    #[serde(rename = "IMAGE_ARCHITECTURE_AMD64")]
60    Amd64,
61    /// ARM64.
62    #[serde(rename = "IMAGE_ARCHITECTURE_ARM64")]
63    Arm64,
64}
65
66impl ImageArchitecture {
67    // Takes `&self` because serde's `skip_serializing_if` requires a `fn(&T)`.
68    #[allow(clippy::trivially_copy_pass_by_ref)]
69    fn is_unspecified(&self) -> bool {
70        matches!(self, ImageArchitecture::Unspecified)
71    }
72}
73
74/// Writable root filesystem for the image artifact.
75#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
76pub enum ImageFilesystem {
77    /// Unset; equivalent to ext4 for backward compatibility.
78    #[default]
79    #[serde(rename = "IMAGE_FILESYSTEM_UNSPECIFIED")]
80    Unspecified,
81    /// ext4 writable root filesystem.
82    #[serde(rename = "IMAGE_FILESYSTEM_EXT4")]
83    Ext4,
84    /// Btrfs writable root filesystem.
85    #[serde(rename = "IMAGE_FILESYSTEM_BTRFS")]
86    Btrfs,
87}
88
89impl ImageFilesystem {
90    #[allow(clippy::trivially_copy_pass_by_ref)]
91    fn is_unspecified(&self) -> bool {
92        matches!(self, ImageFilesystem::Unspecified)
93    }
94}
95
96/// One build step: exactly one operation (the proto's `step` oneof).
97#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
98#[serde(rename_all = "camelCase")]
99pub enum ImageBuildStep {
100    /// Install system packages with apt.
101    AptInstall(PackageInstall),
102    /// Install Python packages with pip.
103    PipInstall(PackageInstall),
104    /// Run a shell command.
105    RunCommand(RunCommand),
106    /// Add one local file, referenced by its content hash.
107    AddLocalFile(AddLocalFile),
108    /// Add a tree of local files.
109    AddLocalDir(AddLocalDir),
110}
111
112/// A set of packages to install.
113#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
114#[serde(default)]
115pub struct PackageInstall {
116    /// Package names.
117    #[serde(skip_serializing_if = "Vec::is_empty")]
118    pub packages: Vec<String>,
119}
120
121/// A shell command to run during the build.
122#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
123#[serde(default)]
124pub struct RunCommand {
125    /// The command line.
126    #[serde(skip_serializing_if = "String::is_empty")]
127    pub command: String,
128}
129
130/// One local file copied into the image at `remote_path`.
131#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
132#[serde(rename_all = "camelCase", default)]
133pub struct AddLocalFile {
134    /// Lowercase hex sha256 of the file contents.
135    #[serde(skip_serializing_if = "String::is_empty")]
136    pub content_sha256: String,
137    /// Absolute path inside the rootfs.
138    #[serde(skip_serializing_if = "String::is_empty")]
139    pub remote_path: String,
140    /// Permission bits (low 9); `0` means the builder default (0644).
141    #[serde(skip_serializing_if = "is_zero")]
142    pub mode: u32,
143}
144
145/// A tree of local files copied into the image under `remote_path`.
146#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
147#[serde(rename_all = "camelCase", default)]
148pub struct AddLocalDir {
149    /// Absolute path inside the rootfs where the files land.
150    #[serde(skip_serializing_if = "String::is_empty")]
151    pub remote_path: String,
152    /// The files in the tree.
153    #[serde(skip_serializing_if = "Vec::is_empty")]
154    pub files: Vec<AddLocalDirFile>,
155}
156
157/// One file within an [`AddLocalDir`].
158#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
159#[serde(rename_all = "camelCase", default)]
160pub struct AddLocalDirFile {
161    /// Path relative to the dir's `remote_path`.
162    #[serde(skip_serializing_if = "String::is_empty")]
163    pub relative_path: String,
164    /// Lowercase hex sha256 of the file contents.
165    #[serde(skip_serializing_if = "String::is_empty")]
166    pub content_sha256: 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// Takes `&u32` because serde's `skip_serializing_if` requires a `fn(&T)`.
173#[allow(clippy::trivially_copy_pass_by_ref)]
174fn is_zero(n: &u32) -> bool {
175    *n == 0
176}