sail-rs 0.7.1

Official Rust SDK for Sail: create and drive Sailboxes (sandboxed cloud VMs) with lifecycle, streaming exec, file transfer, and ingress.
Documentation
//! Typed image specification for
//! [`CreateSailboxRequest`](crate::sailbox::types::CreateSailboxRequest).
//!
//! These mirror the `image.v1.ImageSpec` proto and serialize to the canonical
//! proto-JSON the backend accepts: camelCase field names, enum value names, and
//! each oneof arm as a direct field. The higher-level image-building DSL
//! (reading local files, hashing contents) lives in the language wrapper; this
//! is the typed wire spec the core sends.

use std::collections::HashMap;

use serde::{Deserialize, Serialize};

/// A Sailbox image: a base or registry image plus ordered build steps.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", default)]
pub struct ImageSpec {
    /// Base image to build on; mutually exclusive with `oci` and
    /// `dockerfile`.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub base: Option<BaseImage>,
    /// Your own image used as the root filesystem; mutually exclusive with
    /// `base` and `dockerfile`. Names a Debian- or Ubuntu-based image on a
    /// supported public
    /// registry (`docker.io`, `ghcr.io`, `public.ecr.aws`, or `quay.io`) by
    /// tag, digest, or bare name (a bare name means the `latest` tag). A tag
    /// is pinned for your organization once an image has been built from
    /// it: later builds keep getting that version, even if the tag moves
    /// upstream. A forced build
    /// ([`BuildMode::ForceBuild`](crate::imagebuild::BuildMode)) looks the
    /// tag up again and moves the pin for your whole organization. If
    /// forced builds of the same tag overlap, the last-requested one
    /// that succeeds decides what the tag means, no matter which build
    /// finishes first. A
    /// digest names exactly one image, so it never moves.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub oci: Option<OciImage>,
    /// Your own Dockerfile built into the image; mutually exclusive with
    /// `base` and `oci`. Every image its `FROM` (and `COPY --from`)
    /// instructions name must live on a supported public registry
    /// (`docker.io`, `ghcr.io`, `public.ecr.aws`, or `quay.io`; a short name
    /// like `python:3.12` means `docker.io/library/python:3.12`). Each named
    /// image is pinned to the version its tag pointed at the first time your
    /// organization used it, and those pinned versions become part of the
    /// built image's identity, so rebuilding the same spec reuses the same
    /// image even after a tag moves. A forced build
    /// ([`BuildMode::ForceBuild`](crate::imagebuild::BuildMode)) looks the
    /// tags up again and builds what they point at now.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub dockerfile: Option<DockerfileImage>,
    /// Ordered build steps applied on top of the image source.
    #[serde(skip_serializing_if = "Vec::is_empty")]
    pub build_steps: Vec<ImageBuildStep>,
    /// Environment variables baked into the image.
    #[serde(skip_serializing_if = "HashMap::is_empty")]
    pub env: HashMap<String, String>,
    /// Target CPU architecture. Unset means amd64 with `base` and
    /// `dockerfile`, and with `oci` means whichever architecture the registry
    /// image was built for (amd64 when it was built for both). Setting it
    /// with `oci` requires the image to provide that architecture.
    #[serde(skip_serializing_if = "ImageArchitecture::is_unspecified")]
    pub architecture: ImageArchitecture,
    /// Exact Python version to install as `python3`; empty uses the builder
    /// default. Not accepted with `oci` or `dockerfile`: a pinned
    /// interpreter would shadow the Python the image was built around.
    #[serde(skip_serializing_if = "String::is_empty")]
    pub python_version: String,
    /// Writable root filesystem; unspecified preserves the ext4 default.
    #[serde(skip_serializing_if = "ImageFilesystem::is_unspecified")]
    pub filesystem: ImageFilesystem,
}

/// Your own image from a registry, named by reference.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default)]
pub struct OciImage {
    /// Registry reference, e.g. `docker.io/library/ubuntu:24.04` or
    /// `docker.io/library/ubuntu@sha256:<64 hex>`.
    #[serde(rename = "ref", skip_serializing_if = "String::is_empty")]
    pub reference: String,
}

/// Your own Dockerfile built into an image, with its resolved build context.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", default)]
pub struct DockerfileImage {
    /// Full Dockerfile text.
    #[serde(skip_serializing_if = "String::is_empty")]
    pub dockerfile: String,
    /// Content manifest of the build context the Dockerfile's `COPY` and
    /// `ADD` instructions read from; empty builds without a context.
    #[serde(skip_serializing_if = "Vec::is_empty")]
    pub context_files: Vec<AddLocalDirFile>,
    /// Values for the Dockerfile's `ARG` instructions, like `--build-arg`.
    #[serde(skip_serializing_if = "HashMap::is_empty")]
    pub build_args: HashMap<String, String>,
    /// Every directory in the context with its mode, so `COPY` of an empty
    /// directory works and directory modes survive like they do in a docker
    /// build.
    #[serde(skip_serializing_if = "Vec::is_empty")]
    pub context_dirs: Vec<DockerfileContextDir>,
    /// Symbolic links in the context, carried as links the way a docker
    /// build context carries them.
    #[serde(skip_serializing_if = "Vec::is_empty")]
    pub context_symlinks: Vec<DockerfileContextSymlink>,
    /// The version each external image reference resolved to when this spec
    /// was built, filled in on the spec a completed build returns. A spec
    /// carrying these keeps naming the image its build produced, even after
    /// a forced build moves what the references mean for your organization;
    /// a forced build looks every reference up again instead. Empty means
    /// the build looks the references up.
    #[serde(skip_serializing_if = "Vec::is_empty")]
    pub pinned_from: Vec<DockerfileFromResolution>,
}

/// What one external image reference in a Dockerfile resolved to when an
/// image was built.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", default)]
pub struct DockerfileFromResolution {
    /// The reference as the Dockerfile's `FROM` or `COPY --from` names it.
    #[serde(skip_serializing_if = "String::is_empty")]
    pub reference: String,
    /// The digest-pinned form of the same reference the build used.
    #[serde(skip_serializing_if = "String::is_empty")]
    pub digest_ref: String,
}

/// One directory of a Dockerfile build context.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", default)]
pub struct DockerfileContextDir {
    /// Slash-separated path relative to the context root.
    #[serde(skip_serializing_if = "String::is_empty")]
    pub relative_path: String,
    /// Directory mode permission bits; zero is "unset".
    #[serde(skip_serializing_if = "is_zero")]
    pub mode: u32,
}

/// One symbolic link of a Dockerfile build context.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", default)]
pub struct DockerfileContextSymlink {
    /// Slash-separated path relative to the context root.
    #[serde(skip_serializing_if = "String::is_empty")]
    pub relative_path: String,
    /// Raw link target, exactly as the link stores it; resolved only inside
    /// the built image.
    #[serde(skip_serializing_if = "String::is_empty")]
    pub target: String,
}

/// A supported base image. Absence of a base is `None`, not a variant here.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum BaseImage {
    /// Debian.
    #[serde(rename = "BASE_IMAGE_DEBIAN")]
    Debian,
    /// Debian plus a baked dev layer (node LTS, build tools, Docker,
    /// editor-server OS prerequisites). The Docker daemon starts
    /// automatically when the Sailbox boots and keeps running across
    /// sleeps, and can take a few seconds to accept commands right after
    /// boot. If it stops, it is not restarted automatically. Prebuilt
    /// only: supports no python_version, build steps, or env.
    #[serde(rename = "BASE_IMAGE_DEVBOX")]
    Devbox,
}

/// A target CPU architecture.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
pub enum ImageArchitecture {
    /// Unset; see [`ImageSpec::architecture`] for what each source defaults to.
    #[default]
    #[serde(rename = "IMAGE_ARCHITECTURE_UNSPECIFIED")]
    Unspecified,
    /// x86-64.
    #[serde(rename = "IMAGE_ARCHITECTURE_AMD64")]
    Amd64,
    /// ARM64.
    #[serde(rename = "IMAGE_ARCHITECTURE_ARM64")]
    Arm64,
}

impl ImageArchitecture {
    // Takes `&self` because serde's `skip_serializing_if` requires a `fn(&T)`.
    #[allow(clippy::trivially_copy_pass_by_ref)]
    fn is_unspecified(&self) -> bool {
        matches!(self, ImageArchitecture::Unspecified)
    }
}

/// Writable root filesystem for the image artifact.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
pub enum ImageFilesystem {
    /// Unset; equivalent to ext4 for backward compatibility.
    #[default]
    #[serde(rename = "IMAGE_FILESYSTEM_UNSPECIFIED")]
    Unspecified,
    /// ext4 writable root filesystem.
    #[serde(rename = "IMAGE_FILESYSTEM_EXT4")]
    Ext4,
    /// Btrfs writable root filesystem.
    #[serde(rename = "IMAGE_FILESYSTEM_BTRFS")]
    Btrfs,
}

impl ImageFilesystem {
    #[allow(clippy::trivially_copy_pass_by_ref)]
    fn is_unspecified(&self) -> bool {
        matches!(self, ImageFilesystem::Unspecified)
    }
}

/// One build step: exactly one operation (the proto's `step` oneof).
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum ImageBuildStep {
    /// Install system packages with apt.
    AptInstall(PackageInstall),
    /// Install Python packages with pip.
    PipInstall(PackageInstall),
    /// Run a shell command.
    RunCommand(RunCommand),
    /// Add one local file, referenced by its content hash.
    AddLocalFile(AddLocalFile),
    /// Add a tree of local files.
    AddLocalDir(AddLocalDir),
}

/// A set of packages to install.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct PackageInstall {
    /// Package names.
    #[serde(skip_serializing_if = "Vec::is_empty")]
    pub packages: Vec<String>,
}

/// A shell command to run during the build.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct RunCommand {
    /// The command line.
    #[serde(skip_serializing_if = "String::is_empty")]
    pub command: String,
}

/// One local file copied into the image at `remote_path`.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", default)]
pub struct AddLocalFile {
    /// Lowercase hex sha256 of the file contents.
    #[serde(skip_serializing_if = "String::is_empty")]
    pub content_sha256: String,
    /// Absolute path inside the rootfs.
    #[serde(skip_serializing_if = "String::is_empty")]
    pub remote_path: String,
    /// Permission bits (low 9); `0` means the builder default (0644).
    #[serde(skip_serializing_if = "is_zero")]
    pub mode: u32,
}

/// A tree of local files copied into the image under `remote_path`.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", default)]
pub struct AddLocalDir {
    /// Absolute path inside the rootfs where the files land.
    #[serde(skip_serializing_if = "String::is_empty")]
    pub remote_path: String,
    /// The files in the tree.
    #[serde(skip_serializing_if = "Vec::is_empty")]
    pub files: Vec<AddLocalDirFile>,
}

/// One file within an [`AddLocalDir`].
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", default)]
pub struct AddLocalDirFile {
    /// Path relative to the dir's `remote_path`.
    #[serde(skip_serializing_if = "String::is_empty")]
    pub relative_path: String,
    /// Lowercase hex sha256 of the file contents.
    #[serde(skip_serializing_if = "String::is_empty")]
    pub content_sha256: String,
    /// Permission bits (low 9); `0` means the builder default (0644).
    #[serde(skip_serializing_if = "is_zero")]
    pub mode: u32,
}

// Takes `&u32` because serde's `skip_serializing_if` requires a `fn(&T)`.
#[allow(clippy::trivially_copy_pass_by_ref)]
fn is_zero(n: &u32) -> bool {
    *n == 0
}