use crate::forward::{self, ForwardReport, Forwards, SshConnector};
use crate::mise::{self, MiseError};
use crate::network;
use crate::open::{Opener, Relays};
use crate::pull::{self, PullUpdate};
use crate::secrets::{self, Secret, SecretStore};
use crate::ssh;
use crate::vscode;
use crate::zed;
use firebrick_spec::{MountSpec, NetworkSpec, PortMapping, VolumesSpec, with_port};
use microsandbox::sandbox::{
HostPermissions, MountBuilder, OwnedVolumeStorage, SandboxBuilder, SandboxHandle,
SandboxStatus, VolumeMount,
};
use microsandbox::snapshot::Snapshot;
use microsandbox::{MicrosandboxError, Sandbox};
use microsandbox_image::ImageError;
use oci_client::errors::{OciDistributionError, OciEnvelope, OciErrorCode};
use std::collections::{BTreeMap, HashMap, HashSet};
use std::fmt;
use std::path::Path;
use std::sync::{Arc, PoisonError};
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use thiserror::Error;
use tokio::sync::OwnedMutexGuard;
use tokio::time::{Instant, sleep};
pub const STOP_TIMEOUT: Duration = Duration::from_secs(30);
const SNAPSHOT_IN_USE_TIMEOUT: Duration = Duration::from_secs(10);
const SNAPSHOT_RETRY_INTERVAL: Duration = Duration::from_millis(100);
const DOCKER_DATA_PATH: &str = "/var/lib/docker";
#[derive(Error, Debug, Clone, PartialEq, Eq)]
pub enum SandboxError {
#[error("{0}")]
NotFound(String),
#[error("{0}")]
InvalidArgument(String),
#[error("{0}")]
FailedPrecondition(String),
#[error("{0}")]
Unavailable(String),
#[error("{0}")]
Internal(String),
}
impl SandboxError {
fn not_found(message: impl Into<String>) -> Self {
Self::NotFound(message.into())
}
fn invalid_argument(message: impl Into<String>) -> Self {
Self::InvalidArgument(message.into())
}
fn failed_precondition(message: impl Into<String>) -> Self {
Self::FailedPrecondition(message.into())
}
fn unavailable(message: impl Into<String>) -> Self {
Self::Unavailable(message.into())
}
fn internal(message: impl Into<String>) -> Self {
Self::Internal(message.into())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Resources {
pub cpus: u8,
pub memory_mib: u32,
pub docker_volume_mib: u32,
}
#[derive(Clone)]
pub struct NewSecret {
pub name: String,
pub value: String,
pub allowed_hosts: Vec<String>,
pub sandbox: Option<String>,
}
#[derive(Debug, Clone, Copy)]
pub struct StartSandbox<'a> {
pub name: &'a str,
pub workspace: &'a str,
pub image: &'a str,
pub init: bool,
pub mise: bool,
pub network: &'a NetworkSpec,
pub ports: Option<&'a [PortMapping]>,
pub mounts: &'a [MountSpec],
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SandboxInfo {
pub name: String,
pub status: SandboxStatus,
pub hostname: Option<String>,
pub workspace_path: Option<String>,
pub workspace_host_path: Option<String>,
}
impl SandboxInfo {
fn of(handle: &SandboxHandle) -> Self {
Self {
name: handle.name().to_string(),
status: handle.status_snapshot(),
hostname: ssh::hostname_of(handle),
workspace_path: workspace_path_of(handle),
workspace_host_path: workspace_host_path_of(handle),
}
}
}
fn workspace_path_of(handle: &SandboxHandle) -> Option<String> {
handle.config().ok()?.spec.runtime.workdir
}
fn workspace_host_path_of(handle: &SandboxHandle) -> Option<String> {
let config = handle.config().ok()?;
let workdir = config.spec.runtime.workdir.as_deref()?;
config.spec.mounts.iter().find_map(|mount| match mount {
VolumeMount::Bind { host, guest, .. } if guest == workdir => {
Some(host.to_string_lossy().into_owned())
}
_ => None,
})
}
#[derive(Debug, Clone)]
struct SandboxSettings {
name: String,
labels: BTreeMap<String, String>,
workspace: Option<Workspace>,
mounts: Vec<MountSpec>,
init: bool,
resources: Resources,
}
#[derive(Debug, Clone)]
struct Workspace {
host: String,
guest: String,
}
impl SandboxSettings {
fn of(handle: &SandboxHandle) -> Result<Self, MicrosandboxError> {
let spec = handle.config()?.spec;
let workspace = workspace_host_path_of(handle)
.zip(workspace_path_of(handle))
.map(|(host, guest)| Workspace { host, guest });
let workdir = spec.runtime.workdir.as_deref();
Ok(Self {
name: handle.name().to_string(),
mounts: extra_mounts(&spec.mounts, workdir),
labels: spec.labels,
workspace,
init: spec.init.is_some(),
resources: Resources {
cpus: spec.resources.cpus,
memory_mib: spec.resources.memory_mib,
docker_volume_mib: docker_volume_mib(&spec.mounts),
},
})
}
fn builder(&self) -> SandboxBuilder {
let builder = self
.labels
.iter()
.fold(Sandbox::builder(&self.name), |builder, (key, value)| {
builder.label(key, value)
})
.detached(true);
let builder = with_workspace(builder, self.workspace.as_ref());
let builder = with_mounts(builder, &self.mounts);
with_init(with_resources(builder, self.resources), self.init)
}
}
fn extra_mounts(mounts: &[VolumeMount], workdir: Option<&str>) -> Vec<MountSpec> {
mounts
.iter()
.filter_map(|mount| match mount {
VolumeMount::Bind {
host,
guest,
options,
..
} if Some(guest.as_str()) != workdir => Some(MountSpec {
host: host.to_string_lossy().into_owned(),
guest: guest.clone(),
readonly: options.readonly,
}),
_ => None,
})
.collect()
}
fn docker_volume_mib(mounts: &[VolumeMount]) -> u32 {
mounts
.iter()
.find_map(|mount| match mount {
VolumeMount::Owned {
guest,
storage: OwnedVolumeStorage::Disk { capacity_mib },
..
} if guest == DOCKER_DATA_PATH => Some(*capacity_mib),
_ => None,
})
.unwrap_or_else(default_docker_volume_mib)
}
fn default_docker_volume_mib() -> u32 {
firebrick_spec::parse_size_mib(&VolumesSpec::default().docker).unwrap_or(20 * 1024)
}
pub struct SandboxManager {
secrets: SecretStore,
forwards: Arc<Forwards>,
relays: Relays,
secrets_lock: tokio::sync::Mutex<()>,
sandbox_locks: SandboxLocks,
ports_lock: tokio::sync::Mutex<()>,
}
type SandboxLocks = std::sync::Mutex<HashMap<String, Arc<tokio::sync::Mutex<()>>>>;
struct SandboxGuard<'a> {
locks: &'a SandboxLocks,
name: String,
guard: Option<OwnedMutexGuard<()>>,
}
impl Drop for SandboxGuard<'_> {
fn drop(&mut self) {
let mut locks = self.locks.lock().unwrap_or_else(PoisonError::into_inner);
let Some(guard) = self.guard.take() else {
return;
};
let unused = Arc::strong_count(OwnedMutexGuard::mutex(&guard)) == 2;
drop(guard);
if unused {
locks.remove(&self.name);
}
}
}
impl SandboxManager {
pub fn new(secrets: SecretStore, opener: Arc<dyn Opener>) -> Self {
let forwards = Arc::new(Forwards::new(SshConnector::default()));
Self {
secrets,
relays: Relays::new(opener, Arc::clone(&forwards)),
forwards,
secrets_lock: tokio::sync::Mutex::new(()),
sandbox_locks: std::sync::Mutex::new(HashMap::new()),
ports_lock: tokio::sync::Mutex::new(()),
}
}
async fn lock_sandbox(&self, name: &str) -> SandboxGuard<'_> {
let lock = {
let mut locks = self
.sandbox_locks
.lock()
.unwrap_or_else(PoisonError::into_inner);
locks.entry(name.to_string()).or_default().clone()
};
SandboxGuard {
locks: &self.sandbox_locks,
name: name.to_string(),
guard: Some(lock.lock_owned().await),
}
}
pub async fn start(
&self,
request: StartSandbox<'_>,
resources: impl FnOnce() -> Result<Resources, SandboxError>,
on_pull: impl FnMut(PullUpdate) + Send,
) -> Result<ForwardReport, SandboxError> {
let _guard = self.lock_sandbox(request.name).await;
let result = match Sandbox::get(request.name).await {
Ok(existing_sb) => start_existing_sandbox(&existing_sb, request).await,
Err(_) => self.create_and_install(request, resources, on_pull).await,
};
sync_ssh_config().await;
result?;
self.start_relay(request.name).await;
Ok(self.open_forwards(request.name, request.ports).await)
}
pub async fn restore_forwards(&self) {
let sandboxes = match list_all_sandboxes().await {
Ok(sandboxes) => sandboxes,
Err(err) => {
tracing::warn!("failed to restore port forwards: {err}");
return;
}
};
let _ports = self.ports_lock.lock().await;
for sb in sandboxes
.iter()
.filter(|sb| sb.status_snapshot() == SandboxStatus::Running)
{
self.forwards.apply(sb.name(), &stored_ports(sb)).await;
}
}
async fn open_forwards(&self, name: &str, ports: Option<&[PortMapping]>) -> ForwardReport {
let _ports = self.ports_lock.lock().await;
let ports = match (ports, Sandbox::get(name).await) {
(Some(ports), Ok(sb)) => {
store_ports(&sb, ports).await;
ports.to_vec()
}
(Some(ports), Err(_)) => ports.to_vec(),
(None, Ok(sb)) => stored_ports(&sb),
(None, Err(_)) => vec![],
};
self.forwards.apply(name, &ports).await
}
pub async fn forward_port(&self, name: &str, port: PortMapping) -> Result<bool, SandboxError> {
let _guard = self.lock_sandbox(name).await;
let _ports = self.ports_lock.lock().await;
let sb = get_sandbox(name).await?;
let stored = stored_ports(&sb);
let change = PortsChange {
ports: with_port(&stored, port),
stored,
requested: Some(port),
};
self.change_ports(&sb, &change).await
}
pub async fn remove_port(&self, name: &str, host: u16) -> Result<bool, SandboxError> {
let _guard = self.lock_sandbox(name).await;
let _ports = self.ports_lock.lock().await;
let sb = get_sandbox(name).await?;
let stored = stored_ports(&sb);
let ports: Vec<PortMapping> = stored.iter().filter(|p| p.host != host).copied().collect();
if ports.len() == stored.len() {
return Err(SandboxError::not_found(format!(
"port {host} isn't forwarded for sandbox {name}"
)));
}
let change = PortsChange {
stored,
ports,
requested: None,
};
self.change_ports(&sb, &change).await
}
async fn change_ports(
&self,
sb: &SandboxHandle,
change: &PortsChange,
) -> Result<bool, SandboxError> {
let running = sb.status_snapshot() == SandboxStatus::Running;
if running {
self.apply_new_ports(sb.name(), change).await?;
}
let saved = save_ports(sb, &change.ports).await;
if saved.is_err() && running {
self.forwards.apply(sb.name(), &change.stored).await;
}
saved.map(|()| running)
}
async fn apply_new_ports(&self, name: &str, change: &PortsChange) -> Result<(), SandboxError> {
let report = self.forwards.apply(name, &change.ports).await;
let Some(failure) = report.failed.iter().find(|f| change.is_new(f.port)) else {
return Ok(());
};
self.forwards.apply(name, &change.stored).await;
Err(SandboxError::failed_precondition(failure.reason.clone()))
}
pub async fn stop(&self, name: &str) -> Result<(), SandboxError> {
let _guard = self.lock_sandbox(name).await;
let sb = self.get_or_close_forwards(name).await?;
stop_or_kill(&sb, STOP_TIMEOUT).await.map_err(|err| {
tracing::error!(error = ?err, "failed to stop sandbox {name}");
SandboxError::internal("failed to stop sandbox")
})?;
self.forwards.close(name).await;
self.relays.forget(name);
Ok(())
}
pub async fn remove(&self, name: &str, force: bool) -> Result<(), SandboxError> {
let _guard = self.lock_sandbox(name).await;
let sb = self.get_or_close_forwards(name).await?;
let live = is_live(sb.status_snapshot());
if force && live {
stop_or_kill(&sb, STOP_TIMEOUT)
.await
.map_err(|err| stop_before_remove_failed(name, &err))?;
}
if force || !live {
self.forwards.close(name).await;
self.relays.forget(name);
}
sb.remove().await.map_err(|err| remove_failed(name, err))?;
sync_ssh_config().await;
self.remove_sandbox_secrets(name).await
}
async fn remove_sandbox_secrets(&self, name: &str) -> Result<(), SandboxError> {
let _secrets_guard = self.secrets_lock.lock().await;
self.secrets.remove_sandbox(name).map_err(|err| {
tracing::error!(error = ?err, "failed to remove the secrets of sandbox {name}");
SandboxError::internal(format!(
"removed sandbox {name}, but failed to remove its secrets"
))
})?;
Ok(())
}
async fn get_or_close_forwards(&self, name: &str) -> Result<SandboxHandle, SandboxError> {
let result = get_sandbox(name).await;
if let Err(SandboxError::NotFound(_)) = result {
self.forwards.close(name).await;
self.relays.forget(name);
}
result
}
pub async fn get(&self, name: &str) -> Result<SandboxInfo, SandboxError> {
Ok(SandboxInfo::of(&get_sandbox(name).await?))
}
pub async fn list(&self) -> Result<Vec<SandboxInfo>, SandboxError> {
Ok(list_all_sandboxes()
.await?
.iter()
.map(SandboxInfo::of)
.collect())
}
pub async fn connect(&self, name: &str) -> Result<Sandbox, SandboxError> {
let _guard = self.lock_sandbox(name).await;
let sb = get_sandbox(name)
.await?
.connect()
.await
.map_err(|err| connect_failed(name, &err))?;
self.relays.ensure(&sb).await;
Ok(sb)
}
pub async fn connect_by_hostname(&self, hostname: &str) -> Result<Sandbox, SandboxError> {
let name = sandbox_name_by_hostname(hostname).await?;
let _guard = self.lock_sandbox(&name).await;
let sb = get_sandbox(&name)
.await?
.connect_or_start_detached()
.await
.map_err(|err| {
tracing::error!(error = ?err, "failed to start sandbox with host name {hostname}");
SandboxError::failed_precondition("failed to start sandbox")
})?;
self.open_forwards(sb.name(), None).await;
self.relays.ensure(&sb).await;
Ok(sb)
}
pub async fn update_network(
&self,
name: &str,
network: &NetworkSpec,
) -> Result<bool, SandboxError> {
let _guard = self.lock_sandbox(name).await;
let sb = get_sandbox(name).await?;
let settings = SandboxSettings::of(&sb).map_err(|err| update_failed(name, &err))?;
if settings.labels.get(network::RULES_LABEL) == Some(&network::rules_label(network)) {
return Ok(false);
}
if sb.status_snapshot() == SandboxStatus::Paused {
return Err(SandboxError::failed_precondition(format!(
"sandbox {name} is paused; resume it before updating its network rules"
)));
}
let result = self.replace(&sb, &settings, network).await;
sync_ssh_config().await;
self.reopen_forwards_and_relay(name).await;
result.map(|()| true)
}
async fn reopen_forwards_and_relay(&self, name: &str) {
let running = Sandbox::get(name)
.await
.is_ok_and(|sb| sb.status_snapshot() == SandboxStatus::Running);
if running {
self.open_forwards(name, None).await;
self.start_relay(name).await;
}
}
async fn start_relay(&self, name: &str) {
match async { Sandbox::get(name).await?.connect().await }.await {
Ok(sb) => self.relays.ensure(&sb).await,
Err(err) => tracing::warn!("failed to start the URL relay of sandbox {name}: {err}"),
}
}
async fn replace(
&self,
sb: &SandboxHandle,
settings: &SandboxSettings,
network: &NetworkSpec,
) -> Result<(), SandboxError> {
let name = sb.name();
let was_live = is_live(sb.status_snapshot());
if was_live {
stop_or_kill(sb, STOP_TIMEOUT)
.await
.map_err(|err| update_failed(name, &err))?;
self.forwards.close(name).await;
self.relays.forget(name);
}
let snapshot = match snapshot_and_remove(sb).await {
Ok(snapshot) => snapshot,
Err(err) => return Err(snapshot_failed(sb, was_live, &err).await),
};
self.recreate(settings, network, &snapshot).await?;
if !was_live {
stop_recreated(name).await?;
}
Ok(())
}
async fn recreate(
&self,
settings: &SandboxSettings,
network: &NetworkSpec,
snapshot: &str,
) -> Result<(), SandboxError> {
let name = &settings.name;
if let Err(err) = self.create_from_snapshot(settings, network, snapshot).await {
return Err(recreate_failed(name, snapshot, &err).await);
}
remove_snapshot(snapshot).await;
tracing::info!("updated the network rules of sandbox {name}");
Ok(())
}
async fn create_from_snapshot(
&self,
settings: &SandboxSettings,
network: &NetworkSpec,
snapshot: &str,
) -> Result<Sandbox, String> {
let _secrets_guard = self.secrets_lock.lock().await;
let secrets = self
.load_secrets_for(&settings.name)
.map_err(|err| err.to_string())?;
let builder = settings.builder().override_snapshot(snapshot);
create_with(builder, &secrets, network, |_| {})
.await
.map_err(|err| format!("{err:?}"))
}
pub async fn set_secret(&self, request: NewSecret) -> Result<Vec<String>, SandboxError> {
let secret = Secret::new(request.name, request.value, request.allowed_hosts)
.map_err(|err| SandboxError::invalid_argument(err.to_string()))?
.in_scope(request.sandbox);
let _secrets_guard = self.secrets_lock.lock().await;
let scoped_handle = scoped_sandbox(secret.sandbox()).await?;
self.secrets.set(secret.clone()).map_err(|err| {
tracing::error!(error = ?err, "failed to store secret {}", secret.name());
SandboxError::internal("failed to store secret")
})?;
let change = SecretChange::Add(&secret);
let failed_sandboxes = self
.update_scope(scoped_handle.as_ref(), secret.name(), change)
.await
.map_err(|_| {
SandboxError::internal(
"stored the secret, but failed to list the sandboxes to add it to",
)
})?;
tracing::info!(
"set secret {}",
describe_secret(secret.name(), secret.sandbox())
);
Ok(failed_sandboxes)
}
pub fn list_secrets(&self) -> Result<Vec<Secret>, SandboxError> {
let mut secrets = self.load_secrets()?;
secrets.sort_by(|a, b| (a.name(), a.sandbox()).cmp(&(b.name(), b.sandbox())));
Ok(secrets)
}
pub async fn remove_secret(
&self,
name: &str,
sandbox: Option<&str>,
) -> Result<Vec<String>, SandboxError> {
secrets::validate_name(name)
.map_err(|err| SandboxError::invalid_argument(err.to_string()))?;
let _secrets_guard = self.secrets_lock.lock().await;
let scoped_handle = scoped_sandbox(sandbox).await?;
let stored = self.load_secrets()?;
ensure_secret_exists(&stored, name, sandbox)?;
let change = removal(&stored, name, sandbox);
let failed_sandboxes = self
.update_scope(scoped_handle.as_ref(), name, change)
.await?;
if !failed_sandboxes.is_empty() {
return Ok(failed_sandboxes);
}
self.secrets.remove(name, sandbox).map_err(|err| {
tracing::error!(error = ?err, "failed to remove secret {name}");
SandboxError::internal("failed to remove secret")
})?;
tracing::info!("removed secret {}", describe_secret(name, sandbox));
Ok(failed_sandboxes)
}
async fn update_scope(
&self,
scoped_handle: Option<&SandboxHandle>,
name: &str,
change: SecretChange<'_>,
) -> Result<Vec<String>, SandboxError> {
match scoped_handle {
Some(handle) => Ok(apply_to_sandbox(handle, &change).await),
None => self.update_unscoped_sandboxes(name, change).await,
}
}
async fn update_unscoped_sandboxes(
&self,
name: &str,
change: SecretChange<'_>,
) -> Result<Vec<String>, SandboxError> {
let overridden: HashSet<String> = self
.load_secrets()?
.iter()
.filter(|secret| secret.name() == name)
.filter_map(|secret| secret.sandbox().map(str::to_string))
.collect();
update_firebrick_sandboxes(change, &overridden).await
}
fn load_secrets_for(&self, sandbox: &str) -> Result<Vec<Secret>, SandboxError> {
Ok(secrets::for_sandbox(self.load_secrets()?, sandbox))
}
fn load_secrets(&self) -> Result<Vec<Secret>, SandboxError> {
self.secrets.load().map_err(|err| {
tracing::error!(error = ?err, "failed to load secrets");
SandboxError::internal("failed to load secrets")
})
}
async fn create_and_install(
&self,
request: StartSandbox<'_>,
resources: impl FnOnce() -> Result<Resources, SandboxError>,
on_pull: impl FnMut(PullUpdate) + Send,
) -> Result<(), SandboxError> {
let (sb, guest_path) = self.create_sandbox(request, resources, on_pull).await?;
if request.mise {
install_mise_tools(&sb, &guest_path).await?;
}
Ok(())
}
async fn create_sandbox(
&self,
request: StartSandbox<'_>,
resources: impl FnOnce() -> Result<Resources, SandboxError>,
on_pull: impl FnMut(PullUpdate) + Send,
) -> Result<(Sandbox, String), SandboxError> {
let guest_path = workspace_mount_path(request.workspace)?;
check_mounts(request.mounts, &guest_path)?;
let resources = resources()?;
let hostname = ssh::pick_hostname(request.workspace, &taken_hostnames().await?);
let _secrets_guard = self.secrets_lock.lock().await;
let secrets = self.load_secrets_for(request.name)?;
let settings = new_sandbox_settings(request, &guest_path, &hostname, resources);
let builder = settings.builder().image(sandbox_image(request.image));
let sb = match create_with(builder, &secrets, request.network, on_pull).await {
Ok(sb) => sb,
Err(err) => {
tracing::error!(error = ?err, "failed to create sandbox {}", request.name);
return Err(create_failed(request.name, sandbox_image(request.image), &err).await);
}
};
tracing::info!("created sandbox {} as {hostname}", request.name);
Ok((sb, guest_path))
}
}
fn new_sandbox_settings(
request: StartSandbox<'_>,
guest_path: &str,
hostname: &str,
resources: Resources,
) -> SandboxSettings {
let labels = BTreeMap::from([
(ssh::HOSTNAME_LABEL.to_string(), hostname.to_string()),
(
mise::ENABLED_LABEL.to_string(),
mise::label_value(request.mise).to_string(),
),
(
forward::PORTS_LABEL.to_string(),
forward::label_value(request.ports.unwrap_or_default()),
),
]);
SandboxSettings {
name: request.name.to_string(),
labels,
workspace: Some(Workspace {
host: request.workspace.to_string(),
guest: guest_path.to_string(),
}),
mounts: request.mounts.to_vec(),
init: request.init,
resources,
}
}
fn with_workspace(builder: SandboxBuilder, workspace: Option<&Workspace>) -> SandboxBuilder {
let Some(workspace) = workspace else {
return builder;
};
builder
.volume(&workspace.guest, |m| {
m.bind(&workspace.host)
.host_permissions(HostPermissions::Mirror)
.owner(1000, 1000)
})
.workdir(&workspace.guest)
}
async fn sandbox_name_by_hostname(hostname: &str) -> Result<String, SandboxError> {
retry_while_not_found(|| Sandbox::list_with(|opt| opt.label(ssh::HOSTNAME_LABEL, hostname)))
.await
.map_err(|err| {
tracing::error!(error = ?err, "failed to list sandboxes with host name {hostname}");
SandboxError::internal("failed to list sandboxes")
})?
.sandboxes
.first()
.map(|handle| handle.name().to_string())
.ok_or_else(|| SandboxError::not_found("couldn't find a sandbox with that host name"))
}
async fn snapshot_and_remove(sb: &SandboxHandle) -> Result<String, MicrosandboxError> {
let name = sb.name();
let snapshot = Snapshot::builder(snapshot_name(name))
.from_sandbox(name)
.create()
.await?;
let path = snapshot.path()?.to_string_lossy().into_owned();
if let Err(err) = sb.remove().await {
remove_snapshot(&path).await;
return Err(err);
}
Ok(path)
}
async fn snapshot_failed(
sb: &SandboxHandle,
was_live: bool,
err: &MicrosandboxError,
) -> SandboxError {
let name = sb.name();
let message = format!("failed to update the network rules of {name}; it keeps its old rules");
tracing::error!(error = ?err, "failed to snapshot and remove sandbox {name}");
if !was_live {
return SandboxError::internal(message);
}
match sb.start_detached().await {
Ok(_) => SandboxError::internal(message),
Err(err) => {
tracing::error!(error = ?err, "failed to start sandbox {name} again");
SandboxError::internal(format!("{message}, but it couldn't be started again"))
}
}
}
async fn stop_recreated(name: &str) -> Result<(), SandboxError> {
let result = match Sandbox::get(name).await {
Ok(sb) => stop_or_kill(&sb, STOP_TIMEOUT).await,
Err(err) => Err(err),
};
result.map_err(|err| {
tracing::error!(error = ?err, "failed to stop sandbox {name} after updating its rules");
SandboxError::internal(format!(
"updated the network rules of {name}, but failed to stop it again"
))
})
}
fn snapshot_name(sandbox: &str) -> String {
let nonce = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_or(0, |elapsed| elapsed.as_nanos());
format!("firebrick-network-{sandbox}-{}-{nonce}", std::process::id())
}
async fn remove_snapshot(snapshot: &str) {
let deadline = Instant::now() + SNAPSHOT_IN_USE_TIMEOUT;
loop {
match Snapshot::remove(snapshot, false).await {
Ok(()) => return,
Err(err) if is_in_use(&err) && Instant::now() < deadline => {
sleep(SNAPSHOT_RETRY_INTERVAL).await;
}
Err(err) => {
tracing::warn!(error = ?err, "failed to remove snapshot {snapshot}");
return;
}
}
}
}
fn is_in_use(err: &MicrosandboxError) -> bool {
matches!(err, MicrosandboxError::Custom(message) if message.starts_with("snapshot is in use"))
}
fn update_failed(name: &str, err: &MicrosandboxError) -> SandboxError {
tracing::error!(error = ?err, "failed to update the network rules of sandbox {name}");
SandboxError::internal(format!("failed to update the network rules of {name}"))
}
async fn recreate_failed(name: &str, snapshot: &str, err: &str) -> SandboxError {
if let Ok(sb) = Sandbox::get(name).await {
let _ = stop_or_kill(&sb, STOP_TIMEOUT).await;
if let Err(err) = sb.remove().await {
tracing::warn!(error = ?err, "failed to remove sandbox {name} after a failed recreate");
}
}
tracing::error!(
error = err,
"failed to recreate sandbox {name}; its disk is kept as snapshot {snapshot}"
);
SandboxError::internal(format!(
"failed to update the network rules of {name}; the sandbox was kept as snapshot \
{snapshot}"
))
}
async fn create_with(
builder: SandboxBuilder,
secrets: &[Secret],
network: &NetworkSpec,
on_pull: impl FnMut(PullUpdate) + Send,
) -> Result<Sandbox, MicrosandboxError> {
let builder = network::add_to_builder(builder, network)?
.label(network::RULES_LABEL, network::rules_label(network));
let (progress, task) =
secrets::add_to_builder(builder, secrets).create_detached_with_pull_progress()?;
pull::wait_for_create(progress, task, on_pull).await
}
fn ensure_secret_exists(
secrets: &[Secret],
name: &str,
sandbox: Option<&str>,
) -> Result<(), SandboxError> {
if secrets.iter().any(|secret| secret.matches(name, sandbox)) {
return Ok(());
}
Err(SandboxError::not_found(match sandbox {
Some(sandbox) => format!("secret {name} doesn't exist in sandbox {sandbox}"),
None => format!("secret {name} doesn't exist"),
}))
}
fn describe_secret(name: &str, sandbox: Option<&str>) -> String {
match sandbox {
Some(sandbox) => format!("{name} for sandbox {sandbox}"),
None => name.to_string(),
}
}
fn removal<'a>(stored: &'a [Secret], name: &'a str, sandbox: Option<&str>) -> SecretChange<'a> {
let global = stored.iter().find(|secret| secret.matches(name, None));
match (sandbox, global) {
(Some(_), Some(global)) => SecretChange::Add(global),
_ => SecretChange::Remove(name),
}
}
async fn scoped_sandbox(sandbox: Option<&str>) -> Result<Option<SandboxHandle>, SandboxError> {
let Some(name) = sandbox else {
return Ok(None);
};
get_sandbox(name).await.map(Some).map_err(|err| match err {
SandboxError::NotFound(_) => {
SandboxError::not_found(format!("sandbox {name} doesn't exist"))
}
err => err,
})
}
async fn get_sandbox(name: &str) -> Result<SandboxHandle, SandboxError> {
Sandbox::get(name).await.map_err(|err| match err {
MicrosandboxError::SandboxNotFound(_) => {
SandboxError::not_found("couldn't find specified sandbox")
}
err => {
tracing::error!(error = ?err, "failed to get sandbox {name}");
SandboxError::internal("failed to get sandbox")
}
})
}
fn connect_failed(name: &str, err: &MicrosandboxError) -> SandboxError {
if matches!(err, MicrosandboxError::SandboxNotRunning(_)) {
tracing::warn!(error = ?err, "failed to connect to sandbox {name}");
} else {
tracing::error!(error = ?err, "failed to connect to sandbox {name}");
}
SandboxError::failed_precondition("sandbox is not running")
}
fn remove_failed(name: &str, err: MicrosandboxError) -> SandboxError {
match err {
MicrosandboxError::SandboxStillRunning(_) => SandboxError::failed_precondition(format!(
"sandbox {name} is running; stop it first or remove it with force"
)),
err => {
tracing::error!(error = ?err, "failed to remove sandbox {name}");
SandboxError::internal("failed to remove sandbox")
}
}
}
fn stop_before_remove_failed(name: &str, err: &MicrosandboxError) -> SandboxError {
tracing::error!(error = ?err, "failed to stop sandbox {name} before removing it");
SandboxError::internal("failed to stop sandbox before removing it")
}
pub async fn stop_or_kill(sb: &SandboxHandle, timeout: Duration) -> Result<(), MicrosandboxError> {
match sb.stop_with_timeout(timeout).await {
Err(MicrosandboxError::StopTimeout { .. }) => {
tracing::warn!(
"sandbox {} didn't stop within {timeout:?}; killing it",
sb.name()
);
sb.kill().await
}
result => result,
}
}
fn is_live(status: SandboxStatus) -> bool {
matches!(
status,
SandboxStatus::Starting
| SandboxStatus::Running
| SandboxStatus::Draining
| SandboxStatus::Paused
)
}
async fn start_existing_sandbox(
sb: &SandboxHandle,
request: StartSandbox<'_>,
) -> Result<(), SandboxError> {
if ssh::hostname_of(sb).is_none() {
assign_hostname(sb, request).await?;
}
if matches!(
sb.status_snapshot(),
SandboxStatus::Running | SandboxStatus::Starting
) {
return Ok(());
}
let started = match sb.start_detached().await {
Ok(started) => started,
Err(MicrosandboxError::SandboxStillRunning(_)) => return Ok(()),
Err(err) => {
tracing::error!(error = ?err, "failed to start sandbox {}", request.name);
return Err(SandboxError::internal("failed to start sandbox"));
}
};
tracing::info!("started sandbox {}", request.name);
match workspace_path_of(sb) {
Some(workspace) if mise_enabled(sb) => install_mise_tools(&started, &workspace).await,
_ => Ok(()),
}
}
struct PortsChange {
stored: Vec<PortMapping>,
ports: Vec<PortMapping>,
requested: Option<PortMapping>,
}
impl PortsChange {
fn is_new(&self, port: PortMapping) -> bool {
self.requested == Some(port) || !self.stored.contains(&port)
}
}
fn stored_ports(sb: &SandboxHandle) -> Vec<PortMapping> {
sb.config()
.ok()
.and_then(|config| config.spec.labels.get(forward::PORTS_LABEL).cloned())
.map(|value| forward::ports_from_label(&value))
.unwrap_or_default()
}
async fn store_ports(sb: &SandboxHandle, ports: &[PortMapping]) {
if let Err(err) = save_ports(sb, ports).await {
tracing::warn!("{err}");
}
}
async fn save_ports(sb: &SandboxHandle, ports: &[PortMapping]) -> Result<(), SandboxError> {
if stored_ports(sb) == ports {
return Ok(());
}
sb.modify()
.label(forward::PORTS_LABEL, forward::label_value(ports))
.next_start()
.apply()
.await
.map_err(|err| {
tracing::error!(error = ?err, "failed to store the ports of sandbox {}", sb.name());
SandboxError::internal(format!(
"failed to store the ports of sandbox {}",
sb.name()
))
})?;
Ok(())
}
fn mise_enabled(sb: &SandboxHandle) -> bool {
sb.config()
.is_ok_and(|config| mise::is_enabled(&config.spec.labels))
}
async fn install_mise_tools(sb: &Sandbox, workspace: &str) -> Result<(), SandboxError> {
match mise::install_tools(sb, workspace).await {
Ok(()) => Ok(()),
Err(err @ MiseError::Failed { .. }) => {
tracing::warn!("{err}");
Err(SandboxError::failed_precondition(err.to_string()))
}
Err(err @ MiseError::NotInstalled(_)) => {
tracing::warn!("skipped installing mise tools: {err}");
Ok(())
}
Err(err @ MiseError::Exec { .. }) => {
tracing::error!(error = ?err, "failed to install mise tools in {}", sb.name());
Err(SandboxError::internal("failed to install mise tools"))
}
}
}
async fn assign_hostname(
sb: &SandboxHandle,
request: StartSandbox<'_>,
) -> Result<(), SandboxError> {
let workspace = match request.workspace {
"" => request.name,
workspace => workspace,
};
let hostname = ssh::pick_hostname(workspace, &taken_hostnames().await?);
let result = sb
.modify()
.label(ssh::HOSTNAME_LABEL, &hostname)
.apply()
.await;
if let Err(err) = result {
tracing::warn!("failed to assign host name to {}: {err}", request.name);
}
Ok(())
}
async fn list_all_sandboxes() -> Result<Vec<SandboxHandle>, SandboxError> {
list_all().await.map_err(|err| {
tracing::error!(error = ?err, "failed to list sandboxes");
SandboxError::internal("failed to list sandboxes")
})
}
const LIST_ATTEMPTS: u32 = 5;
pub async fn list_all() -> Result<Vec<SandboxHandle>, MicrosandboxError> {
retry_while_not_found(list_pages).await
}
async fn list_pages() -> Result<Vec<SandboxHandle>, MicrosandboxError> {
let mut sandboxes = vec![];
let mut next_cursor: Option<String> = None;
loop {
let result = Sandbox::list_with(|opt| match next_cursor.take() {
Some(cursor) => opt.cursor(cursor),
None => opt,
})
.await?;
sandboxes.extend(result.sandboxes);
if result.next_cursor.is_none() {
break;
}
next_cursor = result.next_cursor;
}
Ok(sandboxes)
}
async fn retry_while_not_found<T, F, Fut>(mut listing: F) -> Result<T, MicrosandboxError>
where
F: FnMut() -> Fut,
Fut: Future<Output = Result<T, MicrosandboxError>>,
{
let mut attempt = 1;
loop {
match listing().await {
Err(MicrosandboxError::SandboxNotFound(name)) if attempt < LIST_ATTEMPTS => {
tracing::debug!(
"sandbox {name} disappeared while listing sandboxes; listing again"
);
attempt += 1;
}
result => return result,
}
}
}
enum SecretChange<'a> {
Add(&'a Secret),
Remove(&'a str),
}
impl fmt::Display for SecretChange<'_> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
SecretChange::Add(secret) => write!(f, "add secret {} to", secret.name()),
SecretChange::Remove(name) => write!(f, "remove secret {name} from"),
}
}
}
async fn update_firebrick_sandboxes(
change: SecretChange<'_>,
skipped: &HashSet<String>,
) -> Result<Vec<String>, SandboxError> {
let mut failed = vec![];
for handle in list_all_sandboxes().await? {
if ssh::hostname_of(&handle).is_none() || skipped.contains(handle.name()) {
continue;
}
failed.extend(apply_to_sandbox(&handle, &change).await);
}
Ok(failed)
}
async fn apply_to_sandbox(handle: &SandboxHandle, change: &SecretChange<'_>) -> Vec<String> {
let result = match change {
SecretChange::Add(secret) => secrets::add_to_sandbox(handle, secret).await,
SecretChange::Remove(name) => secrets::remove_from_sandbox(handle, name).await,
};
match result {
Ok(()) => vec![],
Err(err) => {
tracing::warn!(error = ?err, "failed to {change} sandbox {}", handle.name());
vec![handle.name().to_string()]
}
}
}
async fn taken_hostnames() -> Result<HashSet<String>, SandboxError> {
Ok(list_all_sandboxes()
.await?
.iter()
.filter_map(ssh::hostname_of)
.collect())
}
pub async fn sync_ssh_config() {
let sandboxes = match list_all_sandboxes().await {
Ok(sandboxes) => sandboxes,
Err(err) => {
tracing::warn!("failed to update SSH config: {err}");
return;
}
};
let hostnames: Vec<String> = sandboxes.iter().filter_map(ssh::hostname_of).collect();
if let Err(err) = ssh::sync_config(&hostnames) {
tracing::warn!("failed to update SSH config: {err:#}");
}
sync_editor_settings(&hostnames);
sync_zed_settings(&sandboxes);
}
fn sync_editor_settings(hostnames: &[String]) {
let Some(config_root) = vscode::config_root() else {
tracing::warn!("failed to update editor settings: HOME is not set");
return;
};
for err in vscode::sync_settings(&config_root, hostnames) {
tracing::warn!(
"failed to update editor settings: {:#}",
anyhow::Error::from(err)
);
}
}
fn sync_zed_settings(sandboxes: &[SandboxHandle]) {
let Some(config_root) = zed::config_root() else {
tracing::warn!("failed to update Zed settings: HOME is not set");
return;
};
let projects: Vec<zed::RemoteProject> = sandboxes.iter().filter_map(remote_project).collect();
if let Err(err) = zed::sync_settings(&config_root, &projects) {
tracing::warn!(
"failed to update Zed settings: {:#}",
anyhow::Error::from(err)
);
}
}
fn remote_project(handle: &SandboxHandle) -> Option<zed::RemoteProject> {
Some(zed::RemoteProject {
host: ssh::hostname_of(handle)?,
nickname: handle.name().to_string(),
workspace_path: workspace_path_of(handle),
})
}
fn workspace_mount_path(workspace: &str) -> Result<String, SandboxError> {
let path = Path::new(workspace);
if !path.is_absolute() {
return Err(SandboxError::invalid_argument(
"workspace must be an absolute path",
));
}
let leaf = path.file_name().ok_or_else(|| {
SandboxError::invalid_argument("workspace must not be the root directory")
})?;
Ok(format!("/workspaces/{}", leaf.to_string_lossy()))
}
fn check_mounts(mounts: &[MountSpec], workspace_guest_path: &str) -> Result<(), SandboxError> {
mounts
.iter()
.try_for_each(|mount| check_mount(mount, workspace_guest_path))
}
fn check_mount(mount: &MountSpec, workspace_guest_path: &str) -> Result<(), SandboxError> {
if !Path::new(&mount.host).is_absolute() {
return Err(SandboxError::invalid_argument(format!(
"mount host path {} must be an absolute path",
mount.host
)));
}
let guest = firebrick_spec::guest_mount_path(&mount.guest)
.map_err(|err| SandboxError::invalid_argument(format!("mount {}: {err}", mount.guest)))?;
if guest == workspace_guest_path {
return Err(SandboxError::invalid_argument(format!(
"mount {guest} conflicts with the workspace mount"
)));
}
if guest == DOCKER_DATA_PATH {
return Err(SandboxError::invalid_argument(format!(
"mount {guest} conflicts with the Docker data volume"
)));
}
Ok(())
}
fn with_mounts(builder: SandboxBuilder, mounts: &[MountSpec]) -> SandboxBuilder {
mounts.iter().fold(builder, |builder, mount| {
builder.volume(&mount.guest, |m| bind_mount(m, mount))
})
}
fn bind_mount(builder: MountBuilder, mount: &MountSpec) -> MountBuilder {
let builder = builder
.bind(&mount.host)
.host_permissions(HostPermissions::Mirror)
.owner(1000, 1000);
if mount.readonly {
builder.readonly()
} else {
builder
}
}
fn sandbox_image(image: &str) -> &str {
if image.is_empty() {
firebrick_spec::DEFAULT_IMAGE
} else {
image
}
}
const INIT_PATH: &str = "/sbin/init";
fn with_resources(builder: SandboxBuilder, resources: Resources) -> SandboxBuilder {
builder
.cpus(resources.cpus)
.memory(resources.memory_mib)
.volume(DOCKER_DATA_PATH, |m| {
m.owned_with(|v| v.disk().size(resources.docker_volume_mib))
})
}
fn with_init(builder: SandboxBuilder, init: bool) -> SandboxBuilder {
if init {
builder.init(INIT_PATH)
} else {
builder
}
}
async fn create_failed(name: &str, image: &str, err: &MicrosandboxError) -> SandboxError {
let error = create_error(image, err);
if matches!(error, SandboxError::FailedPrecondition(_))
&& let Err(err) = Sandbox::remove(name).await
{
tracing::warn!("failed to remove sandbox {name} after a failed create: {err}");
}
error
}
fn create_error(image: &str, err: &MicrosandboxError) -> SandboxError {
match err {
MicrosandboxError::Image(ImageError::Registry(err)) => registry_error(image, err),
err if err.to_string().contains("handoff failed") => SandboxError::failed_precondition(
"failed to create sandbox: the image has no /sbin/init; add one or set init: false \
in .firebrick.yml",
),
_ => SandboxError::internal("failed to create sandbox"),
}
}
fn registry_error(image: &str, err: &OciDistributionError) -> SandboxError {
match err {
OciDistributionError::UnauthorizedError { .. } => SandboxError::not_found(format!(
"failed to create sandbox: image {image} doesn't exist, or its registry needs a login"
)),
OciDistributionError::ImageManifestNotFoundError(_) => image_not_found(image),
OciDistributionError::RegistryError { envelope, .. } if is_not_found(envelope) => {
image_not_found(image)
}
OciDistributionError::RequestError(_) => SandboxError::unavailable(format!(
"failed to create sandbox: couldn't reach the registry of image {image}; check the \
network connection"
)),
_ => SandboxError::internal(format!(
"failed to create sandbox: failed to pull image {image}"
)),
}
}
fn image_not_found(image: &str) -> SandboxError {
SandboxError::not_found(format!(
"failed to create sandbox: image {image} doesn't exist"
))
}
fn is_not_found(envelope: &OciEnvelope) -> bool {
envelope.errors.iter().any(|error| {
matches!(
error.code,
OciErrorCode::ManifestUnknown | OciErrorCode::NameUnknown | OciErrorCode::NotFound
)
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::open::HostOpener;
fn manager_with_secrets(path: &Path, names: &[&str]) -> SandboxManager {
let store = SecretStore::new(path);
for name in names {
let secret =
Secret::new(name.to_string(), "value".into(), vec!["example.com".into()]).unwrap();
store.set(secret).unwrap();
}
SandboxManager::new(store, Arc::new(HostOpener))
}
fn listing_failing_with(
errors: Vec<MicrosandboxError>,
) -> (
Arc<std::sync::atomic::AtomicU32>,
impl FnMut() -> std::future::Ready<Result<(), MicrosandboxError>>,
) {
let calls = Arc::new(std::sync::atomic::AtomicU32::new(0));
let counter = calls.clone();
let mut errors = errors.into_iter();
let listing = move || {
counter.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
std::future::ready(errors.next().map_or(Ok(()), Err))
};
(calls, listing)
}
fn not_found() -> MicrosandboxError {
MicrosandboxError::SandboxNotFound("removed".into())
}
#[tokio::test]
async fn listing_starts_over_when_a_sandbox_disappears_during_it() {
let (calls, listing) = listing_failing_with(vec![not_found(), not_found()]);
assert!(retry_while_not_found(listing).await.is_ok());
assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 3);
}
#[tokio::test]
async fn listing_gives_up_after_the_last_attempt() {
let errors = (0..LIST_ATTEMPTS).map(|_| not_found()).collect();
let (calls, listing) = listing_failing_with(errors);
assert!(matches!(
retry_while_not_found(listing).await,
Err(MicrosandboxError::SandboxNotFound(_))
));
assert_eq!(
calls.load(std::sync::atomic::Ordering::SeqCst),
LIST_ATTEMPTS
);
}
#[tokio::test]
async fn listing_returns_other_errors_without_retrying() {
let error = MicrosandboxError::Runtime("broken".into());
let (calls, listing) = listing_failing_with(vec![error]);
assert!(matches!(
retry_while_not_found(listing).await,
Err(MicrosandboxError::Runtime(_))
));
assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
}
fn scoped_secret(name: &str, sandbox: Option<&str>) -> Secret {
Secret::new(name.to_string(), "value".into(), vec!["example.com".into()])
.unwrap()
.in_scope(sandbox.map(str::to_string))
}
#[test]
fn removal_puts_the_global_secret_back_in_a_sandbox() {
let stored = [scoped_secret("X", None), scoped_secret("X", Some("a"))];
assert!(matches!(
removal(&stored, "X", Some("a")),
SecretChange::Add(secret) if secret == &stored[0]
));
assert!(matches!(
removal(&stored[1..], "X", Some("a")),
SecretChange::Remove("X")
));
assert!(matches!(
removal(&stored, "X", None),
SecretChange::Remove("X")
));
}
async fn poll_once_pending<F: Future>(mut future: std::pin::Pin<&mut F>) -> bool {
std::future::poll_fn(|cx| std::task::Poll::Ready(future.as_mut().poll(cx).is_pending()))
.await
}
fn lock_count(manager: &SandboxManager) -> usize {
manager.sandbox_locks.lock().unwrap().len()
}
#[tokio::test]
async fn released_sandbox_lock_is_removed() {
let dir = tempfile::TempDir::new().unwrap();
let manager = manager_with_secrets(&dir.path().join("secrets.yml"), &[]);
let guard = manager.lock_sandbox("fbk-unit-lock").await;
assert_eq!(lock_count(&manager), 1);
drop(guard);
assert_eq!(lock_count(&manager), 0);
}
#[tokio::test]
async fn sandbox_lock_is_kept_while_a_task_waits_for_it() {
let dir = tempfile::TempDir::new().unwrap();
let manager = manager_with_secrets(&dir.path().join("secrets.yml"), &[]);
let first = manager.lock_sandbox("fbk-unit-lock").await;
let second = manager.lock_sandbox("fbk-unit-lock");
tokio::pin!(second);
assert!(poll_once_pending(second.as_mut()).await);
drop(first);
assert_eq!(lock_count(&manager), 1);
drop(second.await);
assert_eq!(lock_count(&manager), 0);
}
#[test]
fn sandbox_image_falls_back_to_default() {
assert_eq!(sandbox_image("alpine:3.22"), "alpine:3.22");
assert_eq!(sandbox_image(""), firebrick_spec::DEFAULT_IMAGE);
}
const IMAGE: &str = "alpine:0.0.404";
fn registry_error_with_code(code: &str) -> MicrosandboxError {
let envelope = serde_yaml::from_str(&format!("errors: [{{code: {code}}}]")).unwrap();
MicrosandboxError::Image(ImageError::Registry(OciDistributionError::RegistryError {
envelope,
url: "https://registry.example/v2/alpine/manifests/0.0.404".to_string(),
}))
}
#[test]
fn create_error_explains_missing_init() {
let error = create_error(
IMAGE,
&MicrosandboxError::Custom(
"guest initialization failed: handoff failed: init error: no init binary found"
.to_string(),
),
);
assert!(
matches!(&error, SandboxError::FailedPrecondition(message) if message.contains("set init: false")),
"{error:?}"
);
}
#[test]
fn create_error_hides_other_failures() {
assert_eq!(
create_error(IMAGE, &MicrosandboxError::Custom("boom".to_string())),
SandboxError::internal("failed to create sandbox")
);
}
#[test]
fn create_error_names_an_unknown_image() {
for code in ["MANIFEST_UNKNOWN", "NAME_UNKNOWN", "NOT_FOUND"] {
assert_eq!(
create_error(IMAGE, ®istry_error_with_code(code)),
SandboxError::not_found(
"failed to create sandbox: image alpine:0.0.404 doesn't exist"
),
"{code}"
);
}
}
#[test]
fn create_error_names_an_image_the_registry_refuses() {
let err = MicrosandboxError::Image(ImageError::Registry(
OciDistributionError::UnauthorizedError {
url: "https://ghcr.io/v2/user/private/manifests/1".to_string(),
},
));
assert_eq!(
create_error(IMAGE, &err),
SandboxError::not_found(
"failed to create sandbox: image alpine:0.0.404 doesn't exist, or its registry \
needs a login"
)
);
}
#[test]
fn create_error_names_the_image_of_other_registry_errors() {
assert_eq!(
create_error(IMAGE, ®istry_error_with_code("DENIED")),
SandboxError::internal("failed to create sandbox: failed to pull image alpine:0.0.404")
);
}
#[test]
fn workspace_mount_path_uses_leaf_name() {
assert_eq!(
workspace_mount_path("/home/user/project").unwrap(),
"/workspaces/project"
);
assert_eq!(
workspace_mount_path("/home/user/project/").unwrap(),
"/workspaces/project"
);
}
#[test]
fn workspace_mount_path_rejects_relative_empty_and_root_paths() {
for workspace in ["", "project", "./project", "/"] {
assert!(
matches!(
workspace_mount_path(workspace),
Err(SandboxError::InvalidArgument(_))
),
"{workspace:?} should be rejected"
);
}
}
fn mount(host: &str, guest: &str) -> MountSpec {
MountSpec {
host: host.to_string(),
guest: guest.to_string(),
readonly: false,
}
}
#[test]
fn check_mounts_accepts_absolute_paths() {
let mounts = [mount("/home/user/lib", "/workspaces/lib")];
assert_eq!(check_mounts(&mounts, "/workspaces/project"), Ok(()));
}
#[test]
fn check_mounts_rejects_the_workspace_guest_path() {
let mounts = [mount("/home/user/lib", "/workspaces/project")];
assert_eq!(
check_mounts(&mounts, "/workspaces/project"),
Err(SandboxError::InvalidArgument(
"mount /workspaces/project conflicts with the workspace mount".into()
))
);
}
#[test]
fn check_mounts_rejects_the_workspace_guest_path_in_another_form() {
let mounts = [mount("/home/user/lib", "/workspaces/project/.")];
assert_eq!(
check_mounts(&mounts, "/workspaces/project"),
Err(SandboxError::InvalidArgument(
"mount /workspaces/project conflicts with the workspace mount".into()
))
);
}
#[test]
fn check_mounts_rejects_the_docker_data_path() {
let mounts = [mount("/home/user/docker", "/var/lib/docker/")];
assert_eq!(
check_mounts(&mounts, "/workspaces/project"),
Err(SandboxError::InvalidArgument(
"mount /var/lib/docker conflicts with the Docker data volume".into()
))
);
}
#[test]
fn check_mounts_rejects_relative_and_invalid_paths() {
for mounts in [
[mount("../lib", "/lib")],
[mount("/home/user/lib", "lib")],
[mount("/home/user/lib", "/")],
[mount("/home/user/lib", "/lib/../etc")],
[mount("/home/user/lib", "/lib:v1")],
] {
assert!(
matches!(
check_mounts(&mounts, "/workspaces/project"),
Err(SandboxError::InvalidArgument(_))
),
"{mounts:?} should be rejected"
);
}
}
#[test]
fn list_secrets_sorts_by_name_then_scope_with_global_first() {
let dir = tempfile::tempdir().unwrap();
let store = SecretStore::new(dir.path().join("secrets.yml"));
let entries = [
("B", Some("a")),
("B", None),
("A", Some("z")),
("A", None),
("A", Some("b")),
];
for (name, sandbox) in entries {
store.set(scoped_secret(name, sandbox)).unwrap();
}
let manager = SandboxManager::new(store, Arc::new(HostOpener));
let listed = manager.list_secrets().unwrap();
let expected = [
("A", None),
("A", Some("b")),
("A", Some("z")),
("B", None),
("B", Some("a")),
]
.map(|(name, sandbox)| scoped_secret(name, sandbox));
assert_eq!(listed, expected);
}
#[tokio::test]
async fn set_secret_returns_not_found_for_missing_sandbox_without_storing_it() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("secrets.yml");
let manager = SandboxManager::new(SecretStore::new(&path), Arc::new(HostOpener));
let sandbox = format!("fbk-unit-missing-{}", std::process::id());
let result = manager
.set_secret(NewSecret {
name: "X".into(),
value: "value".into(),
allowed_hosts: vec!["example.com".into()],
sandbox: Some(sandbox.clone()),
})
.await;
assert_eq!(
result,
Err(SandboxError::NotFound(format!(
"sandbox {sandbox} doesn't exist"
)))
);
assert!(!path.exists());
}
#[tokio::test]
async fn remove_secret_returns_not_found_for_missing_sandbox_and_keeps_the_store() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("secrets.yml");
let manager = manager_with_secrets(&path, &["X"]);
let sandbox = format!("fbk-unit-missing-{}", std::process::id());
let result = manager.remove_secret("X", Some(&sandbox)).await;
assert_eq!(
result,
Err(SandboxError::NotFound(format!(
"sandbox {sandbox} doesn't exist"
)))
);
assert_eq!(SecretStore::new(&path).load().unwrap().len(), 1);
}
#[test]
fn ensure_secret_exists_checks_the_scope() {
let global = scoped_secret("X", None);
let scoped = scoped_secret("X", Some("a"));
assert_eq!(
ensure_secret_exists(std::slice::from_ref(&scoped), "X", Some("a")),
Ok(())
);
assert_eq!(
ensure_secret_exists(std::slice::from_ref(&global), "X", None),
Ok(())
);
assert_eq!(
ensure_secret_exists(&[global], "X", Some("a")),
Err(SandboxError::NotFound(
"secret X doesn't exist in sandbox a".into()
))
);
assert_eq!(
ensure_secret_exists(&[scoped], "X", None),
Err(SandboxError::NotFound("secret X doesn't exist".into()))
);
}
#[tokio::test]
async fn set_secret_rejects_invalid_secret_without_storing_it() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("secrets.yml");
let manager = SandboxManager::new(SecretStore::new(&path), Arc::new(HostOpener));
let result = manager
.set_secret(NewSecret {
name: "NOT-A-NAME".into(),
value: "value".into(),
allowed_hosts: vec![],
sandbox: None,
})
.await;
assert!(matches!(result, Err(SandboxError::InvalidArgument(_))));
assert!(!path.exists());
}
#[tokio::test]
async fn remove_secret_returns_not_found_and_keeps_the_store() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("secrets.yml");
let manager = manager_with_secrets(&path, &["A"]);
let result = manager.remove_secret("B", None).await;
assert_eq!(
result,
Err(SandboxError::NotFound("secret B doesn't exist".into()))
);
assert_eq!(SecretStore::new(&path).load().unwrap().len(), 1);
}
#[test]
fn is_in_use_matches_only_microsandbox_in_use_error() {
let in_use = MicrosandboxError::Custom(
"snapshot is in use by an active operation: /msb/snapshots/demo/snap_1".to_string(),
);
assert!(is_in_use(&in_use));
assert!(!is_in_use(&MicrosandboxError::Custom("boom".to_string())));
assert!(!is_in_use(&MicrosandboxError::SnapshotNotFound(
"snap_1".to_string()
)));
}
}