use boxlite_shared::{
BindMount, BoxliteError, BoxliteResult, CaCert,
ContainerAdvancedOptions as ProtoContainerAdvancedOptions,
ContainerCapabilities as ProtoContainerCapabilities, ContainerClient,
ContainerConfig as ProtoContainerConfig, ContainerDevice, ContainerInitErrorKind,
ContainerInitRequest, DiskRootfs, LinuxOptions, MergedRootfs, MountOptions, OverlayRootfs,
RootfsInit, container_init_response,
};
use tonic::transport::Channel;
use crate::images::ContainerImageConfig;
use crate::runtime::advanced_options::{
ContainerCapabilities, ResolvedContainerSecurityConfig, ResolvedLinuxSecurity,
ResolvedMountSecurity,
};
use crate::volumes::ContainerMount;
#[derive(Debug, Clone)]
pub enum ContainerRootfsInitConfig {
#[allow(dead_code)] Merged,
#[allow(dead_code)] Overlay {
layer_names: Vec<String>,
copy_layers: bool,
},
DiskImage {
device: String,
need_format: bool,
need_resize: bool,
},
}
impl ContainerRootfsInitConfig {
pub(crate) fn into_proto(self) -> RootfsInit {
match self {
ContainerRootfsInitConfig::Merged => RootfsInit {
strategy: Some(boxlite_shared::rootfs_init::Strategy::Merged(
MergedRootfs {},
)),
},
ContainerRootfsInitConfig::Overlay {
layer_names,
copy_layers,
} => RootfsInit {
strategy: Some(boxlite_shared::rootfs_init::Strategy::Overlay(
OverlayRootfs {
layer_names,
copy_layers,
},
)),
},
ContainerRootfsInitConfig::DiskImage {
device,
need_format,
need_resize,
} => RootfsInit {
strategy: Some(boxlite_shared::rootfs_init::Strategy::Disk(DiskRootfs {
device,
need_format,
need_resize,
})),
},
}
}
}
pub struct ContainerInitConfig {
pub container_id: String,
pub image: ContainerImageConfig,
pub rootfs: ContainerRootfsInitConfig,
pub mounts: Vec<ContainerMount>,
pub ca_certs: Vec<String>,
pub tty: bool,
pub devices: Vec<ContainerDevice>,
pub advanced: ContainerAdvancedConfig,
}
#[derive(Debug, Clone, Default)]
pub struct ContainerAdvancedConfig {
pub capabilities: ContainerCapabilities,
pub(crate) linux: ResolvedLinuxSecurity,
pub(crate) mount: ResolvedMountSecurity,
}
impl From<ResolvedContainerSecurityConfig> for ContainerAdvancedConfig {
fn from(value: ResolvedContainerSecurityConfig) -> Self {
Self {
capabilities: value.capabilities,
linux: value.linux,
mount: value.mount,
}
}
}
pub struct ContainerInterface {
client: ContainerClient<Channel>,
}
impl ContainerInterface {
pub fn new(channel: Channel) -> Self {
Self {
client: ContainerClient::new(channel),
}
}
pub async fn init(&mut self, config: ContainerInitConfig) -> BoxliteResult<String> {
let ContainerInitConfig {
container_id,
image,
rootfs,
mounts,
ca_certs,
tty,
devices,
advanced,
} = config;
let proto_config = ProtoContainerConfig {
entrypoint: image.final_cmd(),
env: image.env.clone(),
workdir: image.working_dir.clone(),
user: image.user.clone(),
tty,
advanced: Some(ProtoContainerAdvancedOptions {
capabilities: Some(ProtoContainerCapabilities {
add: advanced.capabilities.add,
drop: advanced.capabilities.drop,
}),
linux: Some(LinuxOptions {
readonly_paths: advanced.linux.readonly_paths,
}),
mount: Some(MountOptions {
source: "/sys".to_string(),
destination: "/sys".to_string(),
options: advanced.mount.options,
}),
}),
};
let proto_mounts: Vec<BindMount> = mounts
.into_iter()
.map(|m| BindMount {
volume_name: m.volume_name,
destination: m.destination,
read_only: m.read_only,
owner_uid: m.owner_uid,
owner_gid: m.owner_gid,
subpath: m.subpath.unwrap_or_default(),
})
.collect();
tracing::debug!(container_id = %container_id, "Sending ContainerInit request");
tracing::trace!(
container_id = %container_id,
entrypoint = ?image.entrypoint,
cmd = ?image.cmd,
user = %image.user,
workdir = %image.working_dir,
env_count = image.env.len(),
advanced = ?proto_config.advanced,
rootfs = ?rootfs,
mounts_count = proto_mounts.len(),
device_count = devices.len(),
"Container configuration"
);
let request = ContainerInitRequest {
container_id: container_id.clone(),
container_config: Some(proto_config),
rootfs: Some(rootfs.into_proto()),
mounts: proto_mounts,
ca_certs: ca_certs.into_iter().map(|pem| CaCert { pem }).collect(),
execution_id: container_id.clone(),
devices,
};
let response = self
.client
.init(request)
.await
.map_err(map_container_init_status)?
.into_inner();
match response.result {
Some(container_init_response::Result::Success(success)) => {
tracing::debug!(container_id = %success.container_id, "Container initialized");
Ok(success.container_id)
}
Some(container_init_response::Result::Error(err)) => {
tracing::error!(container_id = %container_id, "Container init failed: {}", err.reason);
let reason = format!("Container init failed: {}", err.reason);
match ContainerInitErrorKind::try_from(err.kind) {
Ok(ContainerInitErrorKind::Unsupported) => {
Err(BoxliteError::Unsupported(reason))
}
_ => Err(BoxliteError::Internal(reason)),
}
}
None => Err(BoxliteError::Internal(
"ContainerInit response missing result".to_string(),
)),
}
}
pub async fn start(&mut self, container_id: &str) -> BoxliteResult<()> {
use boxlite_shared::{ContainerStartRequest, container_start_response};
let response = match self
.client
.start(ContainerStartRequest {
container_id: container_id.to_string(),
})
.await
{
Ok(response) => response.into_inner(),
Err(status) if status.code() == tonic::Code::Unimplemented => {
tracing::warn!(
container_id = %container_id,
"guest agent predates Container.Start (pre-#988); its Init already \
started the container — treating as started"
);
return Ok(());
}
Err(status) => return Err(status.into()),
};
match response.result {
Some(container_start_response::Result::Success(_)) => {
tracing::debug!(container_id = %container_id, "Container started");
Ok(())
}
Some(container_start_response::Result::Error(err)) => {
tracing::error!(container_id = %container_id, "Container start failed: {}", err.reason);
Err(BoxliteError::Internal(format!(
"Container start failed: {}",
err.reason
)))
}
None => Err(BoxliteError::Internal(
"ContainerStart response missing result".to_string(),
)),
}
}
}
fn map_container_init_status(status: tonic::Status) -> BoxliteError {
if status.code() == tonic::Code::InvalidArgument {
return BoxliteError::InvalidArgument(status.message().to_owned());
}
status.into()
}
#[cfg(test)]
mod tests {
use super::*;
use boxlite_shared::{
Container as ContainerService, ContainerInitRequest, ContainerInitResponse,
ContainerInitSuccess, ContainerServer, ContainerStartRequest, ContainerStartResponse,
ContainerStartSuccess, container_init_response, container_start_response,
};
use std::sync::{Arc, Mutex};
use tonic::transport::{Endpoint, Server};
use tonic::{Request, Response, Status};
#[test]
fn container_init_preserves_invalid_argument_status() {
let error = map_container_init_status(Status::invalid_argument(
"unknown Linux capability 'CAP_FUTURE'",
));
assert!(matches!(error, BoxliteError::InvalidArgument(_)));
assert_eq!(error.http().0, 400);
}
#[derive(Clone, Copy)]
enum StartReply {
Unimplemented,
RealError,
Success,
}
struct StubGuest {
start_reply: StartReply,
seen_init: Arc<Mutex<Option<ContainerInitRequest>>>,
}
#[tonic::async_trait]
impl ContainerService for StubGuest {
async fn init(
&self,
request: Request<ContainerInitRequest>,
) -> Result<Response<ContainerInitResponse>, Status> {
let request = request.into_inner();
let container_id = request.container_id.clone();
*self.seen_init.lock().unwrap() = Some(request);
Ok(Response::new(ContainerInitResponse {
result: Some(container_init_response::Result::Success(
ContainerInitSuccess { container_id },
)),
}))
}
async fn start(
&self,
request: Request<ContainerStartRequest>,
) -> Result<Response<ContainerStartResponse>, Status> {
let container_id = request.into_inner().container_id;
match self.start_reply {
StartReply::Unimplemented => Err(Status::unimplemented("")),
StartReply::RealError => Err(Status::internal("guest blew up")),
StartReply::Success => Ok(Response::new(ContainerStartResponse {
result: Some(container_start_response::Result::Success(
ContainerStartSuccess { container_id },
)),
})),
}
}
}
async fn interface_for(start_reply: StartReply) -> ContainerInterface {
interface_recording(start_reply, Arc::new(Mutex::new(None))).await
}
async fn interface_recording(
start_reply: StartReply,
seen_init: Arc<Mutex<Option<ContainerInitRequest>>>,
) -> ContainerInterface {
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let addr = listener.local_addr().unwrap();
drop(listener);
tokio::spawn(async move {
Server::builder()
.add_service(ContainerServer::new(StubGuest {
start_reply,
seen_init,
}))
.serve(addr)
.await
.unwrap();
});
let endpoint = Endpoint::from_shared(format!("http://{addr}")).unwrap();
let mut attempts = 0;
let channel = loop {
match endpoint.connect().await {
Ok(channel) => break channel,
Err(e) => {
attempts += 1;
assert!(
attempts < 100,
"stub guest never accepted a connection: {e}"
);
tokio::time::sleep(std::time::Duration::from_millis(20)).await;
}
}
};
ContainerInterface::new(channel)
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn container_start_tolerates_unimplemented_from_legacy_guest() {
let mut iface = interface_for(StartReply::Unimplemented).await;
let result = iface.start("box-legacy").await;
assert!(
result.is_ok(),
"legacy guest (Start Unimplemented) must be tolerated, got {result:?}"
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn container_start_propagates_real_error() {
let mut iface = interface_for(StartReply::RealError).await;
let result = iface.start("box-broken").await;
assert!(
result.is_err(),
"a real Start error must not be swallowed: {result:?}"
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn container_start_ok_on_success() {
let mut iface = interface_for(StartReply::Success).await;
assert!(iface.start("box-ok").await.is_ok());
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn container_init_sends_devices_and_session_id() {
let seen = Arc::new(Mutex::new(None));
let mut iface = interface_recording(StartReply::Success, Arc::clone(&seen)).await;
iface
.init(ContainerInitConfig {
container_id: "container-1".to_string(),
image: crate::images::ContainerImageConfig::default(),
rootfs: ContainerRootfsInitConfig::Merged,
mounts: Vec::new(),
ca_certs: Vec::new(),
tty: true,
devices: vec![ContainerDevice {
source: "/dev/kvm".to_string(),
destination: "/dev/kvm".to_string(),
file_mode: Some(0o666),
}],
advanced: ContainerAdvancedConfig {
capabilities: crate::runtime::advanced_options::ContainerCapabilities {
add: vec!["ALL".into()],
..Default::default()
},
linux: ResolvedLinuxSecurity {
readonly_paths: Vec::new(),
},
mount: ResolvedMountSecurity {
options: vec![
"rbind".to_string(),
"nosuid".to_string(),
"noexec".to_string(),
"nodev".to_string(),
],
},
},
})
.await
.unwrap();
let request = seen.lock().unwrap().take().expect("guest saw Init");
assert_eq!(request.devices.len(), 1);
assert_eq!(request.devices[0].destination, "/dev/kvm");
assert_eq!(request.devices[0].file_mode, Some(0o666));
assert_eq!(request.container_id, "container-1");
assert_eq!(request.execution_id, "container-1");
let container_config = request.container_config.expect("process config");
assert!(container_config.tty);
let advanced = container_config.advanced.expect("advanced options");
assert_eq!(
advanced.capabilities.expect("capabilities").add,
vec!["ALL".to_string()]
);
assert!(
advanced
.linux
.expect("linux options")
.readonly_paths
.is_empty()
);
let mount = advanced.mount.expect("mount options");
assert_eq!(mount.source, "/sys");
assert_eq!(mount.destination, "/sys");
assert!(!mount.options.contains(&"rro".to_string()));
}
}