use std::{collections::BTreeSet, error::Error, fmt};
use crate::{
model::{
ComposeDocument, MemLimitUnit, ShmSizeUnit, StopGracePeriod, valid_generated_device_string,
valid_generated_expose_item, valid_generated_mem_amount, valid_generated_shm_amount,
valid_generated_tmpfs_item, valid_hostname, valid_positive_pids_decimal, valid_pull_policy_duration,
valid_ulimit_name,
},
source::SourceId,
syntax::SyntaxDocument,
};
use super::write_quoted;
#[derive(Clone, Debug, Eq, PartialEq)]
#[non_exhaustive]
pub enum GenerationError {
EmptyValue(&'static str),
ContainsNul(&'static str),
ContainsLineBreak(&'static str),
InvalidEnvironmentName,
InvalidContainerName,
InvalidHostname,
InvalidPullPolicyDuration,
InvalidPidsLimit,
InvalidShmSize,
InvalidMemLimit,
InvalidDnsValue,
InvalidDnsOptionValue,
InvalidDnsSearchValue,
InvalidExposeValue,
InvalidSecurityOptionValue,
InvalidAnnotationName,
InvalidAnnotationValue,
InvalidTmpfsItem,
InvalidDeviceValue(&'static str),
InvalidSysctlName,
InvalidSysctlValue,
InvalidUlimitName,
InvalidUlimitValue,
MissingUlimitRangeMember(&'static str),
InvalidStopGracePeriod,
InvalidShortComponent(&'static str),
InvalidSelinuxBind,
DuplicateField(&'static str),
DuplicateName {
kind: &'static str,
name: String,
},
DuplicateItem(&'static str),
InvalidPort,
UnrepresentableSctpHostIp,
MissingService,
InternalInvariant(&'static str),
}
impl fmt::Display for GenerationError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::EmptyValue(kind) => write!(formatter, "generated {kind} must not be empty"),
Self::ContainsNul(kind) => write!(formatter, "generated {kind} must not contain a NUL byte"),
Self::ContainsLineBreak(kind) => {
write!(formatter, "generated {kind} must not contain a carriage return or line feed")
}
Self::InvalidEnvironmentName => formatter.write_str("generated environment name must not contain `=`"),
Self::InvalidContainerName => {
formatter.write_str("generated container name must match `[a-zA-Z0-9][a-zA-Z0-9_.-]+`")
}
Self::InvalidHostname => formatter.write_str(
"generated hostname must be a resolved ASCII RFC-1123 name with labels of 1 to 63 characters and total length at most 253",
),
Self::InvalidPullPolicyDuration => formatter.write_str(
"generated pull policy duration must match integer `w`, `d`, `h`, `m`, and `s` components",
),
Self::InvalidPidsLimit => {
formatter.write_str("generated finite PID limit must be a positive integral decimal")
}
Self::InvalidShmSize => formatter.write_str(
"generated shared-memory size must use a canonical positive ASCII-integer amount and an explicit documented lowercase unit",
),
Self::InvalidMemLimit => formatter.write_str(
"generated memory limit must use a canonical positive ASCII-integer amount and an explicit documented lowercase unit",
),
Self::InvalidDnsValue => {
formatter.write_str("generated DNS server must be a non-empty resolved single-line string")
}
Self::InvalidDnsOptionValue => {
formatter.write_str("generated DNS option must be a non-empty resolved single-line string")
}
Self::InvalidDnsSearchValue => {
formatter.write_str("generated DNS search domain must be a non-empty resolved single-line string")
}
Self::InvalidExposeValue => formatter.write_str(
"generated expose item must be a resolved decimal port or range with an optional `tcp` or `udp` suffix",
),
Self::InvalidSecurityOptionValue => {
formatter.write_str("generated security option must be a non-empty resolved single-line string")
}
Self::InvalidAnnotationName => formatter
.write_str("generated annotation name must be a non-empty resolved single-line string"),
Self::InvalidAnnotationValue => formatter
.write_str("generated annotation value must be a resolved single-line string"),
Self::InvalidTmpfsItem => formatter.write_str(
"generated tmpfs item must be a non-empty path optionally followed by a colon and non-empty comma-separated raw options",
),
Self::InvalidDeviceValue(member) => write!(
formatter,
"generated device {member} must be a safe resolved single-line string{}",
if matches!(*member, "short item" | "source") {
" and must not be empty"
} else {
""
}
),
Self::InvalidSysctlName => formatter
.write_str("generated sysctl name must be a non-empty resolved single-line string"),
Self::InvalidSysctlValue => formatter
.write_str("generated sysctl value must be a resolved single-line string"),
Self::InvalidUlimitName => formatter
.write_str("generated ulimit name must match lowercase ASCII `[a-z]+`"),
Self::InvalidUlimitValue => formatter
.write_str("generated ulimit value must be `-1` or a non-negative ASCII decimal"),
Self::MissingUlimitRangeMember(member) => {
write!(formatter, "generated ulimit range is missing required `{member}`")
}
Self::InvalidStopGracePeriod => formatter.write_str(
"generated stop grace period must match the ComposeLens duration policy using `us`, `ms`, `s`, `m`, or `h`, or contain an interpolation marker",
),
Self::InvalidShortComponent(kind) => {
write!(formatter, "generated {kind} contains its reserved short-form separator")
}
Self::InvalidSelinuxBind => formatter
.write_str("generated SELinux bind source and target must not contain the short-syntax `:` separator"),
Self::DuplicateField(field) => write!(formatter, "generated field `{field}` was configured more than once"),
Self::DuplicateName { kind, name } => {
write!(formatter, "generated {kind} `{name}` was added more than once")
}
Self::DuplicateItem(kind) => write!(formatter, "generated {kind} contains an exact duplicate item"),
Self::InvalidPort => formatter.write_str("generated container target port must be greater than zero"),
Self::UnrepresentableSctpHostIp => formatter.write_str(
"generated SCTP port with a host address also requires a published port for Compose short syntax",
),
Self::MissingService => formatter.write_str("generated Compose project requires at least one service"),
Self::InternalInvariant(stage) => write!(formatter, "generated Compose document failed {stage} validation"),
}
}
}
impl Error for GenerationError {}
#[derive(Clone, Eq, PartialEq)]
pub struct GeneratedString {
value: String,
sensitive: bool,
}
impl GeneratedString {
pub fn plain(value: impl Into<String>) -> Result<Self, GenerationError> {
Self::new(value.into(), false)
}
pub fn sensitive(value: impl Into<String>) -> Result<Self, GenerationError> {
Self::new(value.into(), true)
}
fn new(value: String, sensitive: bool) -> Result<Self, GenerationError> {
if value.contains('\0') {
return Err(GenerationError::ContainsNul("string"));
}
Ok(Self { value, sensitive })
}
#[must_use]
pub fn expose(&self) -> &str {
&self.value
}
#[must_use]
pub const fn is_sensitive(&self) -> bool {
self.sensitive
}
}
impl fmt::Debug for GeneratedString {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("GeneratedString")
.field("value", &if self.sensitive { "<redacted>" } else { &self.value })
.field("sensitive", &self.sensitive)
.finish()
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
#[non_exhaustive]
pub enum GeneratedCommand {
Exec(Vec<GeneratedString>),
Shell(GeneratedString),
Empty,
}
#[derive(Clone, Debug, Eq, PartialEq)]
#[non_exhaustive]
pub enum GeneratedEntrypoint {
List(Vec<GeneratedString>),
String(GeneratedString),
Empty,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
#[non_exhaustive]
pub enum GeneratedRestartPolicy {
No,
Always,
OnFailure {
maximum_retries: Option<u64>,
},
UnlessStopped,
}
#[derive(Clone, Debug, Eq, PartialEq)]
#[non_exhaustive]
pub enum GeneratedPullPolicy {
Always,
Never,
Missing,
IfNotPresentAlias,
Build,
Daily,
Weekly,
Every(GeneratedString),
}
#[derive(Clone, Debug, Eq, PartialEq)]
#[non_exhaustive]
pub enum GeneratedPidsLimit {
Unlimited,
Finite(String),
}
#[derive(Clone, Debug, Eq, PartialEq)]
#[non_exhaustive]
pub enum GeneratedShmSize {
Explicit {
amount: GeneratedString,
unit: ShmSizeUnit,
},
}
#[derive(Clone, Debug, Eq, PartialEq)]
#[non_exhaustive]
pub enum GeneratedMemLimit {
Explicit {
amount: GeneratedString,
unit: MemLimitUnit,
},
}
#[derive(Clone, Debug, Eq, PartialEq)]
#[non_exhaustive]
pub enum GeneratedTmpfs {
Scalar(GeneratedString),
List(Vec<GeneratedString>),
}
#[derive(Clone, Debug, Eq, PartialEq)]
#[non_exhaustive]
pub enum GeneratedDns {
Scalar(GeneratedString),
List(Vec<GeneratedString>),
}
#[derive(Clone, Debug, Eq, PartialEq)]
#[non_exhaustive]
pub enum GeneratedDnsSearch {
Scalar(GeneratedString),
List(Vec<GeneratedString>),
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct GeneratedLongDevice {
source: GeneratedString,
target: Option<GeneratedString>,
permissions: Option<GeneratedString>,
}
impl GeneratedLongDevice {
pub fn new(
source: GeneratedString,
target: Option<GeneratedString>,
permissions: Option<GeneratedString>,
) -> Result<Self, GenerationError> {
validate_generated_device_member("source", &source, true)?;
if let Some(target) = &target {
validate_generated_device_member("target", target, false)?;
}
if let Some(permissions) = &permissions {
validate_generated_device_member("permissions", permissions, false)?;
}
Ok(Self {
source,
target,
permissions,
})
}
#[must_use]
pub const fn source(&self) -> &GeneratedString {
&self.source
}
#[must_use]
pub const fn target(&self) -> Option<&GeneratedString> {
self.target.as_ref()
}
#[must_use]
pub const fn permissions(&self) -> Option<&GeneratedString> {
self.permissions.as_ref()
}
fn is_sensitive(&self) -> bool {
self.source.is_sensitive()
|| self.target.as_ref().is_some_and(GeneratedString::is_sensitive)
|| self.permissions.as_ref().is_some_and(GeneratedString::is_sensitive)
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
#[non_exhaustive]
pub enum GeneratedDevice {
Short(GeneratedString),
Long(GeneratedLongDevice),
}
impl GeneratedDevice {
fn is_sensitive(&self) -> bool {
match self {
Self::Short(value) => value.is_sensitive(),
Self::Long(value) => value.is_sensitive(),
}
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct GeneratedSysctl {
name: String,
value: GeneratedString,
}
impl GeneratedSysctl {
pub fn new(name: impl Into<String>, value: GeneratedString) -> Result<Self, GenerationError> {
let name = name.into();
if name.is_empty()
|| name.contains(['\0', '\r', '\n'])
|| name.contains('$')
|| value.expose().contains(['\r', '\n', '$'])
{
return Err(if name.is_empty() || name.contains(['\0', '\r', '\n', '$']) {
GenerationError::InvalidSysctlName
} else {
GenerationError::InvalidSysctlValue
});
}
Ok(Self { name, value })
}
#[must_use]
pub fn name(&self) -> &str {
&self.name
}
#[must_use]
pub const fn value(&self) -> &GeneratedString {
&self.value
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
#[non_exhaustive]
pub enum GeneratedSysctls {
Map(Vec<GeneratedSysctl>),
List(Vec<GeneratedString>),
}
#[derive(Clone, Debug, Eq, PartialEq)]
#[non_exhaustive]
pub enum GeneratedUlimitValue {
Single(GeneratedString),
Range {
soft: Option<GeneratedString>,
hard: Option<GeneratedString>,
},
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct GeneratedUlimit {
name: String,
value: GeneratedUlimitValue,
}
impl GeneratedUlimit {
pub fn new(name: impl Into<String>, value: GeneratedUlimitValue) -> Result<Self, GenerationError> {
let name = name.into();
if !valid_ulimit_name(&name) {
return Err(GenerationError::InvalidUlimitName);
}
match &value {
GeneratedUlimitValue::Single(value) => validate_generated_ulimit_value(value)?,
GeneratedUlimitValue::Range { soft, hard } => {
let soft = soft.as_ref().ok_or(GenerationError::MissingUlimitRangeMember("soft"))?;
let hard = hard.as_ref().ok_or(GenerationError::MissingUlimitRangeMember("hard"))?;
validate_generated_ulimit_value(soft)?;
validate_generated_ulimit_value(hard)?;
}
}
Ok(Self { name, value })
}
pub fn single(name: impl Into<String>, value: GeneratedString) -> Result<Self, GenerationError> {
Self::new(name, GeneratedUlimitValue::Single(value))
}
pub fn range(
name: impl Into<String>,
soft: GeneratedString,
hard: GeneratedString,
) -> Result<Self, GenerationError> {
Self::new(
name,
GeneratedUlimitValue::Range {
soft: Some(soft),
hard: Some(hard),
},
)
}
#[must_use]
pub fn name(&self) -> &str {
&self.name
}
#[must_use]
pub const fn value(&self) -> &GeneratedUlimitValue {
&self.value
}
fn is_sensitive(&self) -> bool {
match &self.value {
GeneratedUlimitValue::Single(value) => value.is_sensitive(),
GeneratedUlimitValue::Range { soft, hard } => {
soft.iter().chain(hard.iter()).any(GeneratedString::is_sensitive)
}
}
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct GeneratedUlimits {
entries: Vec<GeneratedUlimit>,
}
impl GeneratedUlimits {
pub fn new(entries: Vec<GeneratedUlimit>) -> Result<Self, GenerationError> {
let mut seen = BTreeSet::new();
for entry in &entries {
if !seen.insert(entry.name()) {
return Err(GenerationError::DuplicateName {
kind: "ulimit",
name: entry.name().to_owned(),
});
}
}
Ok(Self { entries })
}
#[must_use]
pub fn entries(&self) -> &[GeneratedUlimit] {
&self.entries
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
#[non_exhaustive]
pub enum GeneratedHostname {
Resolved(GeneratedString),
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct GeneratedEnvironment {
name: String,
value: Option<GeneratedString>,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
#[non_exhaustive]
pub enum GeneratedEnvironmentFileFormat {
Raw,
}
#[derive(Clone, Debug, Eq, PartialEq)]
#[non_exhaustive]
pub enum GeneratedEnvironmentFile {
Short(GeneratedString),
Long {
path: GeneratedString,
required: Option<bool>,
format: Option<GeneratedEnvironmentFileFormat>,
},
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct GeneratedLabel {
name: String,
value: GeneratedString,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct GeneratedAnnotation {
name: String,
value: GeneratedString,
}
impl GeneratedAnnotation {
pub fn new(name: impl Into<String>, value: GeneratedString) -> Result<Self, GenerationError> {
let name = name.into();
if name.is_empty() || name.contains(['$', '\r', '\n', '\0']) {
return Err(GenerationError::InvalidAnnotationName);
}
if value.expose().contains(['$', '\r', '\n', '\0']) {
return Err(GenerationError::InvalidAnnotationValue);
}
Ok(Self { name, value })
}
#[must_use]
pub fn name(&self) -> &str {
&self.name
}
#[must_use]
pub const fn value(&self) -> &GeneratedString {
&self.value
}
}
impl GeneratedLabel {
pub fn new(name: impl Into<String>, value: GeneratedString) -> Result<Self, GenerationError> {
Ok(Self {
name: required("label name", name.into())?,
value,
})
}
#[must_use]
pub fn name(&self) -> &str {
&self.name
}
#[must_use]
pub const fn value(&self) -> &GeneratedString {
&self.value
}
}
impl GeneratedEnvironment {
pub fn literal(name: impl Into<String>, value: GeneratedString) -> Result<Self, GenerationError> {
Ok(Self {
name: environment_name(name.into())?,
value: Some(value),
})
}
pub fn host(name: impl Into<String>) -> Result<Self, GenerationError> {
Ok(Self {
name: environment_name(name.into())?,
value: None,
})
}
#[must_use]
pub fn name(&self) -> &str {
&self.name
}
#[must_use]
pub const fn value(&self) -> Option<&GeneratedString> {
self.value.as_ref()
}
}
impl GeneratedEnvironmentFile {
pub fn short(path: GeneratedString) -> Result<Self, GenerationError> {
require_generated_string("environment-file path", &path)?;
Ok(Self::Short(path))
}
pub fn long(
path: GeneratedString,
required: Option<bool>,
format: Option<GeneratedEnvironmentFileFormat>,
) -> Result<Self, GenerationError> {
require_generated_string("environment-file path", &path)?;
Ok(Self::Long { path, required, format })
}
#[must_use]
pub const fn path(&self) -> &GeneratedString {
match self {
Self::Short(path) | Self::Long { path, .. } => path,
}
}
#[must_use]
pub const fn required(&self) -> Option<bool> {
match self {
Self::Short(_) => None,
Self::Long { required, .. } => *required,
}
}
#[must_use]
pub const fn format(&self) -> Option<GeneratedEnvironmentFileFormat> {
match self {
Self::Short(_) => None,
Self::Long { format, .. } => *format,
}
}
#[must_use]
pub const fn is_sensitive(&self) -> bool {
self.path().is_sensitive()
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct GeneratedExtraHost {
hostname: String,
address: String,
}
impl GeneratedExtraHost {
pub fn new(hostname: impl Into<String>, address: impl Into<String>) -> Result<Self, GenerationError> {
let hostname = short_component("extra-host hostname", hostname.into(), '=')?;
let address = short_component("extra-host address", address.into(), '=')?;
Ok(Self { hostname, address })
}
#[must_use]
pub fn hostname(&self) -> &str {
&self.hostname
}
#[must_use]
pub fn address(&self) -> &str {
&self.address
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
#[non_exhaustive]
pub enum GeneratedProtocol {
Tcp,
Udp,
Sctp,
}
impl GeneratedProtocol {
const fn as_str(self) -> &'static str {
match self {
Self::Tcp => "tcp",
Self::Udp => "udp",
Self::Sctp => "sctp",
}
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct GeneratedPort {
target: u16,
published: Option<u16>,
host_ip: Option<String>,
protocol: GeneratedProtocol,
}
impl GeneratedPort {
pub fn new(
target: u16,
published: Option<u16>,
host_ip: Option<String>,
protocol: GeneratedProtocol,
) -> Result<Self, GenerationError> {
if target == 0 {
return Err(GenerationError::InvalidPort);
}
if let Some(host_ip) = host_ip.as_deref() {
required("port host address", host_ip.to_owned())?;
if protocol == GeneratedProtocol::Sctp && published.is_none() {
return Err(GenerationError::UnrepresentableSctpHostIp);
}
}
Ok(Self {
target,
published,
host_ip,
protocol,
})
}
#[must_use]
pub const fn target(&self) -> u16 {
self.target
}
#[must_use]
pub const fn published(&self) -> Option<u16> {
self.published
}
#[must_use]
pub fn host_ip(&self) -> Option<&str> {
self.host_ip.as_deref()
}
#[must_use]
pub const fn protocol(&self) -> GeneratedProtocol {
self.protocol
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
#[non_exhaustive]
pub enum GeneratedSelinux {
Private,
Shared,
}
impl GeneratedSelinux {
const fn as_str(self) -> &'static str {
match self {
Self::Private => "Z",
Self::Shared => "z",
}
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
enum GeneratedMountKind {
Volume {
source: String,
},
Bind {
source: String,
selinux: Option<GeneratedSelinux>,
},
Anonymous,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct GeneratedMount {
kind: GeneratedMountKind,
target: String,
read_only: bool,
}
impl GeneratedMount {
pub fn volume(
source: impl Into<String>,
target: impl Into<String>,
read_only: bool,
) -> Result<Self, GenerationError> {
Ok(Self {
kind: GeneratedMountKind::Volume {
source: required("volume source", source.into())?,
},
target: required("mount target", target.into())?,
read_only,
})
}
pub fn bind(
source: impl Into<String>,
target: impl Into<String>,
read_only: bool,
selinux: Option<GeneratedSelinux>,
) -> Result<Self, GenerationError> {
let source = required("bind source", source.into())?;
let target = required("mount target", target.into())?;
if selinux.is_some() && (source.contains(':') || target.contains(':')) {
return Err(GenerationError::InvalidSelinuxBind);
}
Ok(Self {
kind: GeneratedMountKind::Bind { source, selinux },
target,
read_only,
})
}
pub fn anonymous(target: impl Into<String>, read_only: bool) -> Result<Self, GenerationError> {
Ok(Self {
kind: GeneratedMountKind::Anonymous,
target: required("mount target", target.into())?,
read_only,
})
}
#[must_use]
pub fn target(&self) -> &str {
&self.target
}
#[must_use]
pub const fn read_only(&self) -> bool {
self.read_only
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct GeneratedNetworkAttachment {
name: String,
aliases: Vec<String>,
}
impl GeneratedNetworkAttachment {
pub fn new(name: impl Into<String>) -> Result<Self, GenerationError> {
Ok(Self {
name: required("network name", name.into())?,
aliases: Vec::new(),
})
}
pub fn add_alias(&mut self, alias: impl Into<String>) -> Result<(), GenerationError> {
self.aliases.push(required("network alias", alias.into())?);
Ok(())
}
#[must_use]
pub fn name(&self) -> &str {
&self.name
}
#[must_use]
pub fn aliases(&self) -> &[String] {
&self.aliases
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct GeneratedResource {
name: String,
external: bool,
custom_name: Option<String>,
}
impl GeneratedResource {
pub fn application(name: impl Into<String>) -> Result<Self, GenerationError> {
Ok(Self {
name: required("resource name", name.into())?,
external: false,
custom_name: None,
})
}
pub fn external(name: impl Into<String>) -> Result<Self, GenerationError> {
Ok(Self {
name: required("resource name", name.into())?,
external: true,
custom_name: None,
})
}
pub fn set_custom_name(&mut self, name: impl Into<String>) -> Result<(), GenerationError> {
let name = required("custom resource name", name.into())?;
set_once(&mut self.custom_name, name, "resource name")
}
#[must_use]
pub fn name(&self) -> &str {
&self.name
}
#[must_use]
pub const fn is_external(&self) -> bool {
self.external
}
#[must_use]
pub fn custom_name(&self) -> Option<&str> {
self.custom_name.as_deref()
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct GeneratedService {
name: String,
hostname: Option<GeneratedHostname>,
container_name: Option<GeneratedString>,
image: Option<GeneratedString>,
entrypoint: Option<GeneratedEntrypoint>,
command: Option<GeneratedCommand>,
init: Option<bool>,
environment_files: Vec<GeneratedEnvironmentFile>,
environment: Vec<GeneratedEnvironment>,
labels: Vec<GeneratedLabel>,
annotations: Option<Vec<GeneratedAnnotation>>,
user: Option<GeneratedString>,
userns_mode: Option<GeneratedString>,
group_add: Vec<GeneratedString>,
cap_add: Option<Vec<GeneratedString>>,
cap_drop: Option<Vec<GeneratedString>>,
devices: Option<Vec<GeneratedDevice>>,
dns: Option<GeneratedDns>,
dns_options: Option<Vec<GeneratedString>>,
dns_search: Option<GeneratedDnsSearch>,
expose: Option<Vec<GeneratedString>>,
security_options: Option<Vec<GeneratedString>>,
working_dir: Option<GeneratedString>,
read_only: Option<bool>,
pids_limit: Option<GeneratedPidsLimit>,
shm_size: Option<GeneratedShmSize>,
mem_limit: Option<GeneratedMemLimit>,
tmpfs: Option<GeneratedTmpfs>,
sysctls: Option<GeneratedSysctls>,
ulimits: Option<GeneratedUlimits>,
pull_policy: Option<GeneratedPullPolicy>,
restart: Option<GeneratedRestartPolicy>,
stop_signal: Option<GeneratedString>,
stop_grace_period: Option<GeneratedString>,
extra_hosts: Vec<GeneratedExtraHost>,
ports: Vec<GeneratedPort>,
mounts: Vec<GeneratedMount>,
networks: Vec<GeneratedNetworkAttachment>,
}
impl GeneratedService {
pub fn new(name: impl Into<String>) -> Result<Self, GenerationError> {
Ok(Self {
name: required("service name", name.into())?,
hostname: None,
container_name: None,
image: None,
entrypoint: None,
command: None,
init: None,
environment_files: Vec::new(),
environment: Vec::new(),
labels: Vec::new(),
annotations: None,
user: None,
userns_mode: None,
group_add: Vec::new(),
cap_add: None,
cap_drop: None,
devices: None,
dns: None,
dns_options: None,
dns_search: None,
expose: None,
security_options: None,
working_dir: None,
read_only: None,
pids_limit: None,
shm_size: None,
mem_limit: None,
tmpfs: None,
sysctls: None,
ulimits: None,
pull_policy: None,
restart: None,
stop_signal: None,
stop_grace_period: None,
extra_hosts: Vec::new(),
ports: Vec::new(),
mounts: Vec::new(),
networks: Vec::new(),
})
}
#[must_use]
pub fn name(&self) -> &str {
&self.name
}
pub fn set_hostname(&mut self, hostname: GeneratedHostname) -> Result<(), GenerationError> {
let GeneratedHostname::Resolved(value) = &hostname;
if !valid_hostname(value.expose()) {
return Err(GenerationError::InvalidHostname);
}
set_once(&mut self.hostname, hostname, "hostname")
}
pub fn set_container_name(&mut self, name: GeneratedString) -> Result<(), GenerationError> {
if !valid_container_name(name.expose()) {
return Err(GenerationError::InvalidContainerName);
}
set_once(&mut self.container_name, name, "container_name")
}
pub fn set_image(&mut self, image: GeneratedString) -> Result<(), GenerationError> {
require_generated_string("service image", &image)?;
set_once(&mut self.image, image, "image")
}
pub fn set_entrypoint(&mut self, entrypoint: GeneratedEntrypoint) -> Result<(), GenerationError> {
set_once(&mut self.entrypoint, entrypoint, "entrypoint")
}
pub fn set_command(&mut self, command: GeneratedCommand) -> Result<(), GenerationError> {
set_once(&mut self.command, command, "command")
}
pub fn set_init(&mut self, init: bool) -> Result<(), GenerationError> {
set_once(&mut self.init, init, "init")
}
pub fn add_environment_file(&mut self, environment_file: GeneratedEnvironmentFile) {
self.environment_files.push(environment_file);
}
pub fn add_environment(&mut self, environment: GeneratedEnvironment) {
self.environment.push(environment);
}
pub fn add_label(&mut self, label: GeneratedLabel) -> Result<(), GenerationError> {
if self.labels.iter().any(|candidate| candidate.name == label.name) {
return Err(GenerationError::DuplicateName {
kind: "service label",
name: label.name,
});
}
self.labels.push(label);
Ok(())
}
pub fn set_annotations(&mut self, annotations: Vec<GeneratedAnnotation>) -> Result<(), GenerationError> {
let mut seen = BTreeSet::new();
for annotation in &annotations {
if annotation.name.is_empty() || annotation.name.contains(['$', '\r', '\n', '\0']) {
return Err(GenerationError::InvalidAnnotationName);
}
if annotation.value.expose().contains(['$', '\r', '\n', '\0']) {
return Err(GenerationError::InvalidAnnotationValue);
}
if !seen.insert(annotation.name.as_str()) {
return Err(GenerationError::DuplicateName {
kind: "service annotation",
name: annotation.name.clone(),
});
}
}
set_once(&mut self.annotations, annotations, "annotations")
}
#[must_use]
pub fn annotations(&self) -> Option<&[GeneratedAnnotation]> {
self.annotations.as_deref()
}
pub fn set_user(&mut self, user: GeneratedString) -> Result<(), GenerationError> {
set_once(&mut self.user, user, "user")
}
pub fn set_userns_mode(&mut self, mode: GeneratedString) -> Result<(), GenerationError> {
require_generated_string("user namespace mode", &mode)?;
set_once(&mut self.userns_mode, mode, "userns_mode")
}
pub fn add_supplementary_group(&mut self, group: GeneratedString) -> Result<(), GenerationError> {
require_generated_string("supplementary group", &group)?;
self.group_add.push(group);
Ok(())
}
pub fn set_cap_add(&mut self, capabilities: Vec<GeneratedString>) -> Result<(), GenerationError> {
let mut seen = BTreeSet::new();
for capability in &capabilities {
require_generated_string("cap_add item", capability)?;
if capability.expose().contains('\r') || capability.expose().contains('\n') {
return Err(GenerationError::ContainsLineBreak("cap_add item"));
}
if !seen.insert(capability.expose()) {
return Err(GenerationError::DuplicateItem("cap_add"));
}
}
set_once(&mut self.cap_add, capabilities, "cap_add")
}
#[must_use]
pub fn cap_add(&self) -> Option<&[GeneratedString]> {
self.cap_add.as_deref()
}
pub fn set_cap_drop(&mut self, capabilities: Vec<GeneratedString>) -> Result<(), GenerationError> {
let mut seen = BTreeSet::new();
for capability in &capabilities {
require_generated_string("cap_drop item", capability)?;
if capability.expose().contains('\r') || capability.expose().contains('\n') {
return Err(GenerationError::ContainsLineBreak("cap_drop item"));
}
if !seen.insert(capability.expose()) {
return Err(GenerationError::DuplicateItem("cap_drop"));
}
}
set_once(&mut self.cap_drop, capabilities, "cap_drop")
}
#[must_use]
pub fn cap_drop(&self) -> Option<&[GeneratedString]> {
self.cap_drop.as_deref()
}
pub fn set_devices(&mut self, devices: Vec<GeneratedDevice>) -> Result<(), GenerationError> {
for device in &devices {
match device {
GeneratedDevice::Short(value) => {
validate_generated_device_member("short item", value, true)?;
}
GeneratedDevice::Long(value) => {
validate_generated_device_member("source", value.source(), true)?;
if let Some(target) = value.target() {
validate_generated_device_member("target", target, false)?;
}
if let Some(permissions) = value.permissions() {
validate_generated_device_member("permissions", permissions, false)?;
}
}
}
}
set_once(&mut self.devices, devices, "devices")
}
pub fn set_dns(&mut self, dns: GeneratedDns) -> Result<(), GenerationError> {
let values = match &dns {
GeneratedDns::Scalar(value) => std::slice::from_ref(value),
GeneratedDns::List(values) => values.as_slice(),
};
for value in values {
if value.expose().is_empty()
|| value.expose().contains('$')
|| value.expose().contains('\r')
|| value.expose().contains('\n')
{
return Err(GenerationError::InvalidDnsValue);
}
}
set_once(&mut self.dns, dns, "dns")
}
#[must_use]
pub const fn dns(&self) -> Option<&GeneratedDns> {
self.dns.as_ref()
}
pub fn set_dns_options(&mut self, options: Vec<GeneratedString>) -> Result<(), GenerationError> {
let mut seen = BTreeSet::new();
for option in &options {
if option.expose().is_empty()
|| option.expose().contains('$')
|| option.expose().contains('\r')
|| option.expose().contains('\n')
|| option.expose().contains('\0')
{
return Err(GenerationError::InvalidDnsOptionValue);
}
if !seen.insert(option.expose()) {
return Err(GenerationError::DuplicateItem("dns_opt"));
}
}
set_once(&mut self.dns_options, options, "dns_opt")
}
#[must_use]
pub fn dns_options(&self) -> Option<&[GeneratedString]> {
self.dns_options.as_deref()
}
pub fn set_dns_search(&mut self, search: GeneratedDnsSearch) -> Result<(), GenerationError> {
let values = match &search {
GeneratedDnsSearch::Scalar(value) => std::slice::from_ref(value),
GeneratedDnsSearch::List(values) => values.as_slice(),
};
for value in values {
if value.expose().is_empty()
|| value.expose().contains('$')
|| value.expose().contains('\r')
|| value.expose().contains('\n')
|| value.expose().contains('\0')
{
return Err(GenerationError::InvalidDnsSearchValue);
}
}
set_once(&mut self.dns_search, search, "dns_search")
}
#[must_use]
pub const fn dns_search(&self) -> Option<&GeneratedDnsSearch> {
self.dns_search.as_ref()
}
pub fn set_expose(&mut self, expose: Vec<GeneratedString>) -> Result<(), GenerationError> {
let mut seen = BTreeSet::new();
for item in &expose {
if !valid_generated_expose_item(item.expose()) {
return Err(GenerationError::InvalidExposeValue);
}
if !seen.insert(item.expose()) {
return Err(GenerationError::DuplicateItem("expose"));
}
}
set_once(&mut self.expose, expose, "expose")
}
#[must_use]
pub fn expose(&self) -> Option<&[GeneratedString]> {
self.expose.as_deref()
}
pub fn set_security_options(&mut self, options: Vec<GeneratedString>) -> Result<(), GenerationError> {
for option in &options {
if option.expose().is_empty()
|| option.expose().contains('$')
|| option.expose().contains('\r')
|| option.expose().contains('\n')
|| option.expose().contains('\0')
{
return Err(GenerationError::InvalidSecurityOptionValue);
}
}
set_once(&mut self.security_options, options, "security_opt")
}
#[must_use]
pub fn security_options(&self) -> Option<&[GeneratedString]> {
self.security_options.as_deref()
}
#[must_use]
pub fn devices(&self) -> Option<&[GeneratedDevice]> {
self.devices.as_deref()
}
pub fn set_working_dir(&mut self, directory: GeneratedString) -> Result<(), GenerationError> {
require_generated_string("working directory", &directory)?;
set_once(&mut self.working_dir, directory, "working_dir")
}
pub fn set_read_only(&mut self, read_only: bool) -> Result<(), GenerationError> {
set_once(&mut self.read_only, read_only, "read_only")
}
pub fn set_pids_limit(&mut self, limit: GeneratedPidsLimit) -> Result<(), GenerationError> {
if let GeneratedPidsLimit::Finite(decimal) = &limit {
if !valid_positive_pids_decimal(decimal) {
return Err(GenerationError::InvalidPidsLimit);
}
}
set_once(&mut self.pids_limit, limit, "pids_limit")
}
pub fn set_shm_size(&mut self, size: GeneratedShmSize) -> Result<(), GenerationError> {
let GeneratedShmSize::Explicit { amount, .. } = &size;
if !valid_generated_shm_amount(amount.expose()) {
return Err(GenerationError::InvalidShmSize);
}
set_once(&mut self.shm_size, size, "shm_size")
}
pub fn set_mem_limit(&mut self, limit: GeneratedMemLimit) -> Result<(), GenerationError> {
let GeneratedMemLimit::Explicit { amount, .. } = &limit;
if !valid_generated_mem_amount(amount.expose()) {
return Err(GenerationError::InvalidMemLimit);
}
set_once(&mut self.mem_limit, limit, "mem_limit")
}
pub fn set_tmpfs(&mut self, tmpfs: GeneratedTmpfs) -> Result<(), GenerationError> {
let items = match &tmpfs {
GeneratedTmpfs::Scalar(item) => std::slice::from_ref(item),
GeneratedTmpfs::List(items) => items.as_slice(),
};
for item in items {
require_generated_string("tmpfs item", item)?;
if item.expose().contains('\r') || item.expose().contains('\n') {
return Err(GenerationError::ContainsLineBreak("tmpfs item"));
}
if !valid_generated_tmpfs_item(item.expose()) {
return Err(GenerationError::InvalidTmpfsItem);
}
}
set_once(&mut self.tmpfs, tmpfs, "tmpfs")
}
#[must_use]
pub const fn tmpfs(&self) -> Option<&GeneratedTmpfs> {
self.tmpfs.as_ref()
}
pub fn set_sysctls(&mut self, sysctls: GeneratedSysctls) -> Result<(), GenerationError> {
let mut seen = BTreeSet::new();
match &sysctls {
GeneratedSysctls::Map(entries) => {
for entry in entries {
if !seen.insert(entry.name()) {
return Err(GenerationError::DuplicateName {
kind: "sysctl",
name: entry.name().to_owned(),
});
}
}
}
GeneratedSysctls::List(items) => {
for item in items {
if item.expose().contains(['\r', '\n', '$']) {
return Err(GenerationError::InvalidSysctlValue);
}
if !seen.insert(item.expose()) {
return Err(GenerationError::DuplicateItem("sysctls"));
}
}
}
}
set_once(&mut self.sysctls, sysctls, "sysctls")
}
#[must_use]
pub const fn sysctls(&self) -> Option<&GeneratedSysctls> {
self.sysctls.as_ref()
}
pub fn set_ulimits(&mut self, ulimits: GeneratedUlimits) -> Result<(), GenerationError> {
set_once(&mut self.ulimits, ulimits, "ulimits")
}
#[must_use]
pub const fn ulimits(&self) -> Option<&GeneratedUlimits> {
self.ulimits.as_ref()
}
pub fn set_pull_policy(&mut self, policy: GeneratedPullPolicy) -> Result<(), GenerationError> {
if let GeneratedPullPolicy::Every(duration) = &policy {
if !valid_pull_policy_duration(duration.expose()) {
return Err(GenerationError::InvalidPullPolicyDuration);
}
}
set_once(&mut self.pull_policy, policy, "pull_policy")
}
pub fn set_restart(&mut self, restart: GeneratedRestartPolicy) -> Result<(), GenerationError> {
set_once(&mut self.restart, restart, "restart")
}
pub fn set_stop_signal(&mut self, signal: GeneratedString) -> Result<(), GenerationError> {
set_once(&mut self.stop_signal, signal, "stop_signal")
}
pub fn set_stop_grace_period(&mut self, period: GeneratedString) -> Result<(), GenerationError> {
if !StopGracePeriod::parse(period.expose().to_owned()).is_valid() {
return Err(GenerationError::InvalidStopGracePeriod);
}
set_once(&mut self.stop_grace_period, period, "stop_grace_period")
}
pub fn add_extra_host(&mut self, host: GeneratedExtraHost) {
self.extra_hosts.push(host);
}
pub fn add_port(&mut self, port: GeneratedPort) {
self.ports.push(port);
}
pub fn add_mount(&mut self, mount: GeneratedMount) {
self.mounts.push(mount);
}
pub fn add_network(&mut self, network: GeneratedNetworkAttachment) -> Result<(), GenerationError> {
if self.networks.iter().any(|candidate| candidate.name == network.name) {
return Err(GenerationError::DuplicateName {
kind: "service network",
name: network.name,
});
}
self.networks.push(network);
Ok(())
}
fn is_sensitive(&self) -> bool {
matches!(
self.hostname.as_ref(),
Some(GeneratedHostname::Resolved(hostname)) if hostname.is_sensitive()
) || self.image.as_ref().is_some_and(GeneratedString::is_sensitive)
|| self.entrypoint.as_ref().is_some_and(entrypoint_is_sensitive)
|| self.command.as_ref().is_some_and(command_is_sensitive)
|| self
.environment_files
.iter()
.any(GeneratedEnvironmentFile::is_sensitive)
|| self
.environment
.iter()
.filter_map(GeneratedEnvironment::value)
.any(GeneratedString::is_sensitive)
|| self.labels.iter().any(|label| label.value.is_sensitive())
|| self
.annotations
.as_ref()
.is_some_and(|items| items.iter().any(|annotation| annotation.value.is_sensitive()))
|| matches!(
self.pull_policy.as_ref(),
Some(GeneratedPullPolicy::Every(duration)) if duration.is_sensitive()
)
|| matches!(
self.shm_size.as_ref(),
Some(GeneratedShmSize::Explicit { amount, .. }) if amount.is_sensitive()
)
|| matches!(
self.mem_limit.as_ref(),
Some(GeneratedMemLimit::Explicit { amount, .. }) if amount.is_sensitive()
)
|| match self.tmpfs.as_ref() {
Some(GeneratedTmpfs::Scalar(item)) => item.is_sensitive(),
Some(GeneratedTmpfs::List(items)) => items.iter().any(GeneratedString::is_sensitive),
None => false,
}
|| match self.dns.as_ref() {
Some(GeneratedDns::Scalar(value)) => value.is_sensitive(),
Some(GeneratedDns::List(values)) => values.iter().any(GeneratedString::is_sensitive),
None => false,
}
|| self
.dns_options
.as_ref()
.is_some_and(|items| items.iter().any(GeneratedString::is_sensitive))
|| match self.dns_search.as_ref() {
Some(GeneratedDnsSearch::Scalar(value)) => value.is_sensitive(),
Some(GeneratedDnsSearch::List(values)) => values.iter().any(GeneratedString::is_sensitive),
None => false,
}
|| self
.expose
.as_ref()
.is_some_and(|items| items.iter().any(GeneratedString::is_sensitive))
|| self
.security_options
.as_ref()
.is_some_and(|items| items.iter().any(GeneratedString::is_sensitive))
|| match self.sysctls.as_ref() {
Some(GeneratedSysctls::Map(entries)) => entries.iter().any(|entry| entry.value.is_sensitive()),
Some(GeneratedSysctls::List(items)) => items.iter().any(GeneratedString::is_sensitive),
None => false,
}
|| self
.ulimits
.as_ref()
.is_some_and(|limits| limits.entries.iter().any(GeneratedUlimit::is_sensitive))
|| [
self.user.as_ref(),
self.userns_mode.as_ref(),
self.working_dir.as_ref(),
self.stop_signal.as_ref(),
self.stop_grace_period.as_ref(),
]
.into_iter()
.flatten()
.any(GeneratedString::is_sensitive)
|| self.group_add.iter().any(GeneratedString::is_sensitive)
|| self
.cap_add
.as_ref()
.is_some_and(|items| items.iter().any(GeneratedString::is_sensitive))
|| self
.cap_drop
.as_ref()
.is_some_and(|items| items.iter().any(GeneratedString::is_sensitive))
|| self
.devices
.as_ref()
.is_some_and(|items| items.iter().any(GeneratedDevice::is_sensitive))
}
}
#[derive(Clone, Debug, Default, Eq, PartialEq)]
pub struct ComposeDocumentBuilder {
name: Option<String>,
services: Vec<GeneratedService>,
networks: Vec<GeneratedResource>,
volumes: Vec<GeneratedResource>,
}
impl ComposeDocumentBuilder {
#[must_use]
pub const fn new() -> Self {
Self {
name: None,
services: Vec::new(),
networks: Vec::new(),
volumes: Vec::new(),
}
}
pub fn set_name(&mut self, name: impl Into<String>) -> Result<(), GenerationError> {
let name = required("project name", name.into())?;
set_once(&mut self.name, name, "name")
}
pub fn add_service(&mut self, service: GeneratedService) -> Result<(), GenerationError> {
insert_named(&mut self.services, service, "service", GeneratedService::name)
}
pub fn add_network(&mut self, network: GeneratedResource) -> Result<(), GenerationError> {
insert_named(&mut self.networks, network, "network", GeneratedResource::name)
}
pub fn add_volume(&mut self, volume: GeneratedResource) -> Result<(), GenerationError> {
insert_named(&mut self.volumes, volume, "volume", GeneratedResource::name)
}
pub fn build(self, source_id: SourceId) -> Result<GeneratedComposeDocument, GenerationError> {
if self.services.is_empty() {
return Err(GenerationError::MissingService);
}
let sensitive = self.services.iter().any(GeneratedService::is_sensitive);
let text = render_document(&self);
let syntax = SyntaxDocument::parse(source_id, text.clone())
.map_err(|_| GenerationError::InternalInvariant("syntax-tree"))?;
if !syntax.is_valid() {
return Err(GenerationError::InternalInvariant("syntax"));
}
let model = ComposeDocument::parse(syntax.document());
if !model.is_valid() {
return Err(GenerationError::InternalInvariant("typed-model"));
}
let document = model
.document()
.cloned()
.ok_or(GenerationError::InternalInvariant("document-root"))?;
Ok(GeneratedComposeDocument {
text,
sensitive,
document,
})
}
}
#[derive(Clone, Eq, PartialEq)]
pub struct GeneratedComposeDocument {
text: String,
sensitive: bool,
document: ComposeDocument,
}
impl GeneratedComposeDocument {
#[must_use]
pub fn text(&self) -> &str {
&self.text
}
#[must_use]
pub const fn document(&self) -> &ComposeDocument {
&self.document
}
#[must_use]
pub const fn is_sensitive(&self) -> bool {
self.sensitive
}
}
impl fmt::Debug for GeneratedComposeDocument {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("GeneratedComposeDocument")
.field("text", &if self.sensitive { "<redacted>" } else { &self.text })
.field("sensitive", &self.sensitive)
.field("document", &if self.sensitive { "<redacted>" } else { "validated" })
.finish()
}
}
fn render_document(project: &ComposeDocumentBuilder) -> String {
let mut output = String::new();
if let Some(name) = &project.name {
output.push_str("name: ");
write_quoted(&mut output, name);
output.push('\n');
}
output.push_str("services:\n");
for service in &project.services {
write_indent(&mut output, 1);
write_quoted(&mut output, &service.name);
output.push_str(":\n");
render_service(&mut output, service);
}
render_resources(&mut output, "networks", &project.networks);
render_resources(&mut output, "volumes", &project.volumes);
output
}
fn render_service(output: &mut String, service: &GeneratedService) {
if let Some(GeneratedHostname::Resolved(hostname)) = &service.hostname {
render_optional_string(output, "hostname", Some(hostname));
}
render_optional_string(output, "container_name", service.container_name.as_ref());
render_optional_string(output, "image", service.image.as_ref());
if let Some(entrypoint) = &service.entrypoint {
render_entrypoint(output, entrypoint);
}
if let Some(command) = &service.command {
render_command(output, command);
}
if let Some(init) = service.init {
write_field(output, 2, "init");
output.push_str(if init { "true\n" } else { "false\n" });
}
render_environment_files(output, &service.environment_files);
render_environment(output, &service.environment);
render_labels(output, &service.labels);
if let Some(annotations) = &service.annotations {
render_annotations(output, annotations);
}
render_optional_string(output, "user", service.user.as_ref());
render_optional_string(output, "userns_mode", service.userns_mode.as_ref());
render_string_sequence(output, "group_add", &service.group_add);
if let Some(capabilities) = &service.cap_add {
render_configured_string_sequence(output, "cap_add", capabilities);
}
if let Some(capabilities) = &service.cap_drop {
render_configured_string_sequence(output, "cap_drop", capabilities);
}
render_optional_string(output, "working_dir", service.working_dir.as_ref());
if let Some(read_only) = service.read_only {
write_field(output, 2, "read_only");
output.push_str(if read_only { "true\n" } else { "false\n" });
}
if let Some(pids_limit) = &service.pids_limit {
render_pids_limit(output, pids_limit);
}
if let Some(shm_size) = &service.shm_size {
render_shm_size(output, shm_size);
}
if let Some(mem_limit) = &service.mem_limit {
render_mem_limit(output, mem_limit);
}
if let Some(devices) = &service.devices {
render_devices(output, devices);
}
if let Some(dns) = &service.dns {
render_dns(output, dns);
}
if let Some(options) = &service.dns_options {
render_configured_string_sequence(output, "dns_opt", options);
}
if let Some(search) = &service.dns_search {
render_dns_search(output, search);
}
if let Some(expose) = &service.expose {
render_configured_string_sequence(output, "expose", expose);
}
if let Some(options) = &service.security_options {
render_configured_string_sequence(output, "security_opt", options);
}
if let Some(tmpfs) = &service.tmpfs {
render_tmpfs(output, tmpfs);
}
if let Some(sysctls) = &service.sysctls {
render_sysctls(output, sysctls);
}
if let Some(ulimits) = &service.ulimits {
render_ulimits(output, ulimits);
}
if let Some(pull_policy) = &service.pull_policy {
render_pull_policy(output, pull_policy);
}
if let Some(restart) = service.restart {
render_restart(output, restart);
}
render_optional_string(output, "stop_signal", service.stop_signal.as_ref());
render_optional_string(output, "stop_grace_period", service.stop_grace_period.as_ref());
render_extra_hosts(output, &service.extra_hosts);
render_ports(output, &service.ports);
render_mounts(output, &service.mounts);
render_networks(output, &service.networks);
}
fn render_pids_limit(output: &mut String, limit: &GeneratedPidsLimit) {
write_field(output, 2, "pids_limit");
match limit {
GeneratedPidsLimit::Unlimited => output.push_str("-1\n"),
GeneratedPidsLimit::Finite(decimal) => {
output.push_str(decimal);
output.push('\n');
}
}
}
fn render_shm_size(output: &mut String, size: &GeneratedShmSize) {
let GeneratedShmSize::Explicit { amount, unit } = size;
write_field(output, 2, "shm_size");
write_quoted(output, &format!("{}{}", amount.expose(), unit.as_str()));
output.push('\n');
}
fn render_mem_limit(output: &mut String, limit: &GeneratedMemLimit) {
let GeneratedMemLimit::Explicit { amount, unit } = limit;
write_field(output, 2, "mem_limit");
write_quoted(output, &format!("{}{}", amount.expose(), unit.as_str()));
output.push('\n');
}
fn render_devices(output: &mut String, devices: &[GeneratedDevice]) {
if devices.is_empty() {
output.push_str(" devices: []\n");
return;
}
output.push_str(" devices:\n");
for device in devices {
match device {
GeneratedDevice::Short(value) => {
output.push_str(" - ");
write_quoted(output, value.expose());
output.push('\n');
}
GeneratedDevice::Long(value) => {
output.push_str(" - source: ");
write_quoted(output, value.source().expose());
output.push('\n');
if let Some(target) = value.target() {
output.push_str(" target: ");
write_quoted(output, target.expose());
output.push('\n');
}
if let Some(permissions) = value.permissions() {
output.push_str(" permissions: ");
write_quoted(output, permissions.expose());
output.push('\n');
}
}
}
}
}
fn render_dns(output: &mut String, dns: &GeneratedDns) {
match dns {
GeneratedDns::Scalar(value) => render_optional_string(output, "dns", Some(value)),
GeneratedDns::List(values) => render_configured_string_sequence(output, "dns", values),
}
}
fn render_dns_search(output: &mut String, search: &GeneratedDnsSearch) {
match search {
GeneratedDnsSearch::Scalar(value) => render_optional_string(output, "dns_search", Some(value)),
GeneratedDnsSearch::List(values) => render_configured_string_sequence(output, "dns_search", values),
}
}
fn render_tmpfs(output: &mut String, tmpfs: &GeneratedTmpfs) {
match tmpfs {
GeneratedTmpfs::Scalar(item) => render_optional_string(output, "tmpfs", Some(item)),
GeneratedTmpfs::List(items) => render_configured_string_sequence(output, "tmpfs", items),
}
}
fn render_sysctls(output: &mut String, sysctls: &GeneratedSysctls) {
match sysctls {
GeneratedSysctls::Map(entries) if entries.is_empty() => output.push_str(" sysctls: {}\n"),
GeneratedSysctls::Map(entries) => {
output.push_str(" sysctls:\n");
for entry in entries {
write_indent(output, 3);
write_quoted(output, entry.name());
output.push_str(": ");
write_quoted(output, entry.value().expose());
output.push('\n');
}
}
GeneratedSysctls::List(items) => render_configured_string_sequence(output, "sysctls", items),
}
}
fn render_ulimits(output: &mut String, ulimits: &GeneratedUlimits) {
if ulimits.entries.is_empty() {
output.push_str(" ulimits: {}\n");
return;
}
output.push_str(" ulimits:\n");
for limit in &ulimits.entries {
write_indent(output, 3);
write_quoted(output, limit.name());
match limit.value() {
GeneratedUlimitValue::Single(value) => {
output.push_str(": ");
write_quoted(output, value.expose());
output.push('\n');
}
GeneratedUlimitValue::Range {
soft: Some(soft),
hard: Some(hard),
} => {
output.push_str(":\n");
write_indent(output, 4);
output.push_str("soft: ");
write_quoted(output, soft.expose());
output.push('\n');
write_indent(output, 4);
output.push_str("hard: ");
write_quoted(output, hard.expose());
output.push('\n');
}
GeneratedUlimitValue::Range { .. } => {
unreachable!("generated ulimit ranges are validated during construction")
}
}
}
}
fn render_pull_policy(output: &mut String, policy: &GeneratedPullPolicy) {
write_field(output, 2, "pull_policy");
let value = match policy {
GeneratedPullPolicy::Always => "always".to_owned(),
GeneratedPullPolicy::Never => "never".to_owned(),
GeneratedPullPolicy::Missing => "missing".to_owned(),
GeneratedPullPolicy::IfNotPresentAlias => "if_not_present".to_owned(),
GeneratedPullPolicy::Build => "build".to_owned(),
GeneratedPullPolicy::Daily => "daily".to_owned(),
GeneratedPullPolicy::Weekly => "weekly".to_owned(),
GeneratedPullPolicy::Every(duration) => format!("every_{}", duration.expose()),
};
write_quoted(output, &value);
output.push('\n');
}
fn render_entrypoint(output: &mut String, entrypoint: &GeneratedEntrypoint) {
match entrypoint {
GeneratedEntrypoint::List(arguments) if arguments.is_empty() => output.push_str(" entrypoint: []\n"),
GeneratedEntrypoint::List(arguments) => render_string_sequence(output, "entrypoint", arguments),
GeneratedEntrypoint::String(entrypoint) => render_optional_string(output, "entrypoint", Some(entrypoint)),
GeneratedEntrypoint::Empty => output.push_str(" entrypoint: []\n"),
}
}
fn render_restart(output: &mut String, restart: GeneratedRestartPolicy) {
write_field(output, 2, "restart");
let value = match restart {
GeneratedRestartPolicy::No => "no".to_owned(),
GeneratedRestartPolicy::Always => "always".to_owned(),
GeneratedRestartPolicy::OnFailure { maximum_retries: None } => "on-failure".to_owned(),
GeneratedRestartPolicy::OnFailure {
maximum_retries: Some(maximum_retries),
} => format!("on-failure:{maximum_retries}"),
GeneratedRestartPolicy::UnlessStopped => "unless-stopped".to_owned(),
};
write_quoted(output, &value);
output.push('\n');
}
fn render_optional_string(output: &mut String, key: &str, value: Option<&GeneratedString>) {
if let Some(value) = value {
write_field(output, 2, key);
write_quoted(output, value.expose());
output.push('\n');
}
}
fn render_command(output: &mut String, command: &GeneratedCommand) {
match command {
GeneratedCommand::Exec(arguments) if arguments.is_empty() => output.push_str(" command: []\n"),
GeneratedCommand::Exec(arguments) => render_string_sequence(output, "command", arguments),
GeneratedCommand::Shell(command) => render_optional_string(output, "command", Some(command)),
GeneratedCommand::Empty => output.push_str(" command: []\n"),
}
}
fn render_environment(output: &mut String, environment: &[GeneratedEnvironment]) {
if environment.is_empty() {
return;
}
output.push_str(" environment:\n");
for variable in environment {
output.push_str(" - ");
let value = variable.value.as_ref().map_or_else(
|| variable.name.clone(),
|value| format!("{}={}", variable.name, value.expose()),
);
write_quoted(output, &value);
output.push('\n');
}
}
fn render_environment_files(output: &mut String, environment_files: &[GeneratedEnvironmentFile]) {
if environment_files.is_empty() {
return;
}
output.push_str(" env_file:\n");
for environment_file in environment_files {
match environment_file {
GeneratedEnvironmentFile::Short(path) => {
output.push_str(" - ");
write_quoted(output, path.expose());
output.push('\n');
}
GeneratedEnvironmentFile::Long { path, required, format } => {
output.push_str(" - path: ");
write_quoted(output, path.expose());
output.push('\n');
if let Some(required) = required {
output.push_str(" required: ");
output.push_str(if *required { "true\n" } else { "false\n" });
}
if let Some(format) = format {
output.push_str(" format: ");
write_quoted(
output,
match format {
GeneratedEnvironmentFileFormat::Raw => "raw",
},
);
output.push('\n');
}
}
}
}
}
fn render_labels(output: &mut String, labels: &[GeneratedLabel]) {
if labels.is_empty() {
return;
}
output.push_str(" labels:\n");
for label in labels {
output.push_str(" ");
write_quoted(output, &label.name);
output.push_str(": ");
write_quoted(output, label.value.expose());
output.push('\n');
}
}
fn render_annotations(output: &mut String, annotations: &[GeneratedAnnotation]) {
if annotations.is_empty() {
output.push_str(" annotations: {}\n");
return;
}
output.push_str(" annotations:\n");
for annotation in annotations {
output.push_str(" ");
write_quoted(output, &annotation.name);
output.push_str(": ");
write_quoted(output, annotation.value.expose());
output.push('\n');
}
}
fn render_string_sequence(output: &mut String, key: &str, values: &[GeneratedString]) {
if values.is_empty() {
return;
}
write_indent(output, 2);
output.push_str(key);
output.push_str(":\n");
for value in values {
output.push_str(" - ");
write_quoted(output, value.expose());
output.push('\n');
}
}
fn render_configured_string_sequence(output: &mut String, key: &str, values: &[GeneratedString]) {
if values.is_empty() {
write_indent(output, 2);
output.push_str(key);
output.push_str(": []\n");
} else {
render_string_sequence(output, key, values);
}
}
fn render_extra_hosts(output: &mut String, hosts: &[GeneratedExtraHost]) {
if hosts.is_empty() {
return;
}
output.push_str(" extra_hosts:\n");
for host in hosts {
output.push_str(" - ");
write_quoted(output, &format!("{}={}", host.hostname, host.address));
output.push('\n');
}
}
fn render_ports(output: &mut String, ports: &[GeneratedPort]) {
if ports.is_empty() {
return;
}
output.push_str(" ports:\n");
for port in ports {
if port.protocol == GeneratedProtocol::Sctp {
render_short_sctp_port(output, port);
continue;
}
output.push_str(" - target: ");
output.push_str(&port.target.to_string());
output.push('\n');
if let Some(published) = port.published {
output.push_str(" published: ");
write_quoted(output, &published.to_string());
output.push('\n');
}
if let Some(host_ip) = &port.host_ip {
output.push_str(" host_ip: ");
write_quoted(output, host_ip);
output.push('\n');
}
output.push_str(" protocol: ");
write_quoted(output, port.protocol.as_str());
output.push('\n');
}
}
fn render_short_sctp_port(output: &mut String, port: &GeneratedPort) {
let mut value = String::new();
if let Some(host_ip) = &port.host_ip {
if host_ip.contains(':') && !(host_ip.starts_with('[') && host_ip.ends_with(']')) {
value.push('[');
value.push_str(host_ip);
value.push(']');
} else {
value.push_str(host_ip);
}
value.push(':');
}
if let Some(published) = port.published {
value.push_str(&published.to_string());
value.push(':');
}
value.push_str(&port.target.to_string());
value.push_str("/sctp");
output.push_str(" - ");
write_quoted(output, &value);
output.push('\n');
}
fn render_mounts(output: &mut String, mounts: &[GeneratedMount]) {
if mounts.is_empty() {
return;
}
output.push_str(" volumes:\n");
for mount in mounts {
match &mount.kind {
GeneratedMountKind::Bind {
source,
selinux: Some(selinux),
} => render_selinux_bind(output, source, mount, *selinux),
kind => render_long_mount(output, kind, mount),
}
}
}
fn render_selinux_bind(output: &mut String, source: &str, mount: &GeneratedMount, selinux: GeneratedSelinux) {
let mut value = format!("{source}:{}:{}", mount.target, selinux.as_str());
if mount.read_only {
value.push_str(",ro");
}
output.push_str(" - ");
write_quoted(output, &value);
output.push('\n');
}
fn render_long_mount(output: &mut String, kind: &GeneratedMountKind, mount: &GeneratedMount) {
let (mount_type, source) = match kind {
GeneratedMountKind::Volume { source } => ("volume", Some(source.as_str())),
GeneratedMountKind::Bind { source, selinux: None } => ("bind", Some(source.as_str())),
GeneratedMountKind::Anonymous => ("volume", None),
GeneratedMountKind::Bind { selinux: Some(_), .. } => return,
};
output.push_str(" - type: ");
write_quoted(output, mount_type);
output.push('\n');
if let Some(source) = source {
output.push_str(" source: ");
write_quoted(output, source);
output.push('\n');
}
output.push_str(" target: ");
write_quoted(output, &mount.target);
output.push('\n');
if mount.read_only {
output.push_str(" read_only: true\n");
}
}
fn render_networks(output: &mut String, networks: &[GeneratedNetworkAttachment]) {
if networks.is_empty() {
return;
}
output.push_str(" networks:\n");
for network in networks {
output.push_str(" ");
write_quoted(output, &network.name);
if network.aliases.is_empty() {
output.push_str(": {}\n");
} else {
output.push_str(":\n aliases:\n");
for alias in &network.aliases {
output.push_str(" - ");
write_quoted(output, alias);
output.push('\n');
}
}
}
}
fn render_resources(output: &mut String, section: &str, resources: &[GeneratedResource]) {
if resources.is_empty() {
return;
}
output.push_str(section);
output.push_str(":\n");
for resource in resources {
output.push_str(" ");
write_quoted(output, &resource.name);
if !resource.external && resource.custom_name.is_none() {
output.push_str(": {}\n");
continue;
}
output.push_str(":\n");
if let Some(custom_name) = &resource.custom_name {
output.push_str(" name: ");
write_quoted(output, custom_name);
output.push('\n');
}
if resource.external {
output.push_str(" external: true\n");
}
}
}
fn write_field(output: &mut String, depth: usize, key: &str) {
write_indent(output, depth);
output.push_str(key);
output.push_str(": ");
}
fn write_indent(output: &mut String, depth: usize) {
for _ in 0..depth {
output.push_str(" ");
}
}
fn required(kind: &'static str, value: String) -> Result<String, GenerationError> {
if value.is_empty() {
return Err(GenerationError::EmptyValue(kind));
}
if value.contains('\0') {
return Err(GenerationError::ContainsNul(kind));
}
Ok(value)
}
fn require_generated_string(kind: &'static str, value: &GeneratedString) -> Result<(), GenerationError> {
if value.expose().is_empty() {
return Err(GenerationError::EmptyValue(kind));
}
Ok(())
}
fn validate_generated_device_member(
member: &'static str,
value: &GeneratedString,
require_non_empty: bool,
) -> Result<(), GenerationError> {
if valid_generated_device_string(value.expose(), require_non_empty) {
Ok(())
} else {
Err(GenerationError::InvalidDeviceValue(member))
}
}
fn validate_generated_ulimit_value(value: &GeneratedString) -> Result<(), GenerationError> {
let value = value.expose();
if value.contains(['\r', '\n', '$'])
|| (value != "-1" && (value.is_empty() || !value.bytes().all(|byte| byte.is_ascii_digit())))
{
return Err(GenerationError::InvalidUlimitValue);
}
Ok(())
}
fn environment_name(value: String) -> Result<String, GenerationError> {
let value = required("environment name", value)?;
if value.contains('=') {
return Err(GenerationError::InvalidEnvironmentName);
}
Ok(value)
}
fn valid_container_name(value: &str) -> bool {
let mut bytes = value.bytes();
bytes.next().is_some_and(|byte| byte.is_ascii_alphanumeric())
&& bytes
.next()
.is_some_and(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'.' | b'-'))
&& bytes.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'.' | b'-'))
}
fn short_component(kind: &'static str, value: String, separator: char) -> Result<String, GenerationError> {
let value = required(kind, value)?;
if value.contains(separator) {
return Err(GenerationError::InvalidShortComponent(kind));
}
Ok(value)
}
fn set_once<T>(slot: &mut Option<T>, value: T, field: &'static str) -> Result<(), GenerationError> {
if slot.is_some() {
return Err(GenerationError::DuplicateField(field));
}
*slot = Some(value);
Ok(())
}
fn insert_named<T>(
values: &mut Vec<T>,
value: T,
kind: &'static str,
name: impl Fn(&T) -> &str,
) -> Result<(), GenerationError> {
let value_name = name(&value);
if values.iter().any(|candidate| name(candidate) == value_name) {
return Err(GenerationError::DuplicateName {
kind,
name: value_name.to_owned(),
});
}
values.push(value);
Ok(())
}
fn command_is_sensitive(command: &GeneratedCommand) -> bool {
match command {
GeneratedCommand::Exec(arguments) => arguments.iter().any(GeneratedString::is_sensitive),
GeneratedCommand::Shell(command) => command.is_sensitive(),
GeneratedCommand::Empty => false,
}
}
fn entrypoint_is_sensitive(entrypoint: &GeneratedEntrypoint) -> bool {
match entrypoint {
GeneratedEntrypoint::List(arguments) => arguments.iter().any(GeneratedString::is_sensitive),
GeneratedEntrypoint::String(entrypoint) => entrypoint.is_sensitive(),
GeneratedEntrypoint::Empty => false,
}
}