sail-rs 0.2.14

Official Rust SDK for Sail: create and drive sailboxes (sandboxed cloud VMs) with lifecycle, streaming exec, file transfer, and ingress.
Documentation
//! The bound sailbox object: a sailbox id paired with the [`Client`] that
//! reaches it, so every operation is a method instead of an id-threading call.
//!
//! Each method is a one-line delegate to the corresponding [`Client`] method,
//! which remains the single implementation (and the surface the language
//! bridges call with plain ids). The delegation means the two surfaces cannot
//! drift: a signature change on either side fails to compile.

use std::time::Duration;

use crate::client::Client;
use crate::error::SailError;
use crate::exec::{ExecOptions, ExecProcess};
use crate::sailbox::api::UpgradeResult;
use crate::sailbox::ssh::{EnableSshOptions, SshEndpoint};
use crate::sailbox::types::{
    CheckpointOptions, IngressProtocol, SailboxCheckpoint, SailboxHandle, SailboxInfo,
};
use crate::worker::{FileReader, FileWriter, Listener, WriteOptions};

/// A sailbox bound to the client that reaches it. Obtained from
/// [`Client::create_sailbox`], [`Client::create_from_checkpoint`], or
/// [`Client::sailbox`] (which binds an existing id without a network call).
///
/// Cheap to clone; clones share the underlying client transport.
///
/// ```no_run
/// # async fn demo() -> Result<(), sail::error::SailError> {
/// # let client = sail::Client::from_env()?;
/// let sb = client.sailbox("sb_abc123");
/// let result = sb.exec_shell("echo hello", Default::default()).await?.wait().await?;
/// sb.write("/workspace/input.txt", b"hello\n", Default::default()).await?;
/// sb.terminate().await?;
/// # Ok(())
/// # }
/// ```
#[derive(Clone)]
pub struct Sailbox {
    client: Client,
    handle: SailboxHandle,
}

impl std::fmt::Debug for Sailbox {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.debug_struct("Sailbox")
            .field("sailbox_id", &self.handle.sailbox_id)
            .finish_non_exhaustive()
    }
}

impl Sailbox {
    pub(crate) fn bind(client: Client, handle: SailboxHandle) -> Sailbox {
        Sailbox { client, handle }
    }

    pub(crate) fn client(&self) -> &Client {
        &self.client
    }

    /// The sailbox's stable identifier.
    pub fn sailbox_id(&self) -> &str {
        &self.handle.sailbox_id
    }

    /// The data snapshot from the call that produced this object (create,
    /// from-checkpoint). A sailbox bound by id via [`Client::sailbox`] carries
    /// only the id; use [`Sailbox::info`] for fresh state either way.
    pub fn handle(&self) -> &SailboxHandle {
        &self.handle
    }

    /// Consume the object, keeping just the data snapshot.
    pub fn into_handle(self) -> SailboxHandle {
        self.handle
    }

    /// Fetch this sailbox's current state.
    pub async fn info(&self) -> Result<SailboxInfo, SailError> {
        self.client.get_sailbox(self.sailbox_id()).await
    }

    // --- lifecycle ---

    /// Terminate the sailbox (idempotent).
    pub async fn terminate(&self) -> Result<(), SailError> {
        self.client.terminate_sailbox(self.sailbox_id()).await
    }

    /// Pause the sailbox in memory.
    pub async fn pause(&self) -> Result<(), SailError> {
        self.client.pause_sailbox(self.sailbox_id()).await
    }

    /// Sleep the sailbox to disk (it wakes on traffic).
    pub async fn sleep(&self) -> Result<(), SailError> {
        self.client.sleep_sailbox(self.sailbox_id()).await
    }

    /// Resume a paused or sleeping sailbox.
    pub async fn resume(&self) -> Result<(), SailError> {
        self.client
            .resume_sailbox(self.sailbox_id())
            .await
            .map(|_| ())
    }

    /// Checkpoint the sailbox. `options.name` labels the handle;
    /// `options.ttl`, when given, must be positive and overrides the server's
    /// default retention.
    pub async fn checkpoint(
        &self,
        options: CheckpointOptions,
    ) -> Result<SailboxCheckpoint, SailError> {
        self.client
            .checkpoint_sailbox(
                self.sailbox_id(),
                options.name.as_deref(),
                options.ttl.map(|ttl| ttl.as_secs() as i64),
            )
            .await
    }

    /// Upgrade the in-guest agent (now if running, else at next wake).
    pub async fn upgrade(&self) -> Result<UpgradeResult, SailError> {
        self.client.upgrade_sailbox(self.sailbox_id()).await
    }

    // --- exec ---

    /// Run a command from an argv vector (no shell interpretation) and return
    /// a handle to the live process. Resumes (wakes) the sailbox to reach it.
    /// The returned [`ExecProcess`] streams output, accepts stdin, and
    /// resolves the exit status; dropping it detaches without killing the
    /// command. The output pump spawns on the calling task's tokio runtime.
    pub async fn exec(
        &self,
        argv: Vec<String>,
        options: ExecOptions,
    ) -> Result<ExecProcess, SailError> {
        self.client.exec(self.sailbox_id(), argv, options).await
    }

    /// Run a shell command via `/bin/sh -lc` — pipes, globs, and `$VAR`
    /// expansion work — honoring the `cwd`/`background` options. Use
    /// [`Sailbox::exec`] with an argv vector when arguments must reach the
    /// command verbatim. Otherwise behaves like [`Sailbox::exec`].
    pub async fn exec_shell(
        &self,
        command: &str,
        options: ExecOptions,
    ) -> Result<ExecProcess, SailError> {
        self.client
            .exec_shell(self.sailbox_id(), command, options)
            .await
    }

    // --- files ---

    /// Read a guest file into memory in one call.
    pub async fn read(&self, path: &str) -> Result<Vec<u8>, SailError> {
        self.client.read_file(self.sailbox_id(), path).await
    }

    /// Write `data` to a guest file in one call.
    pub async fn write(
        &self,
        path: &str,
        data: &[u8],
        options: WriteOptions,
    ) -> Result<(), SailError> {
        self.client
            .write_file(self.sailbox_id(), path, data, options)
            .await
    }

    /// Open a streaming read of a guest file.
    pub async fn read_stream(&self, path: &str) -> Result<FileReader, SailError> {
        self.client.read_stream(self.sailbox_id(), path).await
    }

    /// Open a streaming write to a guest file.
    pub async fn write_stream(
        &self,
        path: &str,
        options: WriteOptions,
    ) -> Result<FileWriter, SailError> {
        self.client
            .write_stream(self.sailbox_id(), path, options)
            .await
    }

    // --- listeners ---

    /// Expose a guest port at runtime. The returned [`Listener`] carries the
    /// endpoint the scheduler resolved but an unspecified route status — the
    /// expose response does not report reachability; confirm with
    /// [`Sailbox::wait_for_listener`].
    pub async fn expose(
        &self,
        guest_port: u32,
        protocol: IngressProtocol,
        allowlist: &[String],
    ) -> Result<Listener, SailError> {
        self.client
            .expose_listener(self.sailbox_id(), guest_port, protocol, allowlist)
            .await
    }

    /// Remove a runtime ingress port.
    pub async fn unexpose(&self, guest_port: u32) -> Result<(), SailError> {
        self.client
            .unexpose_listener(self.sailbox_id(), guest_port)
            .await
    }

    /// List this sailbox's listeners without waking it.
    pub async fn listeners(&self) -> Result<Vec<Listener>, SailError> {
        self.client.list_listeners(self.sailbox_id()).await
    }

    /// Fetch one listener by guest port without waking the box.
    pub async fn listener(&self, guest_port: u32) -> Result<Listener, SailError> {
        self.client
            .get_listener(self.sailbox_id(), guest_port)
            .await
    }

    /// Block until the listener on `guest_port` is reachable end to end —
    /// route active and its endpoint accepting — and return it. An HTTP
    /// listener is probed by URL, so success means the guest server answered;
    /// a TCP listener is ready once the guest sends bytes or holds the
    /// connection open. This is a connectivity check, not an application
    /// health check. Polls every `poll_interval` (1s is a good default) and
    /// fails with a Timeout error after `timeout` (60s is a good default; an
    /// unrepresentably large value waits indefinitely).
    pub async fn wait_for_listener(
        &self,
        guest_port: u32,
        timeout: Duration,
        poll_interval: Duration,
    ) -> Result<Listener, SailError> {
        self.client
            .wait_for_listener(self.sailbox_id(), guest_port, timeout, poll_interval)
            .await
    }

    /// Ingress-identity headers for this sailbox, as name/value pairs.
    pub async fn ingress_auth_headers(&self) -> Result<Vec<(String, String)>, SailError> {
        self.client.ingress_auth_headers(self.sailbox_id()).await
    }

    // --- ssh ---

    /// Make the sailbox reachable over SSH, returning the endpoint when
    /// `options.wait` is set (else `None`). Installs the org SSH CA as
    /// trusted, (re)starts `sshd`, confirms the CA-only daemon owns guest
    /// port 22, and only then exposes the port as TCP ingress — a failed
    /// enable never leaves a non-CA daemon reachable. Idempotent. A non-empty
    /// `options.allowlist` restricts port 22 to those source CIDRs; when
    /// empty, a first enable is open to any source and a re-enable keeps an
    /// existing restriction.
    pub async fn enable_ssh(
        &self,
        options: EnableSshOptions,
    ) -> Result<Option<SshEndpoint>, SailError> {
        self.client
            .enable_ssh(
                self.sailbox_id(),
                &options.allowlist,
                options.wait,
                options.timeout,
            )
            .await
    }
}

impl Client {
    /// Bind an existing sailbox id to this client without a network call,
    /// giving the method-style surface over it.
    pub fn sailbox(&self, sailbox_id: impl Into<String>) -> Sailbox {
        Sailbox {
            client: self.clone(),
            handle: SailboxHandle {
                sailbox_id: sailbox_id.into(),
                ..Default::default()
            },
        }
    }
}