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}
34
35/// A supported base image.
36#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
37pub enum BaseImage {
38    /// Unset; the backend rejects a create without a base.
39    #[default]
40    #[serde(rename = "BASE_IMAGE_UNSPECIFIED")]
41    Unspecified,
42    /// Debian.
43    #[serde(rename = "BASE_IMAGE_DEBIAN")]
44    Debian,
45    /// Debian plus a baked dev layer (node LTS, build tools, editor-server OS
46    /// prerequisites). Prebuilt only: supports no python_version, build steps,
47    /// or env.
48    #[serde(rename = "BASE_IMAGE_DEVBOX")]
49    Devbox,
50}
51
52/// A target CPU architecture.
53#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
54pub enum ImageArchitecture {
55    /// Unset; the backend picks a default.
56    #[default]
57    #[serde(rename = "IMAGE_ARCHITECTURE_UNSPECIFIED")]
58    Unspecified,
59    /// x86-64.
60    #[serde(rename = "IMAGE_ARCHITECTURE_AMD64")]
61    Amd64,
62    /// ARM64.
63    #[serde(rename = "IMAGE_ARCHITECTURE_ARM64")]
64    Arm64,
65}
66
67impl ImageArchitecture {
68    // Takes `&self` because serde's `skip_serializing_if` requires a `fn(&T)`.
69    #[allow(clippy::trivially_copy_pass_by_ref)]
70    fn is_unspecified(&self) -> bool {
71        matches!(self, ImageArchitecture::Unspecified)
72    }
73}
74
75/// One build step: exactly one operation (the proto's `step` oneof).
76#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
77#[serde(rename_all = "camelCase")]
78pub enum ImageBuildStep {
79    /// Install system packages with apt.
80    AptInstall(PackageInstall),
81    /// Install Python packages with pip.
82    PipInstall(PackageInstall),
83    /// Run a shell command.
84    RunCommand(RunCommand),
85    /// Add one local file, referenced by its content hash.
86    AddLocalFile(AddLocalFile),
87    /// Add a tree of local files.
88    AddLocalDir(AddLocalDir),
89}
90
91/// A set of packages to install.
92#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
93#[serde(default)]
94pub struct PackageInstall {
95    /// Package names.
96    #[serde(skip_serializing_if = "Vec::is_empty")]
97    pub packages: Vec<String>,
98}
99
100/// A shell command to run during the build.
101#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
102#[serde(default)]
103pub struct RunCommand {
104    /// The command line.
105    #[serde(skip_serializing_if = "String::is_empty")]
106    pub command: String,
107}
108
109/// One local file copied into the image at `remote_path`.
110#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
111#[serde(rename_all = "camelCase", default)]
112pub struct AddLocalFile {
113    /// Lowercase hex sha256 of the file contents.
114    #[serde(skip_serializing_if = "String::is_empty")]
115    pub content_sha256: String,
116    /// Absolute path inside the rootfs.
117    #[serde(skip_serializing_if = "String::is_empty")]
118    pub remote_path: String,
119    /// Permission bits (low 9); `0` means the builder default (0644).
120    #[serde(skip_serializing_if = "is_zero")]
121    pub mode: u32,
122}
123
124/// A tree of local files copied into the image under `remote_path`.
125#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
126#[serde(rename_all = "camelCase", default)]
127pub struct AddLocalDir {
128    /// Absolute path inside the rootfs where the files land.
129    #[serde(skip_serializing_if = "String::is_empty")]
130    pub remote_path: String,
131    /// The files in the tree.
132    #[serde(skip_serializing_if = "Vec::is_empty")]
133    pub files: Vec<AddLocalDirFile>,
134}
135
136/// One file within an [`AddLocalDir`].
137#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
138#[serde(rename_all = "camelCase", default)]
139pub struct AddLocalDirFile {
140    /// Path relative to the dir's `remote_path`.
141    #[serde(skip_serializing_if = "String::is_empty")]
142    pub relative_path: String,
143    /// Lowercase hex sha256 of the file contents.
144    #[serde(skip_serializing_if = "String::is_empty")]
145    pub content_sha256: String,
146    /// Permission bits (low 9); `0` means the builder default (0644).
147    #[serde(skip_serializing_if = "is_zero")]
148    pub mode: u32,
149}
150
151// Takes `&u32` because serde's `skip_serializing_if` requires a `fn(&T)`.
152#[allow(clippy::trivially_copy_pass_by_ref)]
153fn is_zero(n: &u32) -> bool {
154    *n == 0
155}