backend: harden rootless app lifecycle orchestration
This commit is contained in:
@@ -8,9 +8,9 @@ pub mod runtime;
|
||||
pub use bitcoin_simulator::{BitcoinSimulationMode, BitcoinSimulator};
|
||||
pub use health_monitor::HealthMonitor;
|
||||
pub use manifest::{
|
||||
AppManifest, BuildConfig, ContainerConfig, Dependency, DerivedEnv, HealthCheck, HostFacts,
|
||||
ManifestError, ResolvedSource, ResourceLimits, SecretEnv, SecretsProvider, SecurityPolicy,
|
||||
Volume,
|
||||
AppManifest, BuildConfig, ContainerConfig, Dependency, DerivedEnv, GeneratedFile, HealthCheck,
|
||||
HostFacts, ManifestError, ResolvedSource, ResourceLimits, SecretEnv, SecretsProvider,
|
||||
SecurityPolicy, Volume,
|
||||
};
|
||||
pub use podman_client::{
|
||||
image_uses_insecure_registry, ContainerState, ContainerStatus, PodmanClient,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
@@ -42,6 +42,9 @@ pub struct AppDefinition {
|
||||
#[serde(default)]
|
||||
pub volumes: Vec<Volume>,
|
||||
|
||||
#[serde(default)]
|
||||
pub files: Vec<GeneratedFile>,
|
||||
|
||||
#[serde(default)]
|
||||
pub environment: Vec<String>,
|
||||
|
||||
@@ -216,6 +219,8 @@ pub struct SecurityPolicy {
|
||||
pub capabilities: Vec<String>,
|
||||
#[serde(default = "default_true")]
|
||||
pub readonly_root: bool,
|
||||
#[serde(default = "default_true")]
|
||||
pub no_new_privileges: bool,
|
||||
#[serde(default = "default_network_policy")]
|
||||
pub network_policy: String,
|
||||
#[serde(default)]
|
||||
@@ -263,6 +268,14 @@ pub struct Volume {
|
||||
pub tmpfs_options: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct GeneratedFile {
|
||||
pub path: String,
|
||||
pub content: String,
|
||||
#[serde(default)]
|
||||
pub overwrite: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct HealthCheck {
|
||||
#[serde(rename = "type")]
|
||||
@@ -302,8 +315,16 @@ impl AppManifest {
|
||||
}
|
||||
|
||||
pub fn validate(&self) -> Result<(), ManifestError> {
|
||||
if self.app.id.is_empty() {
|
||||
return Err(ManifestError::Invalid("app.id cannot be empty".to_string()));
|
||||
if !is_valid_app_id(&self.app.id) {
|
||||
return Err(ManifestError::Invalid(
|
||||
"app.id must be lowercase ASCII letters, digits, or single hyphens".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
if self.app.name.trim().is_empty() {
|
||||
return Err(ManifestError::Invalid(
|
||||
"app.name cannot be empty".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
// Exactly one of container.image or container.build must be set. We can't
|
||||
@@ -355,6 +376,11 @@ impl AppManifest {
|
||||
"container.network cannot be empty (omit the field to use default)".to_string(),
|
||||
));
|
||||
}
|
||||
if is_dangerous_network_mode(n) {
|
||||
return Err(ManifestError::Invalid(format!(
|
||||
"container.network '{n}' is not allowed in app manifests"
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
// custom_args: no empty strings (would inject literal "" into
|
||||
@@ -447,6 +473,11 @@ impl AppManifest {
|
||||
}
|
||||
}
|
||||
|
||||
validate_security(&self.app.security)?;
|
||||
validate_ports(&self.app.ports)?;
|
||||
validate_environment(&self.app.environment)?;
|
||||
validate_devices(&self.app.devices)?;
|
||||
|
||||
// Volume tmpfs_options: only meaningful for type: tmpfs.
|
||||
for (i, v) in self.app.volumes.iter().enumerate() {
|
||||
if v.volume_type == "tmpfs" {
|
||||
@@ -466,6 +497,11 @@ impl AppManifest {
|
||||
v.volume_type
|
||||
)));
|
||||
} else {
|
||||
if v.volume_type != "bind" && v.volume_type != "volume" {
|
||||
return Err(ManifestError::Invalid(format!(
|
||||
"volumes[{i}].type must be bind, volume, or tmpfs"
|
||||
)));
|
||||
}
|
||||
if v.source.is_empty() {
|
||||
return Err(ManifestError::Invalid(format!(
|
||||
"volumes[{i}] ({}) must set source",
|
||||
@@ -478,6 +514,45 @@ impl AppManifest {
|
||||
v.volume_type
|
||||
)));
|
||||
}
|
||||
if v.volume_type == "bind" {
|
||||
validate_bind_source(i, &v.source)?;
|
||||
} else if !is_valid_named_volume(&v.source) {
|
||||
return Err(ManifestError::Invalid(format!(
|
||||
"volumes[{i}].source must be a safe named volume"
|
||||
)));
|
||||
}
|
||||
validate_container_path(i, &v.target)?;
|
||||
validate_volume_options(i, &v.options)?;
|
||||
}
|
||||
}
|
||||
|
||||
for (i, f) in self.app.files.iter().enumerate() {
|
||||
if f.path.is_empty() {
|
||||
return Err(ManifestError::Invalid(format!(
|
||||
"files[{i}].path cannot be empty"
|
||||
)));
|
||||
}
|
||||
if !std::path::Path::new(&f.path).is_absolute() {
|
||||
return Err(ManifestError::Invalid(format!(
|
||||
"files[{i}].path must be absolute"
|
||||
)));
|
||||
}
|
||||
if f.content.is_empty() {
|
||||
return Err(ManifestError::Invalid(format!(
|
||||
"files[{i}].content cannot be empty"
|
||||
)));
|
||||
}
|
||||
let file_path = std::path::Path::new(&f.path);
|
||||
let under_bind_mount = self
|
||||
.app
|
||||
.volumes
|
||||
.iter()
|
||||
.filter(|v| v.volume_type != "tmpfs" && !v.source.is_empty())
|
||||
.any(|v| file_path.starts_with(std::path::Path::new(&v.source)));
|
||||
if !under_bind_mount {
|
||||
return Err(ManifestError::Invalid(format!(
|
||||
"files[{i}].path must live under a bind-mounted volume source"
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -485,6 +560,195 @@ impl AppManifest {
|
||||
}
|
||||
}
|
||||
|
||||
fn is_valid_app_id(id: &str) -> bool {
|
||||
if id.is_empty() || id.starts_with('-') || id.ends_with('-') || id.contains("--") {
|
||||
return false;
|
||||
}
|
||||
id.chars()
|
||||
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
|
||||
}
|
||||
|
||||
fn is_dangerous_network_mode(mode: &str) -> bool {
|
||||
mode.starts_with("container:") || mode.starts_with("ns:")
|
||||
}
|
||||
|
||||
fn validate_security(policy: &SecurityPolicy) -> Result<(), ManifestError> {
|
||||
let allowed_network_policies = ["isolated", "bridge", "host"];
|
||||
if !policy.network_policy.is_empty()
|
||||
&& !allowed_network_policies.contains(&policy.network_policy.as_str())
|
||||
{
|
||||
return Err(ManifestError::Invalid(format!(
|
||||
"security.network_policy must be one of {}",
|
||||
allowed_network_policies.join(", ")
|
||||
)));
|
||||
}
|
||||
|
||||
let allowed_caps = [
|
||||
"CHOWN",
|
||||
"DAC_OVERRIDE",
|
||||
"FOWNER",
|
||||
"NET_ADMIN",
|
||||
"NET_BIND_SERVICE",
|
||||
"NET_RAW",
|
||||
"SETGID",
|
||||
"SETUID",
|
||||
"SYS_ADMIN",
|
||||
];
|
||||
let mut seen = HashSet::new();
|
||||
for cap in &policy.capabilities {
|
||||
if !allowed_caps.contains(&cap.as_str()) {
|
||||
return Err(ManifestError::Invalid(format!(
|
||||
"security.capabilities contains unsupported capability '{cap}'"
|
||||
)));
|
||||
}
|
||||
if !seen.insert(cap.as_str()) {
|
||||
return Err(ManifestError::Invalid(format!(
|
||||
"security.capabilities contains duplicate capability '{cap}'"
|
||||
)));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_ports(ports: &[PortMapping]) -> Result<(), ManifestError> {
|
||||
let mut seen_host = HashSet::new();
|
||||
for (i, port) in ports.iter().enumerate() {
|
||||
if port.host == 0 || port.container == 0 {
|
||||
return Err(ManifestError::Invalid(format!(
|
||||
"ports[{i}].host and ports[{i}].container must be non-zero"
|
||||
)));
|
||||
}
|
||||
let protocol = if port.protocol.is_empty() {
|
||||
"tcp"
|
||||
} else {
|
||||
port.protocol.as_str()
|
||||
};
|
||||
if protocol != "tcp" && protocol != "udp" {
|
||||
return Err(ManifestError::Invalid(format!(
|
||||
"ports[{i}].protocol must be tcp or udp"
|
||||
)));
|
||||
}
|
||||
if !seen_host.insert((port.host, protocol.to_string())) {
|
||||
return Err(ManifestError::Invalid(format!(
|
||||
"ports contains duplicate host binding {}/{}",
|
||||
port.host, protocol
|
||||
)));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_environment(env: &[String]) -> Result<(), ManifestError> {
|
||||
let mut seen = HashSet::new();
|
||||
for (i, entry) in env.iter().enumerate() {
|
||||
let Some((key, _)) = entry.split_once('=') else {
|
||||
return Err(ManifestError::Invalid(format!(
|
||||
"environment[{i}] must be KEY=VALUE"
|
||||
)));
|
||||
};
|
||||
if !is_valid_env_key(key) {
|
||||
return Err(ManifestError::Invalid(format!(
|
||||
"environment[{i}] has invalid key '{key}'"
|
||||
)));
|
||||
}
|
||||
if !seen.insert(key) {
|
||||
return Err(ManifestError::Invalid(format!(
|
||||
"environment contains duplicate key '{key}'"
|
||||
)));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn is_valid_env_key(key: &str) -> bool {
|
||||
let mut chars = key.chars();
|
||||
match chars.next() {
|
||||
Some(c) if c.is_ascii_alphabetic() || c == '_' => {}
|
||||
_ => return false,
|
||||
}
|
||||
chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
|
||||
}
|
||||
|
||||
fn validate_devices(devices: &[String]) -> Result<(), ManifestError> {
|
||||
let mut seen = HashSet::new();
|
||||
for (i, device) in devices.iter().enumerate() {
|
||||
if !device.starts_with("/dev/") || device.contains("..") {
|
||||
return Err(ManifestError::Invalid(format!(
|
||||
"devices[{i}] must be an absolute /dev path"
|
||||
)));
|
||||
}
|
||||
if !seen.insert(device.as_str()) {
|
||||
return Err(ManifestError::Invalid(format!(
|
||||
"devices contains duplicate entry '{device}'"
|
||||
)));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_bind_source(index: usize, source: &str) -> Result<(), ManifestError> {
|
||||
let path = std::path::Path::new(source);
|
||||
if !path.is_absolute() {
|
||||
if is_valid_named_volume(source) {
|
||||
return Ok(());
|
||||
}
|
||||
return Err(ManifestError::Invalid(format!(
|
||||
"volumes[{index}].source must be absolute for host bind mounts or a safe named volume"
|
||||
)));
|
||||
}
|
||||
if source.contains("..") {
|
||||
return Err(ManifestError::Invalid(format!(
|
||||
"volumes[{index}].source must not contain '..'"
|
||||
)));
|
||||
}
|
||||
if source.starts_with("/var/lib/archipelago/") || is_reviewed_host_bind_exception(source) {
|
||||
return Ok(());
|
||||
}
|
||||
Err(ManifestError::Invalid(format!(
|
||||
"volumes[{index}].source must be under /var/lib/archipelago or a reviewed host-bind exception"
|
||||
)))
|
||||
}
|
||||
|
||||
fn is_reviewed_host_bind_exception(source: &str) -> bool {
|
||||
source == "/run/user/1000/podman/podman.sock" || source == "/var/run/dbus"
|
||||
}
|
||||
|
||||
fn is_valid_named_volume(source: &str) -> bool {
|
||||
if source.is_empty() || source.contains('/') || source.contains("..") {
|
||||
return false;
|
||||
}
|
||||
source
|
||||
.chars()
|
||||
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.')
|
||||
}
|
||||
|
||||
fn validate_container_path(index: usize, target: &str) -> Result<(), ManifestError> {
|
||||
if !std::path::Path::new(target).is_absolute() || target.contains("..") {
|
||||
return Err(ManifestError::Invalid(format!(
|
||||
"volumes[{index}].target must be an absolute container path without '..'"
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_volume_options(index: usize, options: &[String]) -> Result<(), ManifestError> {
|
||||
let allowed = ["rw", "ro", "z", "Z", "shared", "rshared", "slave", "rslave"];
|
||||
let mut seen = HashSet::new();
|
||||
for option in options {
|
||||
if !allowed.contains(&option.as_str()) {
|
||||
return Err(ManifestError::Invalid(format!(
|
||||
"volumes[{index}].options contains unsupported option '{option}'"
|
||||
)));
|
||||
}
|
||||
if !seen.insert(option.as_str()) {
|
||||
return Err(ManifestError::Invalid(format!(
|
||||
"volumes[{index}].options contains duplicate option '{option}'"
|
||||
)));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Host facts available to `derived_env` templates at apply time.
|
||||
///
|
||||
/// Mirrors the values `scripts/container-specs.sh:detect_environment()`
|
||||
@@ -864,6 +1128,38 @@ app:
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generated_files_must_live_under_bind_mounts() {
|
||||
let yaml = r#"
|
||||
app:
|
||||
id: test-app
|
||||
name: Test App
|
||||
version: 1.0.0
|
||||
container:
|
||||
image: test/image:latest
|
||||
volumes:
|
||||
- type: bind
|
||||
source: /var/lib/archipelago/test-app
|
||||
target: /data
|
||||
files:
|
||||
- path: /var/lib/archipelago/test-app/config.yaml
|
||||
content: |
|
||||
key: value
|
||||
"#;
|
||||
let manifest = AppManifest::parse(yaml).unwrap();
|
||||
assert_eq!(manifest.app.files.len(), 1);
|
||||
|
||||
let bad = yaml.replace(
|
||||
"/var/lib/archipelago/test-app/config.yaml",
|
||||
"/etc/test-app/config.yaml",
|
||||
);
|
||||
let err = AppManifest::parse(&bad).unwrap_err();
|
||||
assert!(
|
||||
format!("{err}").contains("bind-mounted volume source"),
|
||||
"unexpected error: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_custom_arg_is_rejected() {
|
||||
let yaml = r#"
|
||||
@@ -1089,6 +1385,157 @@ app:
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unsafe_manifest_values_are_rejected() {
|
||||
let cases = [
|
||||
(
|
||||
"bad app id",
|
||||
r#"
|
||||
app:
|
||||
id: Bad_App
|
||||
name: Bad
|
||||
version: 1.0.0
|
||||
container:
|
||||
image: test/image:latest
|
||||
"#,
|
||||
"app.id",
|
||||
),
|
||||
(
|
||||
"unsupported capability",
|
||||
r#"
|
||||
app:
|
||||
id: bad-cap
|
||||
name: Bad
|
||||
version: 1.0.0
|
||||
container:
|
||||
image: test/image:latest
|
||||
security:
|
||||
capabilities: [SYS_MODULE]
|
||||
"#,
|
||||
"unsupported capability",
|
||||
),
|
||||
(
|
||||
"docker socket bind",
|
||||
r#"
|
||||
app:
|
||||
id: bad-bind
|
||||
name: Bad
|
||||
version: 1.0.0
|
||||
container:
|
||||
image: test/image:latest
|
||||
volumes:
|
||||
- type: bind
|
||||
source: /var/run/docker.sock
|
||||
target: /var/run/docker.sock
|
||||
"#,
|
||||
"reviewed host-bind exception",
|
||||
),
|
||||
(
|
||||
"path-like relative bind source",
|
||||
r#"
|
||||
app:
|
||||
id: bad-bind
|
||||
name: Bad
|
||||
version: 1.0.0
|
||||
container:
|
||||
image: test/image:latest
|
||||
volumes:
|
||||
- type: bind
|
||||
source: data/cache
|
||||
target: /data
|
||||
"#,
|
||||
"absolute for host bind mounts",
|
||||
),
|
||||
(
|
||||
"bad environment key",
|
||||
r#"
|
||||
app:
|
||||
id: bad-env
|
||||
name: Bad
|
||||
version: 1.0.0
|
||||
container:
|
||||
image: test/image:latest
|
||||
environment:
|
||||
- 1BAD=value
|
||||
"#,
|
||||
"invalid key",
|
||||
),
|
||||
(
|
||||
"duplicate host port",
|
||||
r#"
|
||||
app:
|
||||
id: bad-port
|
||||
name: Bad
|
||||
version: 1.0.0
|
||||
container:
|
||||
image: test/image:latest
|
||||
ports:
|
||||
- { host: 8080, container: 80, protocol: tcp }
|
||||
- { host: 8080, container: 81, protocol: tcp }
|
||||
"#,
|
||||
"duplicate host binding",
|
||||
),
|
||||
(
|
||||
"bad device",
|
||||
r#"
|
||||
app:
|
||||
id: bad-device
|
||||
name: Bad
|
||||
version: 1.0.0
|
||||
container:
|
||||
image: test/image:latest
|
||||
devices:
|
||||
- /tmp/fake-device
|
||||
"#,
|
||||
"absolute /dev path",
|
||||
),
|
||||
(
|
||||
"container network namespace",
|
||||
r#"
|
||||
app:
|
||||
id: bad-network
|
||||
name: Bad
|
||||
version: 1.0.0
|
||||
container:
|
||||
image: test/image:latest
|
||||
network: container:host
|
||||
"#,
|
||||
"not allowed",
|
||||
),
|
||||
];
|
||||
|
||||
for (name, yaml, expected) in cases {
|
||||
let err = AppManifest::parse(yaml).unwrap_err();
|
||||
let msg = format!("{err}");
|
||||
assert!(
|
||||
msg.contains(expected),
|
||||
"case {name} expected '{expected}', got: {msg}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reviewed_host_bind_exceptions_parse() {
|
||||
let yaml = r#"
|
||||
app:
|
||||
id: reviewed-binds
|
||||
name: Reviewed Binds
|
||||
version: 1.0.0
|
||||
container:
|
||||
image: test/image:latest
|
||||
volumes:
|
||||
- type: bind
|
||||
source: /run/user/1000/podman/podman.sock
|
||||
target: /var/run/docker.sock
|
||||
options: [rw]
|
||||
- type: bind
|
||||
source: /var/run/dbus
|
||||
target: /var/run/dbus
|
||||
options: [ro]
|
||||
"#;
|
||||
AppManifest::parse(yaml).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_every_real_manifest() {
|
||||
let app_manifests = list_repo_manifests();
|
||||
@@ -1099,7 +1546,6 @@ app:
|
||||
|
||||
let mut failures: Vec<String> = Vec::new();
|
||||
let mut modern_count = 0usize;
|
||||
let mut legacy_count = 0usize;
|
||||
for path in app_manifests {
|
||||
let content = fs::read_to_string(&path).expect("read manifest");
|
||||
let parsed_yaml: serde_yaml::Value = match serde_yaml::from_str(&content) {
|
||||
@@ -1121,15 +1567,14 @@ app:
|
||||
failures.push(format!("{}: {err}", path.display()));
|
||||
}
|
||||
} else {
|
||||
legacy_count += 1;
|
||||
failures.push(format!(
|
||||
"{}: expected modern app-schema manifest",
|
||||
path.display()
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
assert!(modern_count > 0, "no modern app-schema manifests found");
|
||||
assert!(
|
||||
legacy_count > 0,
|
||||
"expected at least one legacy manifest shape"
|
||||
);
|
||||
|
||||
assert!(
|
||||
failures.is_empty(),
|
||||
|
||||
@@ -56,9 +56,9 @@ pub enum ContainerState {
|
||||
impl From<&str> for ContainerState {
|
||||
fn from(s: &str) -> Self {
|
||||
match s.to_lowercase().as_str() {
|
||||
"created" => ContainerState::Created,
|
||||
"created" | "initialized" => ContainerState::Created,
|
||||
"running" => ContainerState::Running,
|
||||
"stopping" => ContainerState::Stopping,
|
||||
"stopping" | "removing" => ContainerState::Stopping,
|
||||
"stopped" => ContainerState::Stopped,
|
||||
"exited" => ContainerState::Exited,
|
||||
"paused" => ContainerState::Paused,
|
||||
@@ -129,7 +129,6 @@ impl PodmanClient {
|
||||
"filebrowser" => "http://localhost:8083",
|
||||
"nginx-proxy-manager" => "http://localhost:8081",
|
||||
"portainer" => "http://localhost:9000",
|
||||
"saleor" => "http://localhost:9011",
|
||||
"uptime-kuma" => "http://localhost:3002",
|
||||
"fedimint" | "fedimintd" => "http://localhost:8175",
|
||||
"fedimint-gateway" => "http://localhost:8176",
|
||||
@@ -390,7 +389,7 @@ impl PodmanClient {
|
||||
"cap_add": cap_add,
|
||||
"cap_drop": cap_drop,
|
||||
"read_only_filesystem": manifest.app.security.readonly_root,
|
||||
"no_new_privileges": true,
|
||||
"no_new_privileges": manifest.app.security.no_new_privileges,
|
||||
"restart_policy": "unless-stopped",
|
||||
"restart_tries": 5,
|
||||
"netns": {
|
||||
@@ -635,6 +634,7 @@ fn podman_network_settings(
|
||||
Some("bridge") => ("bridge", None),
|
||||
Some("none") => ("none", None),
|
||||
Some("slirp4netns") => ("slirp4netns", None),
|
||||
Some("pasta") => ("pasta", None),
|
||||
Some("private") => ("private", None),
|
||||
Some(custom) => ("bridge", Some(custom.to_string())),
|
||||
None if network_policy == "host" => ("host", None),
|
||||
|
||||
@@ -7,6 +7,7 @@ use std::time::Duration;
|
||||
use tokio::process::Command as TokioCommand;
|
||||
|
||||
const PODMAN_CLI_DEFAULT_TIMEOUT: Duration = Duration::from_secs(30);
|
||||
const PODMAN_CLI_IMAGE_CHECK_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
const PODMAN_CLI_BUILD_TIMEOUT: Duration = Duration::from_secs(900);
|
||||
|
||||
#[async_trait]
|
||||
@@ -150,7 +151,25 @@ impl ContainerRuntime for PodmanRuntime {
|
||||
if is_missing_container_error(&stderr) {
|
||||
return Ok(());
|
||||
}
|
||||
Err(api_err.context(format!("podman rm fallback failed: {}", stderr.trim())))
|
||||
let zero_timeout = self.podman_cli(&["rm", "-f", "--time", "0", name]).await?;
|
||||
if zero_timeout.status.success() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let _ = self.podman_cli(&["container", "cleanup", name]).await;
|
||||
let cleanup_rm = self.podman_cli(&["rm", "-f", name]).await?;
|
||||
if cleanup_rm.status.success() {
|
||||
return Ok(());
|
||||
}
|
||||
let cleanup_stderr = String::from_utf8_lossy(&cleanup_rm.stderr);
|
||||
if is_missing_container_error(&cleanup_stderr) {
|
||||
return Ok(());
|
||||
}
|
||||
Err(api_err.context(format!(
|
||||
"podman rm fallback failed: {}; cleanup rm failed: {}",
|
||||
stderr.trim(),
|
||||
cleanup_stderr.trim()
|
||||
)))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -196,20 +215,26 @@ impl ContainerRuntime for PodmanRuntime {
|
||||
}
|
||||
|
||||
async fn image_exists(&self, image_ref: &str) -> Result<bool> {
|
||||
// `podman image exists` returns 0 if present, 1 if absent. Any other
|
||||
// exit code is an environment failure we should surface.
|
||||
let output = self.podman_cli(&["image", "exists", image_ref]).await?;
|
||||
// Avoid `podman image exists`: on production nodes with a stressed
|
||||
// rootless store it can hang even when targeted at one image. A bounded
|
||||
// inspect is the local-storage probe the trait contract describes.
|
||||
let output = self
|
||||
.podman_cli_timeout(
|
||||
&["image", "inspect", image_ref],
|
||||
PODMAN_CLI_IMAGE_CHECK_TIMEOUT,
|
||||
)
|
||||
.await?;
|
||||
match output.status.code() {
|
||||
Some(0) => Ok(true),
|
||||
Some(1) => Ok(false),
|
||||
Some(code) => {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
Err(anyhow::anyhow!(
|
||||
"podman image exists {image_ref} exited with {code}: {stderr}"
|
||||
"podman image inspect {image_ref} exited with {code}: {stderr}"
|
||||
))
|
||||
}
|
||||
None => Err(anyhow::anyhow!(
|
||||
"podman image exists {image_ref} terminated by signal"
|
||||
"podman image inspect {image_ref} terminated by signal"
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user