use super::{
extract_container_id, proxy_to_role, proxy_upgrade_to_role, require_amd64_runtime,
resolve_container_role,
};
use crate::api::AppState;
use crate::error::{DockerError, Result};
use crate::proxy::{parse_port_bindings, proxy_to_guest_for_role};
use crate::routing::{UtilityVmRole, query_param, route_container_create};
use axum::body::Body;
use axum::extract::{OriginalUri, State};
use axum::http::{HeaderMap, Method, Request, Uri};
use axum::response::Response;
use bytes::Bytes;
use std::net::IpAddr;
crate::handlers::proxy_handler!(list_containers);
crate::handlers::proxy_handler!(prune_containers);
crate::handlers::container_proxy_handler!(inspect_container);
crate::handlers::container_proxy_handler!(container_logs);
crate::handlers::container_proxy_handler!(wait_container);
crate::handlers::container_proxy_handler!(pause_container);
crate::handlers::container_proxy_handler!(unpause_container);
crate::handlers::container_proxy_handler!(container_top);
crate::handlers::container_proxy_handler!(container_stats);
crate::handlers::container_proxy_handler!(container_changes);
pub async fn create_container(
State(state): State<AppState>,
OriginalUri(uri): OriginalUri,
req: Request<Body>,
) -> Result<Response> {
let (parts, body) = req.into_parts();
let body_bytes = http_body_util::BodyExt::collect(body)
.await
.map_err(|e| DockerError::Server(format!("failed to read body: {e}")))?
.to_bytes();
let body_bytes = crate::host_path::rewrite_create_body(body_bytes);
let route = route_container_create(&uri, &body_bytes);
let requested_name = query_param(&uri, "name").map(str::to_string);
require_amd64_runtime(&state, route).await?;
let role = route.utility_vm();
tracing::debug!(
backend = "hv",
translator = route.translator.as_str(),
platform = ?route.platform,
name = requested_name.as_deref().unwrap_or(""),
"routing Docker container create request"
);
let mut req = Request::from_parts(parts, Body::from(body_bytes));
req.headers_mut().remove(axum::http::header::CONTENT_LENGTH);
let response = proxy_to_role(&state, role, &uri, req).await?;
if !response.status().is_success() {
return Ok(response);
}
let (parts, body) = response.into_parts();
let body_bytes = http_body_util::BodyExt::collect(body)
.await
.map_err(|e| DockerError::Server(format!("failed to read create response: {e}")))?
.to_bytes();
if let Some(id) = parse_create_response_id(&body_bytes) {
tracing::debug!(
translator = route.translator.as_str(),
container_id = %id,
name = requested_name.as_deref().unwrap_or(""),
"recorded container binding",
);
state.workload_roles.record(id.clone(), role).await;
if let Some(name) = requested_name {
state.workload_roles.add_alias(&id, name).await;
}
} else {
tracing::warn!("create response missing container ID; lifecycle ops fall back to HV");
}
Ok(Response::from_parts(parts, Body::from(body_bytes)))
}
pub async fn start_container(
State(state): State<AppState>,
OriginalUri(uri): OriginalUri,
req: Request<Body>,
) -> Result<Response> {
let container_id = extract_container_id(&uri);
let role = resolve_container_role(&state, &uri).await?;
let response = proxy_to_role(&state, role, &uri, req).await?;
if response.status().is_success() {
if let Some(ref id) = container_id {
setup_container_networking(&state, role, id).await;
}
}
Ok(response)
}
pub async fn stop_container(
State(state): State<AppState>,
OriginalUri(uri): OriginalUri,
req: Request<Body>,
) -> Result<Response> {
let role = resolve_container_role(&state, &uri).await?;
let canonical = resolve_or_raw_for_teardown(&state, role, &uri).await;
let response = proxy_to_role(&state, role, &uri, req).await?;
let status = response.status().as_u16();
if status == 204 || status == 304 {
if let Some(canonical) = canonical {
state.runtime.stop_port_forwarding_by_id(&canonical).await;
state.runtime.deregister_dns_by_id(&canonical).await;
}
}
Ok(response)
}
pub async fn kill_container(
State(state): State<AppState>,
OriginalUri(uri): OriginalUri,
req: Request<Body>,
) -> Result<Response> {
let role = resolve_container_role(&state, &uri).await?;
let canonical = resolve_or_raw_for_teardown(&state, role, &uri).await;
let response = proxy_to_role(&state, role, &uri, req).await?;
if response.status().as_u16() == 204 {
if let Some(canonical) = canonical {
state.runtime.stop_port_forwarding_by_id(&canonical).await;
state.runtime.deregister_dns_by_id(&canonical).await;
}
}
Ok(response)
}
pub async fn restart_container(
State(state): State<AppState>,
OriginalUri(uri): OriginalUri,
req: Request<Body>,
) -> Result<Response> {
let role = resolve_container_role(&state, &uri).await?;
let response = proxy_to_role(&state, role, &uri, req).await?;
if response.status().as_u16() == 204 {
if let Some(id) = extract_container_id(&uri) {
let _ = state.runtime.ensure_vm_ready().await;
if let Some(body_bytes) = inspect_container_body(&state, role, &id).await {
let canonical = canonical_id_or_fallback(&id, &body_bytes);
if let Some((aliases, ip)) = extract_container_dns_info(&body_bytes) {
state.runtime.register_dns(&canonical, &aliases, ip).await;
}
}
}
}
Ok(response)
}
pub async fn remove_container(
State(state): State<AppState>,
OriginalUri(uri): OriginalUri,
req: Request<Body>,
) -> Result<Response> {
let role = resolve_container_role(&state, &uri).await?;
let canonical = resolve_or_raw_for_teardown(&state, role, &uri).await;
let response = proxy_to_role(&state, role, &uri, req).await?;
if response.status().is_success() {
if let Some(canonical) = canonical {
state.runtime.stop_port_forwarding_by_id(&canonical).await;
state.runtime.deregister_dns_by_id(&canonical).await;
state.workload_roles.forget(&canonical).await;
}
}
Ok(response)
}
async fn setup_container_networking(state: &AppState, role: UtilityVmRole, container_id: &str) {
let Some(body_bytes) = inspect_container_body(state, role, container_id).await else {
tracing::warn!(
container_id,
"Failed to inspect container for networking setup; \
port forwarding and DNS will not be configured"
);
return;
};
let canonical_id = canonical_id_or_fallback(container_id, &body_bytes);
setup_port_forwarding_from_inspect(state, role, &canonical_id, &body_bytes).await;
if let Some((aliases, ip)) = extract_container_dns_info(&body_bytes) {
state
.runtime
.register_dns(&canonical_id, &aliases, ip)
.await;
}
}
async fn inspect_container_body(
state: &AppState,
role: UtilityVmRole,
container_id: &str,
) -> Option<Bytes> {
let inspect_path = format!("/containers/{container_id}/json");
let inspect_resp = match proxy_to_guest_for_role(
state.connector.as_ref(),
role,
Method::GET,
&inspect_path,
&HeaderMap::new(),
Bytes::new(),
)
.await
{
Ok(resp) if resp.status().is_success() => resp,
Ok(resp) => {
tracing::debug!(
"Inspect container {} returned status {}",
container_id,
resp.status()
);
return None;
}
Err(e) => {
tracing::debug!("Failed to inspect container {}: {}", container_id, e);
return None;
}
};
match http_body_util::BodyExt::collect(inspect_resp.into_body()).await {
Ok(collected) => Some(collected.to_bytes()),
Err(e) => {
tracing::debug!("Failed to read inspect body for {}: {}", container_id, e);
None
}
}
}
async fn setup_port_forwarding_from_inspect(
state: &AppState,
role: UtilityVmRole,
canonical_id: &str,
body_bytes: &[u8],
) {
let bindings = parse_port_bindings(body_bytes);
if bindings.is_empty() {
tracing::debug!("No port bindings found for container {}", canonical_id);
return;
}
tracing::info!(
"Port forwarding: {} bindings for container {}",
bindings.len(),
canonical_id,
);
for b in &bindings {
tracing::info!(
" bind {}:{} → container:{}/{}",
b.host_ip,
b.host_port,
b.container_port,
b.protocol,
);
}
let rules: Vec<_> = bindings
.iter()
.map(|b| {
(
b.host_ip.clone(),
b.host_port,
b.container_port,
b.protocol.clone(),
)
})
.collect();
let machine_name = state.runtime.machine_name_for_role(role);
if let Err(e) = state
.runtime
.start_port_forwarding_for(machine_name, canonical_id, &rules)
.await
{
tracing::warn!(
utility_vm = role.as_str(),
"Failed to start port forwarding for {}: {}",
canonical_id,
e,
);
}
}
pub fn extract_container_dns_info(inspect_json: &[u8]) -> Option<(Vec<String>, IpAddr)> {
let v: serde_json::Value = serde_json::from_slice(inspect_json).ok()?;
let name = v.get("Name")?.as_str()?.trim_start_matches('/').to_string();
if name.is_empty() {
return None;
}
let ip_str = v
.pointer("/NetworkSettings/IPAddress")
.and_then(|v| v.as_str())
.filter(|s| !s.is_empty())
.or_else(|| {
v.pointer("/NetworkSettings/Networks")?
.as_object()?
.values()
.find_map(|net| net.get("IPAddress")?.as_str().filter(|s| !s.is_empty()))
})?;
let aliases = match v.pointer("/Config/Labels").and_then(|l| l.as_object()) {
Some(labels) => {
let project = labels
.get("com.docker.compose.project")
.and_then(|v| v.as_str())
.filter(|s| !s.is_empty());
let service = labels
.get("com.docker.compose.service")
.and_then(|v| v.as_str())
.filter(|s| !s.is_empty());
match (project, service) {
(Some(proj), Some(svc)) => {
vec![format!("{svc}.{proj}"), name]
}
_ => vec![name],
}
}
None => vec![name],
};
Some((aliases, ip_str.parse().ok()?))
}
pub async fn rename_container(
State(state): State<AppState>,
OriginalUri(uri): OriginalUri,
req: Request<Body>,
) -> Result<Response> {
let role = resolve_container_role(&state, &uri).await?;
let canonical = resolve_canonical_from_uri(&state, role, &uri).await;
let new_name = query_param(&uri, "name").map(str::to_string);
let response = proxy_to_role(&state, role, &uri, req).await?;
if response.status().is_success() {
if let Some(ref canonical) = canonical {
state.runtime.deregister_dns_by_id(canonical).await;
if let Some(ref new_name) = new_name {
state
.workload_roles
.rename_alias(canonical, new_name.clone())
.await;
}
if let Some(body_bytes) = inspect_container_body(&state, role, canonical).await
&& let Some((aliases, ip)) = extract_container_dns_info(&body_bytes)
{
state.runtime.register_dns(canonical, &aliases, ip).await;
}
}
}
Ok(response)
}
pub async fn attach_container(
State(state): State<AppState>,
OriginalUri(uri): OriginalUri,
req: Request<Body>,
) -> Result<Response> {
let role = resolve_container_role(&state, &uri).await?;
proxy_upgrade_to_role(&state, role, &uri, req).await
}
fn extract_canonical_id_from_inspect(inspect_json: &[u8]) -> Option<String> {
let value: serde_json::Value = serde_json::from_slice(inspect_json).ok()?;
value.get("Id")?.as_str().map(String::from)
}
fn canonical_id_or_fallback(container_id: &str, inspect_json: &[u8]) -> String {
extract_canonical_id_from_inspect(inspect_json).unwrap_or_else(|| container_id.to_string())
}
async fn resolve_canonical_from_uri(
state: &AppState,
role: UtilityVmRole,
uri: &Uri,
) -> Option<String> {
let id = extract_container_id(uri)?;
let _ = state.runtime.ensure_vm_ready().await;
match resolve_canonical_id(state, role, &id).await {
Some(canonical) => Some(canonical),
None => {
tracing::warn!(
container_id = %id,
utility_vm = role.as_str(),
"Failed to resolve canonical container ID"
);
None
}
}
}
async fn resolve_or_raw_for_teardown(
state: &AppState,
role: UtilityVmRole,
uri: &Uri,
) -> Option<String> {
if let Some(canonical) = resolve_canonical_from_uri(state, role, uri).await {
return Some(canonical);
}
let raw = extract_container_id(uri)?;
tracing::warn!(
container_id = %raw,
utility_vm = role.as_str(),
"Using raw URI-extracted ID for networking teardown"
);
Some(raw)
}
async fn resolve_canonical_id(state: &AppState, role: UtilityVmRole, id: &str) -> Option<String> {
let inspect_path = format!("/containers/{id}/json");
let resp = proxy_to_guest_for_role(
state.connector.as_ref(),
role,
Method::GET,
&inspect_path,
&HeaderMap::new(),
Bytes::new(),
)
.await
.ok()?;
if !resp.status().is_success() {
return None;
}
let body_bytes = http_body_util::BodyExt::collect(resp.into_body())
.await
.ok()?
.to_bytes();
extract_canonical_id_from_inspect(&body_bytes)
}
fn parse_create_response_id(body: &[u8]) -> Option<String> {
let value: serde_json::Value = serde_json::from_slice(body).ok()?;
value.get("Id")?.as_str().map(String::from)
}
#[cfg(test)]
mod tests {
use super::*;
fn uri(s: &str) -> Uri {
s.parse().unwrap()
}
#[test]
fn extract_id_from_start_path() {
let u = uri("/containers/abc123/start");
assert_eq!(extract_container_id(&u).as_deref(), Some("abc123"));
}
#[test]
fn extract_id_from_versioned_path() {
let u = uri("/v1.43/containers/def456/stop");
assert_eq!(extract_container_id(&u).as_deref(), Some("def456"));
}
#[test]
fn extract_id_from_delete_path() {
let u = uri("/containers/xyz789");
assert_eq!(extract_container_id(&u).as_deref(), Some("xyz789"));
}
#[test]
fn extract_id_skips_collection_endpoints() {
assert_eq!(extract_container_id(&uri("/containers/json")), None);
assert_eq!(extract_container_id(&uri("/containers/create")), None);
assert_eq!(extract_container_id(&uri("/containers/prune")), None);
}
#[test]
fn extract_id_no_containers_segment() {
assert_eq!(extract_container_id(&uri("/images/abc/json")), None);
}
#[test]
fn extract_canonical_id_from_inspect_json() {
let json = br#"{"Id":"abc123def456789","Name":"/my-nginx","State":{}}"#;
assert_eq!(
extract_canonical_id_from_inspect(json).as_deref(),
Some("abc123def456789")
);
}
#[test]
fn extract_canonical_id_missing_field() {
let json = br#"{"Name":"/my-nginx"}"#;
assert_eq!(extract_canonical_id_from_inspect(json), None);
}
#[test]
fn extract_canonical_id_invalid_json() {
assert_eq!(extract_canonical_id_from_inspect(b"not json"), None);
}
#[test]
fn parses_id_from_create_response() {
let json = br#"{"Id":"abc123def456","Warnings":null}"#;
assert_eq!(
parse_create_response_id(json).as_deref(),
Some("abc123def456"),
);
}
#[test]
fn returns_none_when_create_response_missing_id() {
assert_eq!(parse_create_response_id(b"{}"), None);
assert_eq!(parse_create_response_id(b"not json"), None);
}
#[test]
fn canonical_id_or_fallback_uses_canonical() {
let json = br#"{"Id":"canonical-abcdef","Name":"/my-nginx"}"#;
assert_eq!(
canonical_id_or_fallback("web", json),
"canonical-abcdef".to_string()
);
}
#[test]
fn canonical_id_or_fallback_falls_back_when_missing() {
let json = br#"{"Name":"/my-nginx"}"#;
assert_eq!(canonical_id_or_fallback("web", json), "web".to_string());
}
#[test]
fn canonical_id_or_fallback_falls_back_on_invalid_json() {
assert_eq!(
canonical_id_or_fallback("web", b"not json"),
"web".to_string()
);
}
#[test]
fn test_extract_container_dns_info_plain() {
let json = serde_json::json!({
"Id": "abc123",
"Name": "/my-nginx",
"NetworkSettings": {
"IPAddress": "172.17.0.2",
"Networks": {}
}
});
let bytes = serde_json::to_vec(&json).unwrap();
let (aliases, ip) = extract_container_dns_info(&bytes).unwrap();
assert_eq!(aliases, vec!["my-nginx"]);
assert_eq!(ip, "172.17.0.2".parse::<IpAddr>().unwrap());
}
#[test]
fn test_extract_container_dns_info_compose() {
let json = serde_json::json!({
"Id": "abc123",
"Name": "/myproject-web-1",
"Config": {
"Labels": {
"com.docker.compose.project": "myproject",
"com.docker.compose.service": "web"
}
},
"NetworkSettings": {
"IPAddress": "172.17.0.2",
"Networks": {}
}
});
let bytes = serde_json::to_vec(&json).unwrap();
let (aliases, ip) = extract_container_dns_info(&bytes).unwrap();
assert_eq!(aliases, vec!["web.myproject", "myproject-web-1"]);
assert_eq!(ip, "172.17.0.2".parse::<IpAddr>().unwrap());
}
#[test]
fn test_extract_container_dns_info_network_fallback() {
let json = serde_json::json!({
"Id": "abc123",
"Name": "/web-app",
"NetworkSettings": {
"IPAddress": "",
"Networks": {
"bridge": {
"IPAddress": "172.18.0.3"
}
}
}
});
let bytes = serde_json::to_vec(&json).unwrap();
let (aliases, ip) = extract_container_dns_info(&bytes).unwrap();
assert_eq!(aliases, vec!["web-app"]);
assert_eq!(ip, "172.18.0.3".parse::<IpAddr>().unwrap());
}
#[test]
fn test_extract_container_dns_info_no_ip() {
let json = serde_json::json!({
"Id": "abc123",
"Name": "/isolated",
"NetworkSettings": {
"IPAddress": "",
"Networks": {}
}
});
let bytes = serde_json::to_vec(&json).unwrap();
assert!(extract_container_dns_info(&bytes).is_none());
}
#[test]
fn test_extract_container_dns_info_invalid_json() {
assert!(extract_container_dns_info(b"not json").is_none());
}
#[test]
fn test_extract_container_dns_info_no_name() {
let json = serde_json::json!({
"Id": "abc123",
"NetworkSettings": {
"IPAddress": "172.17.0.2"
}
});
let bytes = serde_json::to_vec(&json).unwrap();
assert!(extract_container_dns_info(&bytes).is_none());
}
}