Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion crates/openshell-driver-podman/NETWORKING.md
Original file line number Diff line number Diff line change
Expand Up @@ -406,7 +406,7 @@ start and bind-mounted into sandbox containers by the Podman driver.
| DNS | Kubernetes CoreDNS | Podman bridge DNS through aardvark-dns when DNS is enabled. |
| Network policy | Kubernetes network policy for pod ingress plus supervisor policy | nftables inside inner sandbox netns plus supervisor policy. |
| Supervisor delivery | Kubernetes driver managed pod image or template | OCI image volume mount. |
| Secrets | Kubernetes Secret volume and env vars | Mounted TLS client materials from a Podman secret. |
| Secrets | Kubernetes Secret volume and env vars | Per-sandbox JWT via Podman secret; TLS client materials from configured host files. |

Both drivers use the same reverse gRPC relay for SSH transport. The most
important Podman-specific difference is network reachability: in rootless
Expand Down
4 changes: 2 additions & 2 deletions crates/openshell-driver-podman/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,8 +68,8 @@ mount types:
pulls the image during provisioning using the sandbox image pull policy.

Host bind mounts are disabled by default because they expose gateway host paths
to sandbox requests. The driver still uses internal bind mounts for
OpenShell-owned token and TLS material.
to sandbox requests. The driver still uses internal bind mounts for configured
TLS material; per-sandbox gateway JWTs are delivered through Podman secrets.

Podman `bind` mounts accept `source`, `target`, and optional `read_only`.
User-supplied bind and volume mounts are read-only by default; set
Expand Down
74 changes: 74 additions & 0 deletions crates/openshell-driver-podman/src/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -391,6 +391,23 @@ impl PodmanClient {
}
}

/// Perform a versioned HTTP request with a raw byte body (not JSON).
async fn request_raw(
&self,
method: hyper::Method,
path: &str,
content_type: &str,
body: Bytes,
) -> Result<(hyper::StatusCode, Bytes), PodmanApiError> {
let req = Self::build_request(
method,
&format!("/{API_VERSION}{path}"),
Full::new(body),
Some(content_type),
);
self.send_request(req, API_TIMEOUT).await
}

/// POST a JSON body and ignore 409 Conflict (resource already exists).
async fn create_ignore_conflict(&self, path: &str, body: &Value) -> Result<(), PodmanApiError> {
match self
Expand Down Expand Up @@ -518,6 +535,63 @@ impl PodmanClient {
.await
}

// ── Secret operations ────────────────────────────────────────────────

/// Create a Podman secret with the given name and raw value.
///
/// Idempotent: if a secret with the same name already exists it is
/// replaced (delete + recreate) so the value is always up-to-date.
pub async fn create_secret(&self, name: &str, value: &[u8]) -> Result<(), PodmanApiError> {
validate_name(name)?;
let encoded_name = url_encode(name);
let path = format!("/libpod/secrets/create?name={encoded_name}");
let (status, bytes) = self
.request_raw(
hyper::Method::POST,
&path,
"application/octet-stream",
Bytes::copy_from_slice(value),
)
.await?;

match status.as_u16() {
200 | 201 => Ok(()),
409 => {
self.remove_secret(name).await?;
let (status2, bytes2) = self
.request_raw(
hyper::Method::POST,
&path,
"application/octet-stream",
Bytes::copy_from_slice(value),
)
.await?;
if status2.is_success() {
Ok(())
} else {
Err(error_from_response(status2.as_u16(), &bytes2))
}
}
_ => Err(error_from_response(status.as_u16(), &bytes)),
}
}

/// Remove a Podman secret by name. Idempotent (not-found is ignored).
pub async fn remove_secret(&self, name: &str) -> Result<(), PodmanApiError> {
validate_name(name)?;
match self
.request_ok(
hyper::Method::DELETE,
&format!("/libpod/secrets/{name}"),
None,
)
.await
{
Ok(()) | Err(PodmanApiError::NotFound(_)) => Ok(()),
Err(e) => Err(e),
}
}

// ── Network operations ───────────────────────────────────────────────

/// Create a bridge network with DNS enabled. Idempotent.
Expand Down
85 changes: 60 additions & 25 deletions crates/openshell-driver-podman/src/container.rs
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,9 @@ const CONTAINER_PREFIX: &str = "openshell-sandbox-";
/// Volume name prefix.
const VOLUME_PREFIX: &str = "openshell-sandbox-";

/// Secret name prefix for per-sandbox gateway JWTs.
const TOKEN_SECRET_PREFIX: &str = "openshell-token-";

/// Container-side mount paths for client TLS materials and the sandbox token.
const TLS_CA_MOUNT_PATH: &str = openshell_core::driver_utils::TLS_CA_MOUNT_PATH;
const TLS_CERT_MOUNT_PATH: &str = openshell_core::driver_utils::TLS_CERT_MOUNT_PATH;
Expand Down Expand Up @@ -149,6 +152,12 @@ pub fn volume_name(sandbox_id: &str) -> String {
format!("{VOLUME_PREFIX}{sandbox_id}-workspace")
}

/// Build the per-sandbox Podman secret name for the gateway JWT.
#[must_use]
pub fn token_secret_name(sandbox_id: &str) -> String {
format!("{TOKEN_SECRET_PREFIX}{sandbox_id}")
}

/// Truncate a container ID to 12 characters (standard short form).
#[must_use]
pub fn short_id(id: &str) -> String {
Expand Down Expand Up @@ -187,6 +196,8 @@ struct ContainerSpec {
/// environment-variable injection, distinct from `secrets` which only
/// handles file-mounted secrets under `/run/secrets/`.
secret_env: BTreeMap<String, String>,
/// File-mounted Podman secrets.
secrets: Vec<SecretMount>,
stop_timeout: u32,
/// Extra /etc/hosts entries. Used to inject `host.containers.internal`
/// via Podman's `host-gateway` magic so sandbox containers can reach
Expand Down Expand Up @@ -271,6 +282,15 @@ struct HealthConfig {
start_period: u64,
}

#[derive(Serialize)]
struct SecretMount {
source: String,
target: String,
uid: u32,
gid: u32,
mode: u32,
}

#[derive(Serialize)]
struct ResourceLimits {
cpu: CpuLimits,
Expand Down Expand Up @@ -763,17 +783,17 @@ pub fn build_container_spec(sandbox: &DriverSandbox, config: &PodmanComputeConfi
pub fn build_container_spec_with_token(
sandbox: &DriverSandbox,
config: &PodmanComputeConfig,
token_host_path: Option<&Path>,
token_secret_name: Option<&str>,
) -> Value {
try_build_container_spec_with_token(sandbox, config, token_host_path)
try_build_container_spec_with_token(sandbox, config, token_secret_name)
.expect("container spec should be valid")
}

#[cfg(test)]
pub fn try_build_container_spec_with_token(
sandbox: &DriverSandbox,
config: &PodmanComputeConfig,
token_host_path: Option<&Path>,
token_secret_name: Option<&str>,
) -> Result<Value, ComputeDriverError> {
let driver_config = PodmanSandboxDriverConfig::from_sandbox(sandbox)?;
let gpu_requirements = sandbox
Expand All @@ -791,13 +811,13 @@ pub fn try_build_container_spec_with_token(
} else {
None
};
build_container_spec_with_token_and_gpu_devices(sandbox, config, token_host_path, cdi_devices)
build_container_spec_with_token_and_gpu_devices(sandbox, config, token_secret_name, cdi_devices)
}

pub fn build_container_spec_with_token_and_gpu_devices(
sandbox: &DriverSandbox,
config: &PodmanComputeConfig,
token_host_path: Option<&Path>,
token_secret_name: Option<&str>,
gpu_device_ids: Option<&[String]>,
) -> Result<Value, ComputeDriverError> {
let image = resolve_image(sandbox, config);
Expand All @@ -809,6 +829,16 @@ pub fn build_container_spec_with_token_and_gpu_devices(
let resource_limits = build_resource_limits(sandbox, config);
let user_mounts = podman_user_mounts(sandbox, config.enable_bind_mounts)
.map_err(ComputeDriverError::InvalidArgument)?;
if sandbox
.spec
.as_ref()
.is_some_and(|spec| !spec.sandbox_token.is_empty())
&& token_secret_name.is_none()
{
return Err(ComputeDriverError::Precondition(
"podman sandbox token secret is required when sandbox token is set".to_string(),
));
}
let devices = gpu_device_ids.map(|device_ids| {
device_ids
.iter()
Expand Down Expand Up @@ -953,6 +983,15 @@ pub fn build_container_spec_with_token_and_gpu_devices(
},
resource_limits,
secret_env: BTreeMap::new(),
secrets: token_secret_name.map_or_else(Vec::new, |source| {
vec![SecretMount {
source: source.to_string(),
target: SANDBOX_TOKEN_MOUNT_PATH.into(),
uid: 0,
gid: 0,
mode: 0o400,
}]
}),
stop_timeout: config.stop_timeout_secs,
// Inject stable host aliases into /etc/hosts so sandbox containers can
// reach services on the host. `host.openshell.internal` is the driver-
Expand Down Expand Up @@ -1012,18 +1051,6 @@ pub fn build_container_spec_with_token_and_gpu_devices(
options: ro,
});
}
if let Some(path) = token_host_path {
let mut ro = vec!["ro".into(), "rbind".into()];
if is_selinux_enabled() {
ro.push("z".into());
}
m.push(Mount {
kind: "bind".into(),
source: path.display().to_string(),
destination: SANDBOX_TOKEN_MOUNT_PATH.into(),
options: ro,
});
}
m.extend(user_mounts.mounts);
m
},
Expand Down Expand Up @@ -2215,7 +2242,7 @@ mod tests {
}

#[test]
fn container_spec_uses_token_file_mount_without_raw_token_env() {
fn container_spec_uses_token_secret_mount_without_raw_token_env() {
use openshell_core::proto::compute::v1::DriverSandboxSpec;

let mut sandbox = test_sandbox("token-id", "token-name");
Expand All @@ -2224,9 +2251,9 @@ mod tests {
..Default::default()
});
let config = test_config();
let token_path = Path::new("/host/token.jwt");
let secret_name = token_secret_name(&sandbox.id);

let spec = build_container_spec_with_token(&sandbox, &config, Some(token_path));
let spec = build_container_spec_with_token(&sandbox, &config, Some(&secret_name));

let env_map = spec["env"].as_object().expect("env should be an object");
assert_eq!(
Expand All @@ -2241,14 +2268,22 @@ mod tests {
.and_then(|v| v.as_str()),
Some("/etc/openshell/auth/sandbox.jwt")
);
let secrets = spec["secrets"]
.as_array()
.expect("secrets should be an array");
assert!(secrets.iter().any(|secret| {
secret["source"].as_str() == Some(secret_name.as_str())
&& secret["target"].as_str() == Some("/etc/openshell/auth/sandbox.jwt")
&& secret["mode"].as_u64() == Some(0o400)
}));
let mounts = spec["mounts"]
.as_array()
.expect("mounts should be an array");
assert!(mounts.iter().any(|m| {
m["type"].as_str() == Some("bind")
&& m["source"].as_str() == Some("/host/token.jwt")
&& m["destination"].as_str() == Some("/etc/openshell/auth/sandbox.jwt")
}));
assert!(
!mounts
.iter()
.any(|m| { m["destination"].as_str() == Some("/etc/openshell/auth/sandbox.jwt") })
);
}

#[test]
Expand Down
Loading
Loading