Skip to content

Commit 80d7efd

Browse files
feat: add runtime state store v2 with timestamp tracking and stale reconciliation
Add started/stopped timestamps to ContainerInfo, find() for single-container lookup, and reconcile_stale() to detect and clean up containers whose host PID is no longer alive. Runtime::new() now auto-reconciles stale containers on startup. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent e49e2ff commit 80d7efd

2 files changed

Lines changed: 267 additions & 1 deletion

File tree

crates/vz-oci/src/container_store.rs

Lines changed: 232 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,12 @@ pub struct ContainerInfo {
3434
pub status: ContainerStatus,
3535
/// Unix epoch seconds when metadata was created.
3636
pub created_unix_secs: u64,
37+
/// Unix epoch seconds when the container was started, if applicable.
38+
#[serde(default, skip_serializing_if = "Option::is_none")]
39+
pub started_unix_secs: Option<u64>,
40+
/// Unix epoch seconds when the container stopped, if applicable.
41+
#[serde(default, skip_serializing_if = "Option::is_none")]
42+
pub stopped_unix_secs: Option<u64>,
3743
/// Assembled rootfs path for this container, when known.
3844
pub rootfs_path: Option<PathBuf>,
3945
/// Host process ID currently managing this container, if running.
@@ -86,6 +92,56 @@ impl ContainerStore {
8692
self.write_all(&containers)
8793
}
8894

95+
/// Find a single container by ID.
96+
pub fn find(&self, id: &str) -> io::Result<Option<ContainerInfo>> {
97+
let containers = self.load_all()?;
98+
Ok(containers.into_iter().find(|c| c.id == id))
99+
}
100+
101+
/// Reconcile stale containers whose host PID is no longer alive.
102+
///
103+
/// Containers in `Running` or `Created` state whose `host_pid` no longer
104+
/// exists are transitioned to `Stopped { exit_code: -1 }` with their
105+
/// rootfs cleaned up. Returns the IDs of reconciled containers.
106+
pub fn reconcile_stale(&self) -> io::Result<Vec<String>> {
107+
let mut containers = self.load_all()?;
108+
let mut reconciled = Vec::new();
109+
110+
let now_secs = SystemTime::now()
111+
.duration_since(UNIX_EPOCH)
112+
.map_or(0, |d| d.as_secs());
113+
114+
for container in &mut containers {
115+
let is_active = matches!(
116+
container.status,
117+
ContainerStatus::Running | ContainerStatus::Created
118+
);
119+
if !is_active {
120+
continue;
121+
}
122+
123+
let pid_alive = container.host_pid.is_some_and(is_process_alive);
124+
125+
if !pid_alive {
126+
container.status = ContainerStatus::Stopped { exit_code: -1 };
127+
container.stopped_unix_secs = Some(now_secs);
128+
container.host_pid = None;
129+
130+
if let Some(rootfs) = container.rootfs_path.take() {
131+
let _ = fs::remove_dir_all(rootfs);
132+
}
133+
134+
reconciled.push(container.id.clone());
135+
}
136+
}
137+
138+
if !reconciled.is_empty() {
139+
self.write_all(&containers)?;
140+
}
141+
142+
Ok(reconciled)
143+
}
144+
89145
/// Remove a container metadata record by ID.
90146
pub fn remove(&self, id: &str) -> io::Result<()> {
91147
let mut containers = self.load_all()?;
@@ -129,6 +185,16 @@ fn write_atomic(destination: &Path, bytes: &[u8]) -> io::Result<()> {
129185
fs::rename(&tmp, destination)
130186
}
131187

188+
/// Check if a process with the given PID is alive.
189+
fn is_process_alive(pid: u32) -> bool {
190+
std::process::Command::new("kill")
191+
.args(["-0", &pid.to_string()])
192+
.stdout(std::process::Stdio::null())
193+
.stderr(std::process::Stdio::null())
194+
.status()
195+
.is_ok_and(|s| s.success())
196+
}
197+
132198
fn unique_temp_path(path: &Path) -> PathBuf {
133199
let timestamp = SystemTime::now()
134200
.duration_since(UNIX_EPOCH)
@@ -189,6 +255,8 @@ mod tests {
189255
image_id: "sha256:base".to_string(),
190256
status: ContainerStatus::Created,
191257
created_unix_secs: 1700,
258+
started_unix_secs: None,
259+
stopped_unix_secs: None,
192260
rootfs_path: None,
193261
host_pid: None,
194262
})
@@ -201,6 +269,8 @@ mod tests {
201269
image_id: "sha256:base".to_string(),
202270
status: ContainerStatus::Stopped { exit_code: 0 },
203271
created_unix_secs: 1700,
272+
started_unix_secs: None,
273+
stopped_unix_secs: None,
204274
rootfs_path: None,
205275
host_pid: None,
206276
})
@@ -228,6 +298,8 @@ mod tests {
228298
image_id: "sha256:base".to_string(),
229299
status: ContainerStatus::Created,
230300
created_unix_secs: 1700,
301+
started_unix_secs: None,
302+
stopped_unix_secs: None,
231303
rootfs_path: Some(PathBuf::from("/tmp/example")),
232304
host_pid: Some(12345),
233305
})
@@ -239,4 +311,164 @@ mod tests {
239311
assert!(remaining.is_empty());
240312
assert!(store.remove("container-1").is_err());
241313
}
314+
315+
#[test]
316+
fn find_returns_matching_container() {
317+
let root = unique_temp_dir("find");
318+
let store = ContainerStore::new(root);
319+
320+
store
321+
.upsert(ContainerInfo {
322+
id: "ctr-a".to_string(),
323+
image: "ubuntu:24.04".to_string(),
324+
image_id: "sha256:a".to_string(),
325+
status: ContainerStatus::Running,
326+
created_unix_secs: 100,
327+
started_unix_secs: Some(101),
328+
stopped_unix_secs: None,
329+
rootfs_path: None,
330+
host_pid: Some(std::process::id()),
331+
})
332+
.unwrap();
333+
334+
let found = store.find("ctr-a").unwrap();
335+
assert!(found.is_some());
336+
assert_eq!(found.unwrap().id, "ctr-a");
337+
338+
let missing = store.find("ctr-none").unwrap();
339+
assert!(missing.is_none());
340+
}
341+
342+
#[test]
343+
fn reconcile_stale_transitions_dead_pid_containers() {
344+
let root = unique_temp_dir("reconcile");
345+
let store = ContainerStore::new(root);
346+
347+
// Container with a PID that definitely doesn't exist.
348+
store
349+
.upsert(ContainerInfo {
350+
id: "stale-running".to_string(),
351+
image: "ubuntu:24.04".to_string(),
352+
image_id: "sha256:a".to_string(),
353+
status: ContainerStatus::Running,
354+
created_unix_secs: 100,
355+
started_unix_secs: Some(101),
356+
stopped_unix_secs: None,
357+
rootfs_path: None,
358+
host_pid: Some(999_999_999),
359+
})
360+
.unwrap();
361+
362+
// Container with our own PID — should remain running.
363+
store
364+
.upsert(ContainerInfo {
365+
id: "alive-running".to_string(),
366+
image: "ubuntu:24.04".to_string(),
367+
image_id: "sha256:b".to_string(),
368+
status: ContainerStatus::Running,
369+
created_unix_secs: 200,
370+
started_unix_secs: Some(201),
371+
stopped_unix_secs: None,
372+
rootfs_path: None,
373+
host_pid: Some(std::process::id()),
374+
})
375+
.unwrap();
376+
377+
// Already stopped container — should be untouched.
378+
store
379+
.upsert(ContainerInfo {
380+
id: "already-stopped".to_string(),
381+
image: "ubuntu:24.04".to_string(),
382+
image_id: "sha256:c".to_string(),
383+
status: ContainerStatus::Stopped { exit_code: 0 },
384+
created_unix_secs: 50,
385+
started_unix_secs: Some(51),
386+
stopped_unix_secs: Some(60),
387+
rootfs_path: None,
388+
host_pid: None,
389+
})
390+
.unwrap();
391+
392+
let reconciled = store.reconcile_stale().unwrap();
393+
394+
assert_eq!(reconciled, vec!["stale-running".to_string()]);
395+
396+
let containers = store.load_all().unwrap();
397+
let stale = containers.iter().find(|c| c.id == "stale-running").unwrap();
398+
assert!(matches!(
399+
stale.status,
400+
ContainerStatus::Stopped { exit_code: -1 }
401+
));
402+
assert!(stale.stopped_unix_secs.is_some());
403+
assert!(stale.host_pid.is_none());
404+
405+
let alive = containers.iter().find(|c| c.id == "alive-running").unwrap();
406+
assert!(matches!(alive.status, ContainerStatus::Running));
407+
assert_eq!(alive.host_pid, Some(std::process::id()));
408+
}
409+
410+
#[test]
411+
fn reconcile_stale_cleans_up_rootfs() {
412+
let root = unique_temp_dir("reconcile-rootfs");
413+
let store = ContainerStore::new(root.clone());
414+
415+
let rootfs_dir = root.join("stale-rootfs");
416+
fs::create_dir_all(&rootfs_dir).unwrap();
417+
418+
store
419+
.upsert(ContainerInfo {
420+
id: "stale-with-rootfs".to_string(),
421+
image: "ubuntu:24.04".to_string(),
422+
image_id: "sha256:a".to_string(),
423+
status: ContainerStatus::Running,
424+
created_unix_secs: 100,
425+
started_unix_secs: Some(101),
426+
stopped_unix_secs: None,
427+
rootfs_path: Some(rootfs_dir.clone()),
428+
host_pid: Some(999_999_999),
429+
})
430+
.unwrap();
431+
432+
let reconciled = store.reconcile_stale().unwrap();
433+
assert_eq!(reconciled.len(), 1);
434+
assert!(!rootfs_dir.exists());
435+
}
436+
437+
#[test]
438+
fn serde_round_trip_with_new_timestamp_fields() {
439+
let original = ContainerInfo {
440+
id: "ctr-serde".to_string(),
441+
image: "alpine:3.22".to_string(),
442+
image_id: "sha256:serde".to_string(),
443+
status: ContainerStatus::Stopped { exit_code: 42 },
444+
created_unix_secs: 1000,
445+
started_unix_secs: Some(1001),
446+
stopped_unix_secs: Some(1010),
447+
rootfs_path: None,
448+
host_pid: None,
449+
};
450+
451+
let json = serde_json::to_string(&original).unwrap();
452+
let deserialized: ContainerInfo = serde_json::from_str(&json).unwrap();
453+
assert_eq!(deserialized, original);
454+
}
455+
456+
#[test]
457+
fn serde_backward_compat_missing_timestamp_fields() {
458+
// Simulate old JSON without started_unix_secs/stopped_unix_secs.
459+
let json = r#"{
460+
"id": "old-ctr",
461+
"image": "ubuntu:24.04",
462+
"image_id": "sha256:old",
463+
"status": "Created",
464+
"created_unix_secs": 500,
465+
"rootfs_path": null,
466+
"host_pid": null
467+
}"#;
468+
469+
let info: ContainerInfo = serde_json::from_str(json).unwrap();
470+
assert_eq!(info.id, "old-ctr");
471+
assert!(info.started_unix_secs.is_none());
472+
assert!(info.stopped_unix_secs.is_none());
473+
}
242474
}

0 commit comments

Comments
 (0)