#![cfg(feature = "containerd-integration")]
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::{Arc, Mutex};
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use anyhow::{anyhow, Context, Result};
use async_trait::async_trait;
use containerd_client::{
services::v1::{
containers_client::ContainersClient,
snapshots::{snapshots_client::SnapshotsClient, RemoveSnapshotRequest},
tasks_client::TasksClient,
version_client::VersionClient,
Container, CreateContainerRequest, CreateTaskRequest, DeleteContainerRequest,
DeleteTaskRequest, GetContainerRequest, KillRequest, ListContainersRequest, StartRequest,
},
tonic, with_namespace,
};
use containerd_client::tonic::Request;
use kamaji_containerd_core as kcc;
use tokio::io::{AsyncBufReadExt, BufReader};
use tokio_stream::wrappers::LinesStream;
use tokio_stream::StreamExt as TokioStreamExt;
use workload_spec::{MeshIdent, WorkloadSpec};
use crate::socket_custody::{self, SocketCustodian};
use crate::{
Backend, DeployResult, Kamaji, LogEvent, LogOpts, LogStream, LogStreamKind, MeshAssignment,
RuntimeHealth, WorkloadState, WorkloadStatus,
};
use std::path::Path;
pub use kamaji_containerd_core::{DEFAULT_SOCKET, LOG_BASE, YAH_NAMESPACE};
const UPGRADE_SETTLE: Duration = Duration::from_millis(750);
const SIGQUIT: u32 = 3;
#[derive(Clone)]
pub struct ContainerdRuntime {
channel: tonic::transport::Channel,
namespace: String,
log_base: PathBuf,
ledger: RestartLedger,
slots: Arc<Mutex<HashMap<String, kcc::PodSlot>>>,
custodian: Arc<SocketCustodian>,
}
#[derive(Debug, Clone, Copy)]
pub struct RestartRecord {
pub last_exit_code: i32,
pub restart_count: u32,
pub last_finished_at: SystemTime,
pub in_flight: bool,
}
#[derive(Clone, Default)]
pub struct RestartLedger {
inner: Arc<Mutex<HashMap<String, RestartRecord>>>,
}
impl RestartLedger {
pub fn new() -> Self {
Self::default()
}
pub fn record_exit(&self, container_id: &str, exit_code: i32) {
let mut g = self.inner.lock().unwrap();
let now = SystemTime::now();
g.entry(container_id.to_string())
.and_modify(|r| {
r.last_exit_code = exit_code;
r.restart_count = r.restart_count.saturating_add(1);
r.last_finished_at = now;
r.in_flight = true;
})
.or_insert(RestartRecord {
last_exit_code: exit_code,
restart_count: 1,
last_finished_at: now,
in_flight: true,
});
}
pub fn mark_running(&self, container_id: &str) {
let mut g = self.inner.lock().unwrap();
if let Some(r) = g.get_mut(container_id) {
r.in_flight = false;
}
}
pub fn forget(&self, container_id: &str) {
let mut g = self.inner.lock().unwrap();
g.remove(container_id);
}
pub fn get(&self, container_id: &str) -> Option<RestartRecord> {
self.inner.lock().unwrap().get(container_id).copied()
}
}
fn apply_ledger(base: WorkloadStatus, rec: Option<RestartRecord>) -> WorkloadStatus {
let rec = match rec {
Some(r) if r.in_flight && r.restart_count > 0 => r,
_ => return base,
};
match base {
WorkloadStatus::Stopped | WorkloadStatus::Failed { .. } => {
let last_finished_at_unix_ms = rec
.last_finished_at
.duration_since(UNIX_EPOCH)
.map(|d| d.as_millis() as u64)
.unwrap_or(0);
WorkloadStatus::Restarting {
last_exit_code: rec.last_exit_code,
restart_count: rec.restart_count,
last_finished_at_unix_ms,
}
}
other => other,
}
}
impl ContainerdRuntime {
pub async fn connect() -> Result<Self> {
Self::connect_at(DEFAULT_SOCKET).await
}
pub async fn connect_at(socket: impl AsRef<std::path::Path>) -> Result<Self> {
let channel = kcc::connect(socket).await?;
Ok(ContainerdRuntime {
channel,
namespace: YAH_NAMESPACE.to_string(),
log_base: PathBuf::from(LOG_BASE),
ledger: RestartLedger::new(),
slots: Arc::new(Mutex::new(HashMap::new())),
custodian: Arc::new(SocketCustodian::new()),
})
}
fn live_container_id(&self, ident: &MeshIdent) -> String {
self.slots
.lock()
.unwrap()
.get(&ident.0)
.copied()
.unwrap_or_default()
.container_id(&ident.0)
}
fn set_slot(&self, ident: &MeshIdent, slot: kcc::PodSlot) {
self.slots.lock().unwrap().insert(ident.0.clone(), slot);
}
fn clear_slot(&self, ident: &MeshIdent) {
self.slots.lock().unwrap().remove(&ident.0);
}
async fn teardown_container(&self, container_id: &str) -> Result<()> {
let mut tasks = self.tasks_client();
let mut ctrs = self.containers_client();
let kill_req = KillRequest {
container_id: container_id.to_string(),
exec_id: String::new(),
signal: 9, all: true,
};
let kill_req = with_namespace!(kill_req, self.namespace);
let _ = tasks.kill(kill_req).await;
tokio::time::sleep(Duration::from_millis(500)).await;
let del_task_req = DeleteTaskRequest {
container_id: container_id.to_string(),
};
let del_task_req = with_namespace!(del_task_req, self.namespace);
let _ = tasks.delete(del_task_req).await;
let del_req = DeleteContainerRequest {
id: container_id.to_string(),
};
let del_req = with_namespace!(del_req, self.namespace);
match ctrs.delete(del_req).await {
Ok(_) => {}
Err(status) if status.code() == tonic::Code::NotFound => {}
Err(e) => {
return Err(anyhow!(e).context(format!("deleting container {container_id}")));
}
}
let rm_snap = RemoveSnapshotRequest {
snapshotter: "overlayfs".to_string(),
key: container_id.to_string(),
};
let rm_snap = with_namespace!(rm_snap, self.namespace);
let _ = self.snapshots_client().remove(rm_snap).await;
self.ledger.forget(container_id);
tracing::info!(container_id = %container_id, "container torn down");
Ok(())
}
async fn create_and_start(
&self,
spec: &WorkloadSpec,
mesh: &MeshAssignment,
container_id: &str,
pod: &kcc::PodOptions,
extra_env: &[String],
) -> Result<DeployResult> {
let image_ref = Self::image_ref(spec);
let image_target_digest =
kcc::resolve_image_target_digest(&self.channel, &self.namespace, &image_ref).await?;
let image_config =
kcc::image_oci_config(&self.channel, &self.namespace, &image_target_digest)
.await
.ok();
let mut deploy_env = vec![format!("YAH_MESH_IP={}", mesh.mesh_ip)];
deploy_env.extend(extra_env.iter().cloned());
let oci_spec = kcc::build_oci_spec_with(spec, &deploy_env, image_config.as_ref(), pod);
let spec_bytes = serde_json::to_vec(&oci_spec).context("serializing OCI spec")?;
let any_spec = prost_types::Any {
type_url: "types.containerd.io/opencontainers/runtime-spec/1/Spec".to_string(),
value: spec_bytes,
};
let log_dir = self.log_dir(container_id);
tokio::fs::create_dir_all(&log_dir)
.await
.with_context(|| format!("creating log dir {}", log_dir.display()))?;
let stdout_file = log_dir.join("stdout.log");
let stderr_file = log_dir.join("stderr.log");
tokio::fs::File::create(&stdout_file)
.await
.with_context(|| format!("creating {}", stdout_file.display()))?;
tokio::fs::File::create(&stderr_file)
.await
.with_context(|| format!("creating {}", stderr_file.display()))?;
let stdout_path = stdout_file.to_string_lossy().into_owned();
let stderr_path = stderr_file.to_string_lossy().into_owned();
{
let mut ctrs = self.containers_client();
let mut labels = spec.labels.clone();
labels.insert("yah.ident".to_string(), spec.expose.mesh.identity.0.clone());
labels.insert("yah.mesh_ip".to_string(), mesh.mesh_ip.to_string());
let container = Container {
id: container_id.to_string(),
image: image_ref.clone(),
runtime: Some(containerd_client::services::v1::container::Runtime {
name: "io.containerd.runc.v2".to_string(),
options: None,
}),
spec: Some(any_spec),
snapshotter: "overlayfs".to_string(),
snapshot_key: container_id.to_string(),
labels,
..Default::default()
};
let req = CreateContainerRequest {
container: Some(container),
};
let req = with_namespace!(req, self.namespace);
ctrs.create(req)
.await
.with_context(|| format!("creating container {container_id}"))?;
}
let rootfs_mounts = self
.prepare_rootfs(container_id, &image_target_digest)
.await
.with_context(|| format!("preparing rootfs for {container_id}"))?;
let task_pid = {
let mut tasks = self.tasks_client();
let req = CreateTaskRequest {
container_id: container_id.to_string(),
rootfs: rootfs_mounts,
stdin: String::new(),
stdout: stdout_path,
stderr: stderr_path,
terminal: false,
checkpoint: None,
options: None,
..Default::default()
};
let req = with_namespace!(req, self.namespace);
let resp = tasks
.create(req)
.await
.with_context(|| format!("creating task for {container_id}"))?;
resp.into_inner().pid
};
{
let mut tasks = self.tasks_client();
let req = StartRequest {
container_id: container_id.to_string(),
exec_id: String::new(),
};
let req = with_namespace!(req, self.namespace);
tasks
.start(req)
.await
.with_context(|| format!("starting task for {container_id}"))?;
}
Ok(DeployResult {
container_id: container_id.to_string(),
mesh_ip: mesh.mesh_ip,
task_pid,
})
}
async fn container_status(&self, container_id: &str) -> Option<WorkloadStatus> {
let mut tasks = self.tasks_client();
match get_task_status(&mut tasks, &self.namespace, container_id).await {
Ok(Some((code, _pid, exit_status))) => Some(Self::map_task_status(code, exit_status)),
_ => None,
}
}
async fn sigquit_task(&self, container_id: &str) -> Result<()> {
let mut tasks = self.tasks_client();
let req = KillRequest {
container_id: container_id.to_string(),
exec_id: String::new(),
signal: SIGQUIT,
all: false,
};
let req = with_namespace!(req, self.namespace);
tasks
.kill(req)
.await
.with_context(|| format!("SIGQUIT {container_id}"))?;
Ok(())
}
async fn passway_pod_options(
&self,
spec: &WorkloadSpec,
slot: kcc::PodSlot,
) -> Result<kcc::PodOptions> {
let Some(sock_dir) = kcc::upgrade_sock_dir(spec) else {
return Ok(kcc::PodOptions::default());
};
let host_dir = kcc::shared_upgrade_hostdir(&spec.expose.mesh.identity.0, slot);
tokio::fs::create_dir_all(&host_dir)
.await
.with_context(|| format!("creating shared upgrade dir {}", host_dir.display()))?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let _ =
tokio::fs::set_permissions(&host_dir, std::fs::Permissions::from_mode(0o700)).await;
}
Ok(kcc::PodOptions {
join_netns: None,
shared_dir: Some((host_dir.to_string_lossy().into_owned(), sock_dir)),
})
}
fn custody_netns(spec: &WorkloadSpec, mesh: &MeshAssignment) -> Option<PathBuf> {
if spec.wants_host_network() {
None
} else {
mesh.netns_name.as_deref().map(socket_custody::netns_path)
}
}
fn host_upgrade_sock(&self, spec: &WorkloadSpec, slot: kcc::PodSlot) -> Option<PathBuf> {
let base = kcc::upgrade_sock_basename(spec)?;
Some(kcc::shared_upgrade_hostdir(&spec.expose.mesh.identity.0, slot).join(base))
}
async fn custody_bind_and_hold(
&self,
ident: &str,
bind_addr: &str,
netns: Option<PathBuf>,
) -> Result<()> {
let cust = self.custodian.clone();
let ident = ident.to_string();
let bind = bind_addr.to_string();
let bind_for_ctx = bind.clone();
tokio::task::spawn_blocking(move || cust.bind_and_hold(&ident, &bind, netns.as_deref()))
.await
.context("custody bind_and_hold task join")?
.with_context(|| format!("binding custodial listener {bind_for_ctx}"))?;
Ok(())
}
async fn custody_hand_off(&self, ident: &str, host_sock: &Path) -> Result<()> {
let cust = self.custodian.clone();
let ident = ident.to_string();
let ident_for_ctx = ident.clone();
let host_sock = host_sock.to_path_buf();
tokio::task::spawn_blocking(move || cust.hand_off(&ident, &host_sock))
.await
.context("custody hand_off task join")?
.with_context(|| format!("handing listen fd to workload {ident_for_ctx}"))?;
Ok(())
}
async fn deploy_custody(
&self,
spec: &WorkloadSpec,
mesh: &MeshAssignment,
slot: kcc::PodSlot,
) -> Result<DeployResult> {
let ident = spec.expose.mesh.identity.clone();
let bind_addr = kcc::passway_listen_addr(spec);
let netns = Self::custody_netns(spec, mesh);
self.custody_bind_and_hold(&ident.0, &bind_addr, netns)
.await
.with_context(|| format!("custody deploy of {}", ident.0))?;
let container_id = slot.container_id(&ident.0);
let pod = self.passway_pod_options(spec, slot).await?;
let result = match self
.create_and_start(
spec,
mesh,
&container_id,
&pod,
&[format!("{}=true", kcc::PASSWAY_UPGRADE_ENV)],
)
.await
{
Ok(r) => r,
Err(e) => {
self.custodian.release(&ident.0);
return Err(e).context("starting custody passway container");
}
};
let host_sock = self
.host_upgrade_sock(spec, slot)
.ok_or_else(|| anyhow!("passway workload {} declares no upgrade sock", ident.0))?;
if let Err(e) = self.custody_hand_off(&ident.0, &host_sock).await {
let _ = self.teardown_container(&container_id).await;
self.custodian.release(&ident.0);
return Err(e);
}
self.set_slot(&ident, slot);
tracing::info!(
ident = %ident.0,
bind = %bind_addr,
container_id = %result.container_id,
"custody deploy: passway adopted kamaji-held listen socket"
);
Ok(result)
}
pub fn ledger(&self) -> &RestartLedger {
&self.ledger
}
pub fn with_namespace(mut self, ns: impl Into<String>) -> Self {
self.namespace = ns.into();
self
}
pub fn with_log_base(mut self, path: impl Into<PathBuf>) -> Self {
self.log_base = path.into();
self
}
fn containers_client(&self) -> ContainersClient<tonic::transport::Channel> {
kcc::containers_client(&self.channel)
}
fn tasks_client(&self) -> TasksClient<tonic::transport::Channel> {
kcc::tasks_client(&self.channel)
}
fn version_client(&self) -> VersionClient<tonic::transport::Channel> {
kcc::version_client(&self.channel)
}
fn snapshots_client(&self) -> SnapshotsClient<tonic::transport::Channel> {
kcc::snapshots_client(&self.channel)
}
async fn prepare_rootfs(
&self,
container_id: &str,
image_target_digest: &str,
) -> Result<Vec<containerd_client::types::Mount>> {
kcc::prepare_rootfs(
&self.channel,
&self.namespace,
container_id,
image_target_digest,
)
.await
}
fn log_dir(&self, container_id: &str) -> PathBuf {
self.log_base.join(&self.namespace).join(container_id)
}
fn image_ref(spec: &WorkloadSpec) -> String {
kcc::image_ref(spec)
}
fn map_task_status(code: i32, exit_status: u32) -> WorkloadStatus {
match code {
2 => WorkloadStatus::Running,
3 if exit_status == 0 => WorkloadStatus::Stopped,
3 => WorkloadStatus::Failed {
reason: format!("exited with status {exit_status}"),
},
4 | 5 => WorkloadStatus::Stopping,
1 => WorkloadStatus::Pending,
_ => WorkloadStatus::Failed {
reason: format!("unknown task status code {code}"),
},
}
}
}
#[async_trait]
impl Kamaji for ContainerdRuntime {
fn backend(&self) -> Backend {
Backend::Containerd
}
async fn deploy_workload(
&self,
spec: &WorkloadSpec,
mesh: &MeshAssignment,
) -> Result<DeployResult> {
if spec.wants_host_network() && spec.tier.0 != "infra" {
anyhow::bail!(
"workload requests host networking (annotation {}={}) but tier is {:?}; \
host networking is only permitted for tier=\"infra\"",
workload_spec::HOST_NETWORK_ANNOTATION,
workload_spec::HOST_NETWORK_VALUE,
spec.tier.0,
);
}
if spec.wants_nested_sandbox() && spec.tier.0 != "infra" {
anyhow::bail!(
"workload requests the nested-sandbox grant (annotation {}={}) but tier is {:?}; \
it is only permitted for tier=\"infra\"",
workload_spec::NESTED_SANDBOX_ANNOTATION,
workload_spec::NESTED_SANDBOX_VALUE,
spec.tier.0,
);
}
let _ = self.teardown_workload(&spec.expose.mesh.identity).await;
if kcc::upgrade_sock_dir(spec).is_some() {
return self.deploy_custody(spec, mesh, kcc::PodSlot::A).await;
}
let container_id = kcc::PodSlot::A.container_id(&spec.expose.mesh.identity.0);
let result = self
.create_and_start(spec, mesh, &container_id, &kcc::PodOptions::default(), &[])
.await?;
tracing::info!(
container_id = %result.container_id,
mesh_ip = %mesh.mesh_ip,
task_pid = result.task_pid,
"workload deployed"
);
Ok(result)
}
async fn list_workloads(&self) -> Result<Vec<WorkloadState>> {
let mut ctrs = self.containers_client();
let mut tasks = self.tasks_client();
let req = ListContainersRequest {
filters: vec![format!("labels.\"yah.ident\"!=\"\"")],
};
let req = with_namespace!(req, self.namespace);
let containers = ctrs
.list(req)
.await
.context("listing containerd containers")?
.into_inner()
.containers;
let mut states = Vec::with_capacity(containers.len());
for c in containers {
let ident_str = c
.labels
.get("yah.ident")
.cloned()
.unwrap_or_else(|| c.id.clone());
let mesh_ip = c.labels.get("yah.mesh_ip").and_then(|s| s.parse().ok());
let base = match get_task_status(&mut tasks, &self.namespace, &c.id).await {
Ok(Some((code, _pid, exit_status))) => Self::map_task_status(code, exit_status),
Ok(None) => WorkloadStatus::Stopped,
Err(_) => WorkloadStatus::Stopped,
};
let status = apply_ledger(base, self.ledger.get(&c.id));
states.push(WorkloadState {
ident: MeshIdent(ident_str),
container_id: c.id,
status,
mesh_ip,
});
}
Ok(states)
}
async fn get_workload(&self, ident: &MeshIdent) -> Result<Option<WorkloadState>> {
let container_id = self.live_container_id(ident);
let mut ctrs = self.containers_client();
let req = GetContainerRequest {
id: container_id.to_string(),
};
let req = with_namespace!(req, self.namespace);
let container = match ctrs.get(req).await {
Ok(resp) => resp.into_inner().container,
Err(status) if status.code() == tonic::Code::NotFound => return Ok(None),
Err(e) => return Err(anyhow!(e).context(format!("get container {container_id}"))),
};
let c = match container {
Some(c) => c,
None => return Ok(None),
};
let mesh_ip = c.labels.get("yah.mesh_ip").and_then(|s| s.parse().ok());
let mut tasks = self.tasks_client();
let base = match get_task_status(&mut tasks, &self.namespace, &container_id).await {
Ok(Some((code, _pid, exit_status))) => Self::map_task_status(code, exit_status),
Ok(None) => WorkloadStatus::Stopped,
Err(_) => WorkloadStatus::Stopped,
};
let status = apply_ledger(base, self.ledger.get(&container_id));
Ok(Some(WorkloadState {
ident: ident.clone(),
container_id: c.id,
status,
mesh_ip,
}))
}
async fn stream_logs(&self, ident: &MeshIdent, opts: LogOpts) -> Result<LogStream> {
let container_id = self.live_container_id(ident);
let log_dir = self.log_dir(&container_id);
let ident_clone = ident.clone();
let stdout_path = log_dir.join("stdout.log");
let stderr_path = log_dir.join("stderr.log");
let include_stdout = opts
.stream
.map(|s| s == LogStreamKind::Stdout)
.unwrap_or(true);
let include_stderr = opts
.stream
.map(|s| s == LogStreamKind::Stderr)
.unwrap_or(true);
let follow = opts.follow;
let stdout_stream: Option<LogStream> = if include_stdout && stdout_path.exists() {
let file = tokio::fs::File::open(&stdout_path)
.await
.with_context(|| format!("opening {}", stdout_path.display()))?;
let reader = BufReader::new(file);
let ident = ident_clone.clone();
let lines = LinesStream::new(reader.lines());
let stream = TokioStreamExt::filter_map(lines, move |line| {
line.ok()
.map(|msg| LogEvent::plain(ident.clone(), LogStreamKind::Stdout, msg))
});
Some(Box::pin(stream))
} else {
None
};
let stderr_stream: Option<LogStream> = if include_stderr && stderr_path.exists() {
let file = tokio::fs::File::open(&stderr_path)
.await
.with_context(|| format!("opening {}", stderr_path.display()))?;
let reader = BufReader::new(file);
let ident = ident_clone.clone();
let lines = LinesStream::new(reader.lines());
let stream = TokioStreamExt::filter_map(lines, move |line| {
line.ok()
.map(|msg| LogEvent::plain(ident.clone(), LogStreamKind::Stderr, msg))
});
Some(Box::pin(stream))
} else {
None
};
let merged: LogStream = match (stdout_stream, stderr_stream) {
(Some(a), Some(b)) => Box::pin(tokio_stream::StreamExt::merge(a, b)),
(Some(a), None) => a,
(None, Some(b)) => b,
(None, None) => Box::pin(tokio_stream::empty()),
};
let _ = follow;
Ok(merged)
}
async fn graceful_upgrade_workload(
&self,
spec: &WorkloadSpec,
mesh: &MeshAssignment,
) -> Result<DeployResult> {
let ident = spec.expose.mesh.identity.clone();
if kcc::upgrade_sock_dir(spec).is_none() {
tracing::warn!(
ident = %ident.0,
"graceful_upgrade_workload: spec declares no PASSWAY_UPGRADE_SOCK; \
falling back to a connection-dropping redeploy"
);
return self.deploy_workload(spec, mesh).await;
}
let current_slot = self
.slots
.lock()
.unwrap()
.get(&ident.0)
.copied()
.unwrap_or_default();
let current_id = current_slot.container_id(&ident.0);
let running = matches!(
self.container_status(¤t_id).await,
Some(WorkloadStatus::Running | WorkloadStatus::Restarting { .. })
);
if !self.custodian.holds(&ident.0) || !running {
tracing::info!(
ident = %ident.0,
holds = self.custodian.holds(&ident.0),
running,
"graceful_upgrade_workload: no live custody instance; deploying fresh"
);
return self.deploy_workload(spec, mesh).await;
}
let incoming_slot = current_slot.other();
let incoming_id = incoming_slot.container_id(&ident.0);
let pod = self.passway_pod_options(spec, incoming_slot).await?;
self.teardown_container(&incoming_id).await?;
let result = self
.create_and_start(
spec,
mesh,
&incoming_id,
&pod,
&[format!("{}=true", kcc::PASSWAY_UPGRADE_ENV)],
)
.await
.context("starting graceful-upgrade replacement container")?;
let host_sock = self
.host_upgrade_sock(spec, incoming_slot)
.ok_or_else(|| anyhow!("passway workload {} declares no upgrade sock", ident.0))?;
if let Err(e) = self.custody_hand_off(&ident.0, &host_sock).await {
let _ = self.teardown_container(&incoming_id).await;
return Err(e).with_context(|| {
format!(
"workload {}: handing listen fd to replacement failed; \
outgoing left serving on the kamaji-held socket",
ident.0
)
});
}
tokio::time::sleep(UPGRADE_SETTLE).await;
match self.container_status(&incoming_id).await {
Some(WorkloadStatus::Running) => {}
other => {
let _ = self.teardown_container(&incoming_id).await;
anyhow::bail!(
"workload {}: graceful-upgrade replacement is not running after settle \
({other:?}); outgoing instance left serving",
ident.0
);
}
}
self.sigquit_task(¤t_id)
.await
.with_context(|| format!("signalling outgoing container {current_id}"))?;
let grace = Duration::from_millis(spec.stop_policy.grace_period.0);
tokio::time::sleep(grace).await;
let _ = self.teardown_container(¤t_id).await;
self.set_slot(&ident, incoming_slot);
tracing::info!(
ident = %ident.0,
from = %current_id,
to = %incoming_id,
"graceful cert-reload upgrade complete (zero dropped connections)"
);
Ok(result)
}
async fn restart_workload(&self, ident: &MeshIdent) -> Result<()> {
let container_id = self.live_container_id(ident);
let mut tasks = self.tasks_client();
let req = KillRequest {
container_id: container_id.to_string(),
exec_id: String::new(),
signal: 15, all: false,
};
let req = with_namespace!(req, self.namespace);
tasks
.kill(req)
.await
.with_context(|| format!("SIGTERM {container_id}"))?;
tokio::time::sleep(Duration::from_secs(5)).await;
let req = StartRequest {
container_id: container_id.to_string(),
exec_id: String::new(),
};
let req = with_namespace!(req, self.namespace);
tasks
.start(req)
.await
.with_context(|| format!("restarting task for {container_id}"))?;
tracing::info!(container_id = %container_id, "workload restarted");
Ok(())
}
async fn teardown_workload(&self, ident: &MeshIdent) -> Result<()> {
for slot in [kcc::PodSlot::A, kcc::PodSlot::B] {
let container_id = slot.container_id(&ident.0);
self.teardown_container(&container_id).await?;
}
self.clear_slot(ident);
self.custodian.release(&ident.0);
Ok(())
}
async fn health(&self) -> Result<RuntimeHealth> {
let mut ver = self.version_client();
let req = tonic::Request::new(());
match ver.version(req).await {
Ok(resp) => {
let v = resp.into_inner();
Ok(RuntimeHealth {
ok: true,
version: Some(v.version),
detail: None,
})
}
Err(e) => Ok(RuntimeHealth {
ok: false,
version: None,
detail: Some(e.to_string()),
}),
}
}
}
async fn get_task_status(
tasks: &mut TasksClient<tonic::transport::Channel>,
namespace: &str,
container_id: &str,
) -> Result<Option<(i32, u32, u32)>> {
Ok(
match kcc::get_task_status(tasks, namespace, container_id).await? {
kcc::TaskProbe::Status {
code,
pid,
exit_status,
} => Some((code, pid, exit_status)),
kcc::TaskProbe::MissingProcess => Some((0, 0, 0)),
kcc::TaskProbe::NoTask => None,
},
)
}
#[cfg(test)]
mod tests {
use super::*;
use workload_spec::{
ExposeSpec, ImageRef, MeshExpose, Millis, NamespaceId, ResourceLimits, RestartPolicy,
SchemaVersion, StopPolicy, TenantId, TierTag, WorkloadSpec,
};
async fn containerd_available() -> bool {
ContainerdRuntime::connect().await.is_ok()
}
fn test_spec(name: &str) -> WorkloadSpec {
WorkloadSpec {
schema_version: SchemaVersion::V1,
name: name.to_string(),
image: ImageRef {
registry: "docker.io".to_string(),
repository: "library/alpine".to_string(),
tag: "latest".to_string(),
digest: workload_spec::testing::test_digest(),
},
tier: TierTag("infra".to_string()),
tenant: TenantId::singleton(),
namespace: NamespaceId::singleton(),
replicas: 1,
command: Some(vec!["sleep".to_string(), "30".to_string()]),
entrypoint: None,
workdir: None,
user: None,
env: vec![],
secrets: vec![],
volumes: vec![],
resources: ResourceLimits {
memory_mb: 64,
cpu_millis: 128,
ephemeral_storage_mb: 128,
},
depends_on: vec![],
healthcheck: None,
restart_policy: RestartPolicy::Never,
archetype: None,
stop_policy: StopPolicy {
signal: 15,
grace_period: Millis::from_secs(5),
},
expose: ExposeSpec {
mesh: MeshExpose {
identity: MeshIdent(name.to_string()),
ports: vec![],
allow_from: vec![],
},
public: None,
operator: None,
},
labels: Default::default(),
annotations: Default::default(),
}
}
fn netns_present(oci: &serde_json::Value) -> bool {
oci["linux"]["namespaces"]
.as_array()
.unwrap()
.iter()
.any(|n| n["type"] == "network")
}
#[test]
fn oci_spec_injects_mesh_ip_after_literal_env() {
let mesh = MeshAssignment::stub("10.64.0.9".parse().unwrap());
let deploy_env = vec![format!("YAH_MESH_IP={}", mesh.mesh_ip)];
let oci = kcc::build_oci_spec(&test_spec("svc"), &deploy_env, None);
assert!(
netns_present(&oci),
"default workload must get an isolated netns"
);
let env = oci["process"]["env"].as_array().unwrap();
assert_eq!(
env.last().unwrap().as_str().unwrap(),
"YAH_MESH_IP=10.64.0.9",
"mesh ip must be appended after the spec's literal env vars"
);
}
#[test]
fn ledger_record_exit_increments_count_and_arms_in_flight() {
let ledger = RestartLedger::new();
ledger.record_exit("c1", 137);
let r = ledger.get("c1").unwrap();
assert_eq!(r.restart_count, 1);
assert_eq!(r.last_exit_code, 137);
assert!(r.in_flight);
ledger.record_exit("c1", 2);
let r = ledger.get("c1").unwrap();
assert_eq!(r.restart_count, 2);
assert_eq!(r.last_exit_code, 2);
assert!(r.in_flight);
}
#[test]
fn ledger_mark_running_clears_in_flight_preserves_count() {
let ledger = RestartLedger::new();
ledger.record_exit("c1", 1);
ledger.mark_running("c1");
let r = ledger.get("c1").unwrap();
assert_eq!(r.restart_count, 1);
assert!(!r.in_flight);
}
#[test]
fn apply_ledger_upgrades_stopped_to_restarting_when_in_flight() {
let ledger = RestartLedger::new();
ledger.record_exit("c1", 137);
let status = apply_ledger(WorkloadStatus::Stopped, ledger.get("c1"));
match status {
WorkloadStatus::Restarting {
last_exit_code,
restart_count,
last_finished_at_unix_ms,
} => {
assert_eq!(last_exit_code, 137);
assert_eq!(restart_count, 1);
assert!(last_finished_at_unix_ms > 0);
}
other => panic!("expected Restarting, got {other:?}"),
}
}
#[test]
fn apply_ledger_passthrough_when_no_record_or_not_in_flight() {
assert_eq!(
apply_ledger(WorkloadStatus::Stopped, None),
WorkloadStatus::Stopped
);
let ledger = RestartLedger::new();
ledger.record_exit("c1", 1);
ledger.mark_running("c1");
assert_eq!(
apply_ledger(WorkloadStatus::Stopped, ledger.get("c1")),
WorkloadStatus::Stopped
);
ledger.record_exit("c1", 1);
assert_eq!(
apply_ledger(WorkloadStatus::Running, ledger.get("c1")),
WorkloadStatus::Running
);
}
#[test]
fn apply_ledger_upgrades_failed_to_restarting() {
let ledger = RestartLedger::new();
ledger.record_exit("c1", 1);
let status = apply_ledger(
WorkloadStatus::Failed {
reason: "exit 1".into(),
},
ledger.get("c1"),
);
assert!(matches!(status, WorkloadStatus::Restarting { .. }));
}
#[test]
fn restarting_serde_round_trips_through_json() {
let original = WorkloadStatus::Restarting {
last_exit_code: 2,
restart_count: 5,
last_finished_at_unix_ms: 1_700_000_000_000,
};
let json = serde_json::to_string(&original).unwrap();
assert!(json.contains("\"type\":\"restarting\""));
let parsed: WorkloadStatus = serde_json::from_str(&json).unwrap();
assert_eq!(parsed, original);
assert!(!original.is_terminal(), "Restarting must not be terminal");
}
#[tokio::test]
async fn runtime_health_returns_ok_or_degraded() {
if !containerd_available().await {
eprintln!("SKIP: containerd not reachable (run with --features containerd-integration on a host with containerd)");
return;
}
let rt = ContainerdRuntime::connect().await.unwrap();
let h = rt.health().await.unwrap();
assert!(h.ok, "expected healthy containerd: {:?}", h.detail);
assert!(h.version.is_some(), "expected version string");
}
#[tokio::test]
async fn deploy_get_teardown() {
if !containerd_available().await {
eprintln!("SKIP: containerd not reachable");
return;
}
let rt = ContainerdRuntime::connect()
.await
.unwrap()
.with_namespace("yah-test");
let spec = test_spec("test-deploy-get-teardown");
let mesh = MeshAssignment::stub("10.64.0.1".parse().unwrap());
let result = rt.deploy_workload(&spec, &mesh).await.unwrap();
assert_eq!(result.container_id, "test-deploy-get-teardown");
assert!(result.task_pid > 0);
let state = rt
.get_workload(&spec.expose.mesh.identity)
.await
.unwrap()
.expect("workload should exist after deploy");
assert_eq!(state.status, WorkloadStatus::Running);
rt.teardown_workload(&spec.expose.mesh.identity)
.await
.unwrap();
let after = rt.get_workload(&spec.expose.mesh.identity).await.unwrap();
assert!(after.is_none(), "workload should be absent after teardown");
}
#[tokio::test]
async fn list_workloads_empty_when_no_containers() {
if !containerd_available().await {
eprintln!("SKIP: containerd not reachable");
return;
}
let rt = ContainerdRuntime::connect()
.await
.unwrap()
.with_namespace("yah-test-list-empty");
let list = rt.list_workloads().await.unwrap();
assert!(
list.is_empty(),
"expected empty namespace, found {} containers",
list.len()
);
}
#[tokio::test]
async fn stream_logs_returns_output() {
if !containerd_available().await {
eprintln!("SKIP: containerd not reachable");
return;
}
let tmp = tempfile::TempDir::new().unwrap();
let rt = ContainerdRuntime::connect()
.await
.unwrap()
.with_namespace("yah-test-logs")
.with_log_base(tmp.path());
let spec = test_spec("test-log-stream");
let mesh = MeshAssignment::stub("10.64.0.2".parse().unwrap());
rt.deploy_workload(&spec, &mesh).await.unwrap();
tokio::time::sleep(Duration::from_secs(2)).await;
let opts = LogOpts {
tail: Some(100),
follow: false,
stream: Some(LogStreamKind::Stdout),
};
let mut log_stream = rt
.stream_logs(&spec.expose.mesh.identity, opts)
.await
.unwrap();
use tokio_stream::StreamExt as _;
let mut events = vec![];
while let Some(ev) = log_stream.next().await {
events.push(ev);
}
rt.teardown_workload(&spec.expose.mesh.identity)
.await
.unwrap();
println!("log events: {}", events.len());
}
}