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