Skip to content

Commit ebae510

Browse files
feat: VM-level container logs, named volume tmpfs, Docker health semantics
Three fixes that get both postgres services in gpu-cli-two passing health checks: 1. **VM-level container logs**: Bind-mount `/run/vz-oci/logs/{cid}/` into containers at `/var/log/vz-oci/` so stdout/stderr logs survive container death. Added `exec_host()` on Runtime for VM-level command execution without nsenter. `logs()` now reads from VM level, working even when the container's init process has exited. 2. **Named volumes use tmpfs**: VirtioFS doesn't support chown/chmod from the guest, which breaks containers like postgres that change data dir ownership. Named volumes now use tmpfs inside the VM (matching Docker Desktop behavior) instead of VirtioFS host-backed storage. 3. **Docker health check semantics**: When retries are exhausted, container stays Running (unhealthy) instead of being killed and recreated. Health checks continue indefinitely; a future pass promotes to healthy. Also includes exec_with_output on ContainerRuntime trait, OCI service implementation in guest agent, and shared VM volume mount support. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent d701bdb commit ebae510

15 files changed

Lines changed: 493 additions & 133 deletions

File tree

crates/vz-agent-proto/src/lib.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -246,10 +246,12 @@ mod tests {
246246
NetworkServiceConfig {
247247
name: "web".to_string(),
248248
addr: "172.20.0.2/24".to_string(),
249+
network_name: "default".to_string(),
249250
},
250251
NetworkServiceConfig {
251252
name: "db".to_string(),
252253
addr: "172.20.0.3/24".to_string(),
254+
network_name: "default".to_string(),
253255
},
254256
],
255257
};

crates/vz-cli/src/commands/oci.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -659,6 +659,7 @@ async fn create_container(runtime: &vz_oci_macos::Runtime, args: CreateArgs) ->
659659
domainname: None,
660660
stop_signal: None,
661661
stop_grace_period_secs: None,
662+
mount_tag_offset: 0,
662663
};
663664

664665
info!(image = %args.image, "creating long-lived container");
@@ -886,6 +887,7 @@ fn build_run_config(args: &RunArgs) -> anyhow::Result<vz_oci_macos::RunConfig> {
886887
domainname: None,
887888
stop_signal: None,
888889
stop_grace_period_secs: None,
890+
mount_tag_offset: 0,
889891
})
890892
}
891893

crates/vz-cli/src/commands/stack.rs

Lines changed: 20 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -373,23 +373,6 @@ impl OciContainerRuntime {
373373
Ok(Self { backend, handle })
374374
}
375375

376-
/// Execute a command in a running container and capture output.
377-
fn exec_with_output(
378-
&self,
379-
container_id: &str,
380-
cmd: Vec<String>,
381-
) -> Result<vz_runtime_contract::ExecOutput, StackError> {
382-
use vz_runtime_contract::RuntimeBackend;
383-
tokio::task::block_in_place(|| {
384-
let exec_config = vz_runtime_contract::ExecConfig {
385-
cmd,
386-
..Default::default()
387-
};
388-
self.handle
389-
.block_on(self.backend.exec_container(container_id, exec_config))
390-
.map_err(|e| StackError::Network(format!("exec failed: {e}")))
391-
})
392-
}
393376
}
394377

395378
impl ContainerRuntime for OciContainerRuntime {
@@ -443,6 +426,15 @@ impl ContainerRuntime for OciContainerRuntime {
443426
}
444427

445428
fn exec(&self, container_id: &str, command: &[String]) -> Result<i32, StackError> {
429+
let (code, _, _) = ContainerRuntime::exec_with_output(self, container_id, command)?;
430+
Ok(code)
431+
}
432+
433+
fn exec_with_output(
434+
&self,
435+
container_id: &str,
436+
command: &[String],
437+
) -> Result<(i32, String, String), StackError> {
446438
use vz_runtime_contract::RuntimeBackend;
447439
tokio::task::block_in_place(|| {
448440
let exec_config = vz_runtime_contract::ExecConfig {
@@ -451,7 +443,7 @@ impl ContainerRuntime for OciContainerRuntime {
451443
};
452444
self.handle
453445
.block_on(self.backend.exec_container(container_id, exec_config))
454-
.map(|output| output.exit_code)
446+
.map(|output| (output.exit_code, output.stdout, output.stderr))
455447
.map_err(|e| StackError::Network(format!("exec failed: {e}")))
456448
})
457449
}
@@ -530,21 +522,12 @@ impl ContainerRuntime for OciContainerRuntime {
530522
}
531523

532524
fn logs(&self, container_id: &str) -> Result<ContainerLogs, StackError> {
533-
let output = self.exec_with_output(
534-
container_id,
535-
vec![
536-
"tail".into(),
537-
"-n".into(),
538-
"100".into(),
539-
CONTAINER_LOG_FILE.into(),
540-
],
541-
)?;
525+
use vz_runtime_contract::RuntimeBackend;
526+
let logs = self.backend.logs(container_id).map_err(|e| {
527+
StackError::Network(format!("logs failed: {e}"))
528+
})?;
542529
Ok(ContainerLogs {
543-
output: if output.exit_code == 0 {
544-
output.stdout
545-
} else {
546-
String::new()
547-
},
530+
output: logs.output,
548531
})
549532
}
550533
}
@@ -978,11 +961,11 @@ fn handle_exec(
978961
);
979962

980963
let runtime = orchestrator.executor().runtime();
981-
match runtime.exec_with_output(container_id, request.cmd.clone()) {
982-
Ok(output) => ControlResponse {
983-
exit_code: output.exit_code,
984-
stdout: output.stdout,
985-
stderr: output.stderr,
964+
match runtime.exec_with_output(container_id, &request.cmd) {
965+
Ok((exit_code, stdout, stderr)) => ControlResponse {
966+
exit_code,
967+
stdout,
968+
stderr,
986969
error: None,
987970
},
988971
Err(e) => ControlResponse {

crates/vz-cli/src/tui.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1104,6 +1104,7 @@ mod tests {
11041104
labels: std::collections::HashMap::new(),
11051105
stop_signal: None,
11061106
stop_grace_period_secs: None,
1107+
container_name: None,
11071108
},
11081109
vz_stack::ServiceSpec {
11091110
name: "db".into(),
@@ -1137,6 +1138,7 @@ mod tests {
11371138
labels: std::collections::HashMap::new(),
11381139
stop_signal: None,
11391140
stop_grace_period_secs: None,
1141+
container_name: None,
11401142
},
11411143
],
11421144
networks: vec![],

crates/vz-guest-agent/src/grpc_server.rs

Lines changed: 48 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -523,14 +523,19 @@ impl oci_service_server::OciService for OciServiceImpl {
523523
format!("--root=/proc/{pid}/root"),
524524
format!("--target={pid}"),
525525
"--".into(),
526+
"env".into(),
526527
];
527528

528-
// Build env prefix if environment variables are specified.
529-
if !req.env.is_empty() || !req.working_dir.is_empty() || !req.user.is_empty() {
530-
nsenter_args.push("env".into());
531-
for (key, value) in &req.env {
532-
nsenter_args.push(format!("{key}={value}"));
533-
}
529+
// Always set a standard PATH so commands like pg_isready are found.
530+
let has_path = req.env.keys().any(|k| k == "PATH");
531+
if !has_path {
532+
nsenter_args.push(
533+
"PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin".into(),
534+
);
535+
}
536+
537+
for (key, value) in &req.env {
538+
nsenter_args.push(format!("{key}={value}"));
534539
}
535540

536541
nsenter_args.push(req.command);
@@ -661,10 +666,23 @@ async fn run_youki(args: &[&str]) -> Result<(), Status> {
661666
if !status.success() {
662667
let youki_log = tokio::fs::read_to_string(&log_file).await.unwrap_or_default();
663668
error!(command = %subcmd, log = %youki_log, "youki command failed");
664-
return Err(Status::internal(format!(
665-
"youki {subcmd} failed (exit {}): see youki log",
666-
status.code().unwrap_or(-1)
667-
)));
669+
// Include the last few lines of the youki log in the error response
670+
// so the host can surface them without needing VM access.
671+
let log_tail: String = youki_log
672+
.lines()
673+
.rev()
674+
.take(10)
675+
.collect::<Vec<_>>()
676+
.into_iter()
677+
.rev()
678+
.collect::<Vec<_>>()
679+
.join("\n");
680+
let exit_code = status.code().unwrap_or(-1);
681+
return Err(Status::internal(if log_tail.is_empty() {
682+
format!("youki {subcmd} failed (exit {exit_code}): no log output")
683+
} else {
684+
format!("youki {subcmd} failed (exit {exit_code}): {log_tail}")
685+
}));
668686
}
669687

670688
Ok(())
@@ -745,10 +763,10 @@ async fn patch_oci_config(config_path: &str) -> Result<(), Status> {
745763
});
746764
}
747765

748-
// Strip maskedPaths, readonlyPaths, and namespaces — the minimal VM
749-
// kernel doesn't support the namespace types youki tries to unshare,
750-
// and masked/readonly paths reference /proc and /sys paths that may
751-
// not exist, causing youki to hang.
766+
// Strip maskedPaths, readonlyPaths, and unsupported namespaces — the
767+
// minimal VM kernel doesn't support all namespace types youki tries to
768+
// unshare, and masked/readonly paths reference /proc and /sys paths that
769+
// may not exist, causing youki to hang.
752770
if let Some(linux) = config.pointer_mut("/linux") {
753771
if let Some(obj) = linux.as_object_mut() {
754772
if obj.remove("maskedPaths").is_some() {
@@ -757,10 +775,22 @@ async fn patch_oci_config(config_path: &str) -> Result<(), Status> {
757775
if obj.remove("readonlyPaths").is_some() {
758776
tracing::info!("stripped readonlyPaths from OCI config");
759777
}
760-
// Strip all namespaces — the container runs directly in the
761-
// guest VM's namespace. The VM itself provides isolation.
762-
if obj.remove("namespaces").is_some() {
763-
tracing::info!("stripped namespaces from OCI config");
778+
// Strip unsupported namespaces but preserve mount and network.
779+
// The host-side bundle already strips PID/IPC/UTS/cgroup, but
780+
// older bundles or third-party configs may still include them.
781+
// Network namespaces MUST be preserved — multi-service stacks
782+
// use per-service netns (e.g. /var/run/netns/svc-web) for
783+
// container network isolation and service discovery.
784+
if let Some(namespaces) = obj.get_mut("namespaces").and_then(|v| v.as_array_mut()) {
785+
let before = namespaces.len();
786+
namespaces.retain(|ns| {
787+
let typ = ns.get("type").and_then(|t| t.as_str()).unwrap_or("");
788+
matches!(typ, "mount" | "network")
789+
});
790+
let stripped = before - namespaces.len();
791+
if stripped > 0 {
792+
tracing::info!(stripped, "stripped unsupported namespaces from OCI config");
793+
}
764794
}
765795
}
766796
}

crates/vz-oci-macos/src/config.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -186,6 +186,9 @@ pub struct RunConfig {
186186
pub stop_signal: Option<String>,
187187
/// Seconds to wait after stop signal before SIGKILL. Default: 10.
188188
pub stop_grace_period_secs: Option<u64>,
189+
// ── Shared VM mount support ──────────────────────────────────
190+
/// Offset added to VirtioFS mount tag indices in shared VM mode.
191+
pub mount_tag_offset: usize,
189192
}
190193

191194
/// Options for executing a command in an already-running container.

crates/vz-oci-macos/src/macos_backend.rs

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -187,13 +187,13 @@ impl RuntimeBackend for MacosRuntimeBackend {
187187
}
188188

189189
fn logs(&self, container_id: &str) -> Result<contract::ContainerLogs, RuntimeError> {
190+
use crate::runtime::container_log_dir;
191+
192+
// Read from the VM-level log directory (not inside the container).
193+
// This works even when the container's init process has exited.
194+
let log_path = format!("{}/output.log", container_log_dir(container_id));
190195
let exec_config = oci_config::ExecConfig {
191-
cmd: vec![
192-
"tail".into(),
193-
"-n".into(),
194-
"100".into(),
195-
"/var/log/vz-oci/output.log".into(),
196-
],
196+
cmd: vec!["tail".into(), "-n".into(), "100".into(), log_path],
197197
working_dir: None,
198198
env: vec![],
199199
user: None,
@@ -202,7 +202,7 @@ impl RuntimeBackend for MacosRuntimeBackend {
202202

203203
let output = tokio::task::block_in_place(|| {
204204
tokio::runtime::Handle::current()
205-
.block_on(self.runtime.exec_container(container_id, exec_config))
205+
.block_on(self.runtime.exec_host(container_id, exec_config))
206206
})
207207
.map_err(oci_err)?;
208208

@@ -264,6 +264,7 @@ fn run_config_from_contract(c: contract::RunConfig) -> oci_config::RunConfig {
264264
domainname: c.domainname,
265265
stop_signal: c.stop_signal,
266266
stop_grace_period_secs: c.stop_grace_period_secs,
267+
mount_tag_offset: c.mount_tag_offset,
267268
}
268269
}
269270

0 commit comments

Comments
 (0)