Skip to content

Commit 355a410

Browse files
committed
fix(podman): provision runtime state and rotate restart channel identity
Signed-off-by: Drew Newberry <anewberry@nvidia.com>
1 parent 03e46ec commit 355a410

4 files changed

Lines changed: 258 additions & 22 deletions

File tree

‎crates/openshell-driver-podman/README.md‎

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -36,12 +36,18 @@ separate. The channel volume uses shared SELinux relabeling (`:z`).
3636

3737
Before starting either container, the driver uploads volume-relative archives
3838
directly to the channel and workspace volume destinations. A rootfs upload on a
39-
stopped Podman container does not populate nested named volumes. Restart restores
40-
only the channel bootstrap into the existing channel volume, preserving the
41-
workspace. The workload starts before the supervisor so its user namespace exists
39+
stopped Podman container does not populate nested named volumes. Each restart
40+
rotates the generation, session, bootstrap token, and mutual-TLS credentials,
41+
then stages the matching channel bootstrap and supervisor topology while both
42+
containers are stopped. Failed staging leaves both stopped; retry rotates again.
43+
Restart preserves the workspace. The workload starts before the supervisor so its user namespace exists
4244
when the supervisor joins it; a stopped supervisor resolves that namespace again
4345
on its next start.
4446

47+
Both containers have an identity-owned, 64 MiB `/run` tmpfs. This lets the
48+
non-root sandbox install `/run/openshell-proxy-ca` without capabilities or an
49+
image-provided writable runtime directory.
50+
4551
The runtime must pass the sandbox's unprivileged enforcement probe, including
4652
nested seccomp notification and Landlock. Unsupported runtime defaults fail
4753
closed; do not switch to an unconfined profile or add capabilities.

‎crates/openshell-driver-podman/src/container.rs‎

Lines changed: 49 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1448,6 +1448,11 @@ pub fn build_isolation_specs(
14481448
workload
14491449
.mounts
14501450
.retain(|mount| !trusted_mount(&mount.destination));
1451+
// The capability-free sandbox installs interception CA material below
1452+
// /run before launching the agent. Image-owned /run is commonly root-only.
1453+
workload
1454+
.mounts
1455+
.push(identity_owned_tmpfs("/run", input.identity));
14511456
workload.volumes.push(NamedVolume {
14521457
name: channel.clone(),
14531458
dest: crate::isolation::CHANNEL_ROOT.into(),
@@ -1494,20 +1499,9 @@ pub fn build_isolation_specs(
14941499
&& mount.destination != openshell_core::container_paths::NETNS_MOUNT_ROOT
14951500
});
14961501
for destination in ["/run", "/var/log", "/tmp"] {
1497-
supervisor.mounts.push(Mount {
1498-
kind: "tmpfs".into(),
1499-
source: "tmpfs".into(),
1500-
destination: destination.into(),
1501-
options: vec![
1502-
"rw".into(),
1503-
"nosuid".into(),
1504-
"nodev".into(),
1505-
format!("uid={}", input.identity.uid),
1506-
format!("gid={}", input.identity.gid),
1507-
"mode=0700".into(),
1508-
"size=64m".into(),
1509-
],
1510-
});
1502+
supervisor
1503+
.mounts
1504+
.push(identity_owned_tmpfs(destination, input.identity));
15111505
}
15121506
for secret in &mut supervisor.secrets {
15131507
secret.uid = input.identity.uid;
@@ -1528,6 +1522,26 @@ pub fn build_isolation_specs(
15281522
})
15291523
}
15301524

1525+
fn identity_owned_tmpfs(
1526+
destination: &str,
1527+
identity: &openshell_isolation_interface::contract::ResolvedWorkloadIdentity,
1528+
) -> Mount {
1529+
Mount {
1530+
kind: "tmpfs".into(),
1531+
source: "tmpfs".into(),
1532+
destination: destination.into(),
1533+
options: vec![
1534+
"rw".into(),
1535+
"nosuid".into(),
1536+
"nodev".into(),
1537+
format!("uid={}", identity.uid),
1538+
format!("gid={}", identity.gid),
1539+
"mode=0700".into(),
1540+
"size=64m".into(),
1541+
],
1542+
}
1543+
}
1544+
15311545
fn trusted_mount(destination: &str) -> bool {
15321546
matches!(
15331547
destination,
@@ -1684,6 +1698,27 @@ mod tests {
16841698
assert!(spec.cap_add.is_empty());
16851699
assert!(spec.seccomp_profile_path.is_empty());
16861700
assert!(spec.no_new_privileges);
1701+
let runtime_mounts: Vec<_> = spec
1702+
.mounts
1703+
.iter()
1704+
.filter(|mount| mount.destination == "/run")
1705+
.collect();
1706+
assert_eq!(runtime_mounts.len(), 1);
1707+
let runtime = runtime_mounts[0];
1708+
assert_eq!(runtime.kind, "tmpfs");
1709+
assert_eq!(runtime.source, "tmpfs");
1710+
assert_eq!(
1711+
runtime.options,
1712+
vec![
1713+
"rw",
1714+
"nosuid",
1715+
"nodev",
1716+
"uid=1000",
1717+
"gid=1001",
1718+
"mode=0700",
1719+
"size=64m"
1720+
]
1721+
);
16871722
}
16881723
assert_eq!(specs.workload.netns.nsmode, "none");
16891724
assert!(specs.workload.networks.is_empty());

‎crates/openshell-driver-podman/src/driver.rs‎

Lines changed: 89 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1318,8 +1318,19 @@ impl PodmanComputeDriver {
13181318
.await?;
13191319
let bundle =
13201320
extract_first_tar_entry(&archive).map_err(ComputeDriverError::Precondition)?;
1321+
let archives =
1322+
crate::isolation::rotate_bootstrap_archives(sandbox_id, &container_id, &bundle)?;
1323+
// Update both endpoints while stopped. A failed upload leaves no
1324+
// running peer with a mismatched generation; retry rotates again.
13211325
self.client
1322-
.copy_to_container(&container_id, crate::isolation::CHANNEL_ROOT, bundle)
1326+
.copy_to_container(
1327+
&container_id,
1328+
crate::isolation::CHANNEL_ROOT,
1329+
archives.channel,
1330+
)
1331+
.await?;
1332+
self.client
1333+
.copy_to_container(&supervisor, "/", archives.supervisor)
13231334
.await?;
13241335
self.client.verify_isolation_fence(&container_id).await?;
13251336
self.client.start_container(&container_id).await?;
@@ -1901,10 +1912,16 @@ mod tests {
19011912
.filter(|request| request.starts_with("PUT "))
19021913
.cloned()
19031914
.collect::<Vec<_>>(),
1904-
vec![format!(
1905-
"PUT {}",
1906-
api_path("/libpod/containers/ctr-1/archive?path=%2F.openshell%2Fchannel")
1907-
)]
1915+
vec![
1916+
format!(
1917+
"PUT {}",
1918+
api_path("/libpod/containers/ctr-1/archive?path=%2F.openshell%2Fchannel")
1919+
),
1920+
format!(
1921+
"PUT {}",
1922+
api_path("/libpod/containers/openshell-supervisor-sandbox-1/archive?path=%2F")
1923+
),
1924+
]
19081925
);
19091926
assert!(
19101927
!restart_requests
@@ -1932,6 +1949,67 @@ mod tests {
19321949
let _ = fs::remove_file(start_socket);
19331950
}
19341951

1952+
#[tokio::test]
1953+
async fn restart_upload_failures_leave_both_peers_stopped() {
1954+
#[derive(serde::Serialize)]
1955+
#[serde(rename_all = "PascalCase")]
1956+
struct Summary {
1957+
id: &'static str,
1958+
state: &'static str,
1959+
}
1960+
#[derive(serde::Serialize)]
1961+
#[serde(rename_all = "PascalCase")]
1962+
struct State {
1963+
status: &'static str,
1964+
running: bool,
1965+
}
1966+
#[derive(serde::Serialize)]
1967+
#[serde(rename_all = "PascalCase")]
1968+
struct Inspect {
1969+
id: &'static str,
1970+
name: &'static str,
1971+
state: State,
1972+
}
1973+
for upload_index in [3, 4] {
1974+
let mut responses = vec![
1975+
StubResponse::new(
1976+
StatusCode::OK,
1977+
serde_json::to_vec(&[Summary {
1978+
id: "ctr-1",
1979+
state: "stopped",
1980+
}])
1981+
.unwrap(),
1982+
),
1983+
StubResponse::new(
1984+
StatusCode::OK,
1985+
serde_json::to_vec(&Inspect {
1986+
id: "ctr-1",
1987+
name: "sandbox",
1988+
state: State {
1989+
status: "exited",
1990+
running: false,
1991+
},
1992+
})
1993+
.unwrap(),
1994+
),
1995+
];
1996+
let mut restart = restart_responses();
1997+
restart[upload_index] =
1998+
StubResponse::new(StatusCode::INTERNAL_SERVER_ERROR, "upload failed");
1999+
responses.extend(restart.into_iter().take(upload_index + 1));
2000+
let (path, requests, server) = spawn_podman_stub("restart-upload-failure", responses);
2001+
assert!(test_driver(path).start_sandbox("sandbox-1").await.is_err());
2002+
server.await.unwrap();
2003+
assert!(
2004+
requests
2005+
.lock()
2006+
.unwrap()
2007+
.iter()
2008+
.all(|request| !request.ends_with("/start"))
2009+
);
2010+
}
2011+
}
2012+
19352013
#[tokio::test]
19362014
async fn stop_waits_for_the_container_to_leave_stopping_state() {
19372015
let (socket, requests, handle) = spawn_podman_stub(
@@ -3150,6 +3228,12 @@ mod tests {
31503228
),
31513229
StubResponse::new(StatusCode::OK, archive.into_inner().unwrap()),
31523230
StubResponse::new(StatusCode::OK, "").with_archive_members(channel_archive_members()),
3231+
StubResponse::new(StatusCode::OK, "").with_archive_members(&[
3232+
".openshell",
3233+
".openshell/supervisor",
3234+
".openshell/supervisor/topology.payload",
3235+
".openshell/supervisor/sandbox-bundle.tar",
3236+
]),
31533237
fence_response(),
31543238
StubResponse::new(StatusCode::NO_CONTENT, ""), // workload start
31553239
StubResponse::new(StatusCode::NO_CONTENT, ""), // supervisor start

‎crates/openshell-driver-podman/src/isolation.rs‎

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -226,6 +226,52 @@ pub fn bootstrap_archives(
226226
})
227227
}
228228

229+
/// Retain only stable launch inputs from the supervisor-private restart copy.
230+
/// Every container restart establishes a new authenticated listener generation.
231+
pub fn rotate_bootstrap_archives(
232+
sandbox_id: &str,
233+
container_id: &str,
234+
previous_channel: &[u8],
235+
) -> Result<BootstrapArchives, ComputeDriverError> {
236+
let mut archive = tar::Archive::new(previous_channel);
237+
for entry in archive.entries().map_err(invalid)? {
238+
let entry = entry.map_err(invalid)?;
239+
if entry.path().map_err(invalid)?.as_ref() != std::path::Path::new("sandbox/bootstrap.json")
240+
{
241+
continue;
242+
}
243+
if !entry.header().entry_type().is_file() {
244+
return Err(invalid("restart bootstrap must be a regular file"));
245+
}
246+
let previous: BoundaryConfig = serde_json::from_reader(entry).map_err(invalid)?;
247+
if previous.boundary_id != sandbox_id
248+
|| previous
249+
.resource_claims
250+
.get("podman.container_id")
251+
.map(String::as_str)
252+
!= Some(container_id)
253+
|| !matches!(&previous.driver_fence, DriverFenceEvidence::Podman { container_id: bound, .. } if bound == container_id)
254+
{
255+
return Err(invalid(
256+
"restart bootstrap does not belong to this Podman workload",
257+
));
258+
}
259+
previous
260+
.driver_fence
261+
.validate_for_backend("podman")
262+
.map_err(invalid)?;
263+
return bootstrap_archives(
264+
sandbox_id,
265+
container_id,
266+
&previous.workload_identity,
267+
previous.child_env,
268+
);
269+
}
270+
Err(invalid(
271+
"supervisor restart archive is missing sandbox/bootstrap.json",
272+
))
273+
}
274+
229275
struct Archive<'a> {
230276
builder: tar::Builder<Vec<u8>>,
231277
identity: &'a ResolvedWorkloadIdentity,
@@ -379,4 +425,69 @@ mod tests {
379425
&archives.channel
380426
);
381427
}
428+
429+
#[test]
430+
fn restart_rotates_every_authentication_value_while_preserving_launch_inputs() {
431+
let identity = ResolvedWorkloadIdentity::new(
432+
1000,
433+
1001,
434+
vec![],
435+
"image".into(),
436+
"sha256:image".into(),
437+
)
438+
.unwrap();
439+
let original = bootstrap_archives(
440+
"sandbox",
441+
"container",
442+
&identity,
443+
HashMap::from([("PATH".into(), "/usr/bin".into())]),
444+
)
445+
.unwrap();
446+
let renewed = rotate_bootstrap_archives("sandbox", "container", &original.channel).unwrap();
447+
let original_files = files(&original.channel);
448+
let renewed_files = files(&renewed.channel);
449+
let supervisor_files = files(&renewed.supervisor);
450+
let old: BoundaryConfig =
451+
serde_json::from_slice(&original_files[&PathBuf::from("sandbox/bootstrap.json")])
452+
.unwrap();
453+
let new: BoundaryConfig =
454+
serde_json::from_slice(&renewed_files[&PathBuf::from("sandbox/bootstrap.json")])
455+
.unwrap();
456+
let supervisor: BoundaryTopology = serde_json::from_slice(
457+
&supervisor_files[&PathBuf::from(TOPOLOGY_PATH.trim_start_matches('/'))],
458+
)
459+
.unwrap();
460+
assert_ne!(new.generation, old.generation);
461+
assert_ne!(new.session_epoch, old.session_epoch);
462+
assert_ne!(new.bootstrap_token, old.bootstrap_token);
463+
assert_eq!(new.generation, supervisor.generation);
464+
assert_eq!(new.session_epoch, supervisor.session_epoch);
465+
assert_eq!(new.bootstrap_token, supervisor.bootstrap_token);
466+
assert_eq!(new.workload_identity, old.workload_identity);
467+
assert_eq!(new.resource_claims, old.resource_claims);
468+
assert_eq!(new.child_env, old.child_env);
469+
for path in [
470+
"sandbox/server.crt",
471+
"sandbox/server.key",
472+
"sandbox/client-ca.crt",
473+
] {
474+
assert_ne!(
475+
renewed_files[&PathBuf::from(path)],
476+
original_files[&PathBuf::from(path)]
477+
);
478+
}
479+
let BoundaryTransport::Unix { tls, .. } = supervisor.transport else {
480+
panic!("expected Unix channel")
481+
};
482+
assert_eq!(
483+
tls.ca_certificate_pem.as_bytes(),
484+
&renewed_files[&PathBuf::from("sandbox/client-ca.crt")]
485+
);
486+
assert_eq!(
487+
supervisor_files[&PathBuf::from(RESTART_BUNDLE_PATH.trim_start_matches('/'))],
488+
renewed.channel
489+
);
490+
assert!(rotate_bootstrap_archives("other", "container", &original.channel).is_err());
491+
assert!(rotate_bootstrap_archives("sandbox", "other", &original.channel).is_err());
492+
}
382493
}

0 commit comments

Comments
 (0)