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