use std::fmt;
#[derive(Debug)]
#[non_exhaustive]
pub enum ComposeError {
Parse(serde_yaml::Error),
FileNotFound(String),
Io(std::io::Error),
Podman(crate::libpod::PodmanError),
ServiceNotFound(String),
CircularDependency(String),
NoImageOrBuild(String),
RequiredVarNotSet { var: String, msg: String },
InvalidSubstitution(String),
HealthCheckTimeout(String),
InvalidPort(String),
InvalidSignal(String),
Build(String),
Copy(String),
Extends(String),
Include(String),
Watch(String),
Unsupported(String),
RunExited(i64),
Update(String),
ExternalNotFound(String),
ScalePortConflict {
service: String,
replicas: usize,
ports: Vec<u16>,
},
WaitServiceExited { container: String, code: i64 },
ReplicaLimitExceeded {
service: String,
replicas: usize,
max: u32,
},
WaitTimeout { secs: u64 },
ReplicaIndex { service: String, index: u32 },
IoPath {
path: String,
source: std::io::Error,
},
BuildContext {
service: String,
path: String,
source: std::io::Error,
},
NotRunning(String),
ExecFailed(String),
InvalidTimeout(i32),
EnvFile(String),
}
fn sanitize_name(s: &str) -> std::borrow::Cow<'_, str> {
if s.chars().any(char::is_control) {
s.chars()
.flat_map(|c| {
if c.is_control() {
c.escape_default().collect::<Vec<_>>()
} else {
vec![c]
}
})
.collect::<String>()
.into()
} else {
std::borrow::Cow::Borrowed(s)
}
}
impl fmt::Display for ComposeError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Parse(e) => match e.location() {
Some(loc) => write!(
f,
"failed to parse compose file at line {}, column {}",
loc.line(),
loc.column()
),
None => write!(f, "failed to parse compose file"),
},
Self::FileNotFound(s) => write!(f, "compose file not found: {}", sanitize_name(s)),
Self::Io(e) => write!(f, "io error: {e}"),
Self::Podman(e) => write!(f, "podman error: {e}"),
Self::ServiceNotFound(s) => write!(f, "service '{}' not found", sanitize_name(s)),
Self::CircularDependency(s) => write!(f, "{s}"),
Self::NoImageOrBuild(s) => {
write!(
f,
"service '{}' has no image or build config",
sanitize_name(s)
)
}
Self::RequiredVarNotSet { var, msg } => {
write!(f, "required variable '{var}' is not set: {msg}")
}
Self::InvalidSubstitution(s) => {
write!(f, "invalid variable substitution: {s}")
}
Self::HealthCheckTimeout(s) => {
write!(
f,
"health check timeout for container '{}'",
sanitize_name(s)
)
}
Self::InvalidPort(s) => write!(f, "invalid port mapping: {s}"),
Self::InvalidSignal(s) => write!(f, "invalid signal: {s}"),
Self::Build(s) => write!(f, "build error: {s}"),
Self::Copy(s) => write!(f, "cp error: {s}"),
Self::Extends(s) => write!(f, "extends error: {s}"),
Self::Include(s) => write!(f, "include error: {s}"),
Self::Watch(s) => write!(f, "watch error: {s}"),
Self::Unsupported(s) => write!(f, "unsupported feature: {s}"),
Self::RunExited(code) => write!(f, "run container exited with code {code}"),
Self::Update(s) => write!(f, "update error: {s}"),
Self::ExternalNotFound(s) => write!(f, "external resource not found: {s}"),
Self::ScalePortConflict {
service,
replicas,
ports,
} => {
let ports = ports
.iter()
.map(u16::to_string)
.collect::<Vec<_>>()
.join(", ");
write!(
f,
"service '{service}' publishes fixed host port(s) [{ports}] but is scaled to \
{replicas} replicas; only one container can bind a host port. Use one of:\n \
- remove the host port (e.g. `- \"80\"`) so Podman assigns a random one per \
replica\n - put the service behind a reverse proxy and publish only the \
proxy's port\n - reduce the service to a single replica"
)
}
Self::WaitServiceExited { container, code } => write!(
f,
"container '{}' exited with code {code} while waiting for it to be ready",
sanitize_name(container)
),
Self::ReplicaLimitExceeded {
service,
replicas,
max,
} => write!(
f,
"service '{service}' requests {replicas} replicas, which exceeds the limit of \
{max}; lower the count or raise the limit with PODUP_MAX_REPLICAS"
),
Self::WaitTimeout { secs } => write!(
f,
"timed out after {secs}s waiting for services to become healthy"
),
Self::ReplicaIndex { service, index } => write!(
f,
"service '{}' has no replica {index} (replica indexes are 1-based)",
sanitize_name(service)
),
Self::IoPath { path, source } => {
write!(f, "io error: {}: {source}", sanitize_name(path))
}
Self::BuildContext {
service,
path,
source,
} => write!(
f,
"build context '{}' for service '{}': {source}",
sanitize_name(path),
sanitize_name(service)
),
Self::NotRunning(s) => write!(f, "service '{}' is not running", sanitize_name(s)),
Self::ExecFailed(s) => write!(f, "exec failed: {s}"),
Self::InvalidTimeout(secs) => write!(
f,
"invalid --timeout {secs}: use -1 to wait indefinitely or a non-negative number of seconds"
),
Self::EnvFile(s) => write!(f, "{s}"),
}
}
}
impl std::error::Error for ComposeError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Self::Parse(e) => Some(e),
Self::Io(e) => Some(e),
Self::Podman(e) => Some(e),
Self::IoPath { source, .. } | Self::BuildContext { source, .. } => Some(source),
_ => None,
}
}
}
impl From<serde_yaml::Error> for ComposeError {
fn from(e: serde_yaml::Error) -> Self {
Self::Parse(e)
}
}
impl From<std::io::Error> for ComposeError {
fn from(e: std::io::Error) -> Self {
Self::Io(e)
}
}
impl From<crate::libpod::PodmanError> for ComposeError {
fn from(e: crate::libpod::PodmanError) -> Self {
Self::Podman(e)
}
}
pub type Result<T> = std::result::Result<T, ComposeError>;
#[cfg(test)]
mod tests {
use super::ComposeError;
#[test]
fn display_covers_all_variants() {
let cases: &[(&str, ComposeError)] = &[
(
"failed to parse compose file",
ComposeError::Parse(serde_yaml::from_str::<serde_yaml::Value>(":\0").unwrap_err()),
),
(
"compose file not found: f",
ComposeError::FileNotFound("f".into()),
),
("io error:", ComposeError::Io(std::io::Error::other("x"))),
(
"service 's' not found",
ComposeError::ServiceNotFound("s".into()),
),
("c", ComposeError::CircularDependency("c".into())),
(
"service 'svc' has no image or build config",
ComposeError::NoImageOrBuild("svc".into()),
),
(
"required variable 'V' is not set: reason",
ComposeError::RequiredVarNotSet {
var: "V".into(),
msg: "reason".into(),
},
),
(
"health check timeout for container 'c'",
ComposeError::HealthCheckTimeout("c".into()),
),
(
"invalid port mapping: p",
ComposeError::InvalidPort("p".into()),
),
(
"podman error:",
ComposeError::Podman(crate::libpod::PodmanError::Api {
status: 500,
message: "boom".into(),
}),
),
(
"invalid variable substitution: bad",
ComposeError::InvalidSubstitution("bad".into()),
),
("build error: b", ComposeError::Build("b".into())),
("cp error: c", ComposeError::Copy("c".into())),
("extends error: e", ComposeError::Extends("e".into())),
("include error: i", ComposeError::Include("i".into())),
("watch error: w", ComposeError::Watch("w".into())),
(
"unsupported feature: u",
ComposeError::Unsupported("u".into()),
),
(
"run container exited with code 1",
ComposeError::RunExited(1),
),
("update error: u", ComposeError::Update("u".into())),
(
"external resource not found: external volume \"v\" does not exist",
ComposeError::ExternalNotFound("external volume \"v\" does not exist".into()),
),
(
"service 'web' publishes fixed host port(s) [8080] but is scaled to 3 replicas",
ComposeError::ScalePortConflict {
service: "web".into(),
replicas: 3,
ports: vec![8080],
},
),
(
"container 'web' exited with code 7 while waiting for it to be ready",
ComposeError::WaitServiceExited {
container: "web".into(),
code: 7,
},
),
(
"service 'web' requests 100000 replicas, which exceeds the limit of 256",
ComposeError::ReplicaLimitExceeded {
service: "web".into(),
replicas: 100_000,
max: 256,
},
),
(
"timed out after 30s waiting for services to become healthy",
ComposeError::WaitTimeout { secs: 30 },
),
(
"service 'web' has no replica 99 (replica indexes are 1-based)",
ComposeError::ReplicaIndex {
service: "web".into(),
index: 99,
},
),
(
"io error: /out/x.tar:",
ComposeError::IoPath {
path: "/out/x.tar".into(),
source: std::io::Error::other("boom"),
},
),
(
"build context './ctx' for service 'web':",
ComposeError::BuildContext {
service: "web".into(),
path: "./ctx".into(),
source: std::io::Error::other("boom"),
},
),
(
"service 'web' is not running",
ComposeError::NotRunning("web".into()),
),
(
"exec failed: the exec session did not start within 20s",
ComposeError::ExecFailed("the exec session did not start within 20s".into()),
),
(
"invalid --timeout -5: use -1 to wait indefinitely or a non-negative number of seconds",
ComposeError::InvalidTimeout(-5),
),
(
"env file not found: app.env",
ComposeError::EnvFile("env file not found: app.env".into()),
),
];
for (expected_prefix, err) in cases {
let msg = err.to_string();
assert!(
msg.starts_with(expected_prefix),
"Display for {:?}: got {msg:?}, expected prefix {expected_prefix:?}",
std::mem::discriminant(err),
);
}
}
#[test]
fn parse_display_does_not_echo_offending_scalar() {
#[derive(Debug, serde::Deserialize)]
struct OnlyMap {
#[allow(dead_code)]
services: std::collections::BTreeMap<String, String>,
}
let err = serde_yaml::from_str::<OnlyMap>("services: s3cr3t-token\n").unwrap_err();
let msg = ComposeError::Parse(err).to_string();
assert!(
!msg.contains("s3cr3t-token"),
"parse error must not echo file content, got {msg:?}"
);
assert!(msg.starts_with("failed to parse compose file"));
}
#[test]
fn source_provided_for_wrapped_variants() {
use std::error::Error;
let io = ComposeError::Io(std::io::Error::other("x"));
assert!(io.source().is_some());
let parse =
ComposeError::Parse(serde_yaml::from_str::<serde_yaml::Value>(":\0").unwrap_err());
assert!(parse.source().is_some());
let podman = ComposeError::Podman(crate::libpod::PodmanError::Api {
status: 500,
message: "boom".into(),
});
assert!(podman.source().is_some());
let svc = ComposeError::ServiceNotFound("s".into());
assert!(svc.source().is_none());
}
#[test]
fn service_name_control_chars_are_escaped_in_display() {
let err = ComposeError::ServiceNotFound("we\x1b[31mb\n".into());
let msg = err.to_string();
assert!(!msg.contains('\x1b'), "ESC must be escaped: {msg:?}");
assert!(!msg.contains('\n'), "newline must be escaped: {msg:?}");
assert!(
msg.contains("\\u{1b}") && msg.contains("\\n"),
"got {msg:?}"
);
}
#[test]
fn replica_index_hint_is_outside_the_quoted_name() {
let err = ComposeError::ReplicaIndex {
service: "web".into(),
index: 0,
};
let msg = err.to_string();
assert!(msg.contains("'web'"), "service name stays clean: {msg:?}");
assert!(!msg.contains("'web "), "hint leaked into the name: {msg:?}");
assert!(msg.contains("1-based"));
}
#[test]
fn from_impls_convert_correctly() {
let io_err = std::io::Error::other("x");
let e: ComposeError = io_err.into();
assert!(matches!(e, ComposeError::Io(_)));
let yaml_err = serde_yaml::from_str::<serde_yaml::Value>(":\0").unwrap_err();
let e: ComposeError = yaml_err.into();
assert!(matches!(e, ComposeError::Parse(_)));
let podman_err = crate::libpod::PodmanError::Api {
status: 404,
message: "not found".into(),
};
let e: ComposeError = podman_err.into();
assert!(matches!(e, ComposeError::Podman(_)));
}
}