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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -273,6 +273,39 @@ If a compose file is not given, Podlet will search for the following files in th
- `podman-compose.yaml`
- `podman-compose.yml`

#### Restart policies

For Quadlet output, Compose's `deploy.restart_policy.condition` and `delay` map
to systemd's `Restart=` and `RestartSec=`:

```yaml
services:
app:
image: quay.io/podman/hello
deploy:
restart_policy:
condition: any
delay: 5s
```

The generated `.container` file includes:

```ini
[Service]
Restart=always
RestartSec=5s
```

Conditions `none`, `on-failure`, and `any` map to `no`, `on-failure`, and `always`,
respectively. A deployment restart policy takes precedence over `restart`;
without one, the existing `restart` conversion is unchanged. An omitted condition
defaults to `any`, and an omitted delay defaults to `0s`.

Other deployment options, including restart-policy `max_attempts` and `window`,
remain unsupported and produce an error. For Podman command conversion,
`podlet podman run --restart always --restart-sec 5s IMAGE` sets the same systemd
restart policy and delay.

#### Pod

The `--pod` option will create a `.pod` Quadlet file and link each `.container` file to it.
Expand Down
4 changes: 3 additions & 1 deletion src/cli/build.rs
Original file line number Diff line number Diff line change
Expand Up @@ -431,7 +431,9 @@ fn cache_try_into_image(
) -> color_eyre::Result<String> {
let image = match cache_type {
CacheType::Registry(image) => image.into_inner(),
CacheType::Other(_) => bail!("only the `registry` cache type is supported"),
CacheType::Other(_) => {
bail!("only the `registry` cache type is supported");
}
};
ensure!(options.is_empty(), "cache options are not supported");
Ok(image)
Expand Down
138 changes: 136 additions & 2 deletions src/cli/compose.rs
Original file line number Diff line number Diff line change
Expand Up @@ -438,7 +438,24 @@ fn service_try_into_quadlet_file(

let global_args = GlobalArgs::from_compose(&mut service);

let restart = service.restart;
let restart = match service
.deploy
.as_mut()
.and_then(|deploy| deploy.restart_policy.take())
{
Some(policy) => {
let restart = quadlet::Service::try_from(policy)?;
if service
.deploy
.as_ref()
.is_some_and(compose_spec::service::Deploy::is_empty)
{
service.deploy = None;
}
restart
}
None => service.restart.map(Into::into).unwrap_or_default(),
};

let mut container = Container::try_from(service)
.map(quadlet::Container::from)
Expand Down Expand Up @@ -479,7 +496,7 @@ fn service_try_into_quadlet_file(
resource: container.into(),
globals: global_args.into(),
quadlet,
service: restart.map(Into::into).unwrap_or_default(),
service: restart,
install,
})
}
Expand Down Expand Up @@ -562,3 +579,120 @@ fn volumes_try_into_quadlet_files(
})
})
}

#[cfg(test)]
mod tests {
use super::*;

fn convert_service(options: &str, pod: Option<&str>) -> color_eyre::Result<String> {
let service = serde_yaml::from_str(&format!("image: quay.io/podman/hello\n{options}"))?;
let file = service_try_into_quadlet_file(
service,
"test".parse()?,
GenericSections {
unit: quadlet::Unit::default(),
quadlet: quadlet::Quadlet::default(),
install: quadlet::Install::default(),
},
&HashMap::new(),
pod,
&mut Vec::new(),
false,
)?;
Ok(file.serialize_to_quadlet(&quadlet::JoinOption::all_set())?)
}

#[test]
fn compose_restart_policies() -> color_eyre::Result<()> {
for (condition, expected) in [
("none", "no"),
("on-failure", "on-failure"),
("any", "always"),
] {
let output = convert_service(
&format!("deploy:\n restart_policy:\n condition: {condition}\n delay: 5s\n"),
None,
)?;
assert!(output.contains(&format!("[Service]\nRestart={expected}\nRestartSec=5s\n")));
}
Ok(())
}

#[test]
fn compose_restart_delay_durations() -> color_eyre::Result<()> {
for delay in ["0s", "5s", "500ms", "1m30s", "1s500ms250us"] {
let output = convert_service(
&format!("deploy:\n restart_policy:\n delay: {delay}\n"),
None,
)?;
assert!(output.contains(&format!("RestartSec={delay}\n")));
assert!(output.contains("Restart=always\n"));
}
Ok(())
}

#[test]
fn compose_restart_policy_defaults_and_precedence() -> color_eyre::Result<()> {
let output = convert_service("restart: 'no'\ndeploy:\n restart_policy: {}\n", None)?;
assert!(output.contains("Restart=always\nRestartSec=0s\n"));

let output = convert_service(
"restart: always\ndeploy:\n restart_policy:\n condition: on-failure\n",
None,
)?;
assert!(output.contains("Restart=on-failure\nRestartSec=0s\n"));
Ok(())
}

#[test]
fn compose_restart_without_deploy_is_unchanged() -> color_eyre::Result<()> {
for (policy, expected) in [
("'no'", "no"),
("always", "always"),
("unless-stopped", "always"),
("on-failure", "on-failure"),
] {
let output = convert_service(&format!("restart: {policy}\n"), None)?;
assert!(output.contains(&format!("Restart={expected}\n")));
assert!(!output.contains("RestartSec="));
}
let output = convert_service("", None)?;
assert!(!output.contains("[Service]"));
Ok(())
}

#[test]
fn compose_restart_policy_with_pod() -> color_eyre::Result<()> {
let output = convert_service("deploy:\n restart_policy:\n delay: 5s\n", Some("test"))?;
assert!(output.contains("Pod=test.pod\n"));
assert!(output.contains("Restart=always\nRestartSec=5s\n"));
Ok(())
}

#[test]
fn unsupported_restart_policy_fields_are_rejected() {
for (field, value) in [("max_attempts", "3"), ("window", "10s")] {
let error = convert_service(
&format!("deploy:\n restart_policy:\n delay: 5s\n {field}: {value}\n"),
None,
)
.expect_err("unsupported restart policy field must be rejected");
assert!(
format!("{error:#}")
.contains(&format!("`deploy.restart_policy.{field}` is not supported"))
);
}
}

#[test]
fn unsupported_deploy_fields_are_still_rejected() {
for options in [
"deploy:\n replicas: 2\n restart_policy:\n delay: 5s\n",
"deploy:\n replicas: 2\n",
"deploy:\n restart_policy:\n delay: 5s\n x-custom: true\n",
"deploy:\n x-custom: true\n restart_policy:\n delay: 5s\n",
] {
assert!(convert_service(options, None).is_err());
}
}
}
4 changes: 3 additions & 1 deletion src/cli/k8s/volume.rs
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,9 @@ impl DriverOpts {
};
self.add_mount_options(&mount_options)?;
}
key => bail!("unknown volume driver option `{key}`"),
key => {
bail!("unknown volume driver option `{key}`");
}
}
Ok(())
}
Expand Down
4 changes: 3 additions & 1 deletion src/quadlet/build.rs
Original file line number Diff line number Diff line change
Expand Up @@ -204,7 +204,9 @@ impl FromStr for Secret {
);
source = Some(value.into());
}
option => bail!("unknown secret option `{option}`"),
option => {
bail!("unknown secret option `{option}`");
}
}
}

Expand Down
89 changes: 86 additions & 3 deletions src/quadlet/service.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
use clap::{Args, ValueEnum};
use compose_spec::service::Restart;
use color_eyre::eyre::ensure;
use compose_spec::{
duration,
service::{
Restart,
deploy::{RestartCondition, RestartPolicy},
},
};
use serde::Serialize;

/// The `[Service]` section of a systemd unit / Quadlet file.
Expand All @@ -12,21 +19,31 @@ pub struct Service {
/// Configure if and when the service should be restarted.
#[arg(long, value_name = "POLICY")]
pub restart: Option<RestartConfig>,

/// How long to wait before restarting the service.
///
/// Converts to "RestartSec=DURATION" in the [Service] section.
#[arg(long, value_name = "DURATION")]
pub restart_sec: Option<String>,
}

impl Service {
/// Returns `true` if all fields are [`None`].
pub fn is_empty(&self) -> bool {
let Self { restart } = self;
let Self {
restart,
restart_sec,
} = self;

restart.is_none()
restart.is_none() && restart_sec.is_none()
}
}

impl From<RestartConfig> for Service {
fn from(restart: RestartConfig) -> Self {
Self {
restart: Some(restart),
..Self::default()
}
}
}
Expand All @@ -37,6 +54,38 @@ impl From<Restart> for Service {
}
}

impl TryFrom<RestartPolicy> for Service {
type Error = color_eyre::Report;

fn try_from(
RestartPolicy {
condition,
delay,
max_attempts,
window,
extensions,
}: RestartPolicy,
) -> Result<Self, Self::Error> {
ensure!(
max_attempts.is_none(),
"`deploy.restart_policy.max_attempts` is not supported"
);
ensure!(
window.is_none(),
"`deploy.restart_policy.window` is not supported"
);
ensure!(
extensions.is_empty(),
"compose extensions are not supported"
);

Ok(Self {
restart: Some(condition.unwrap_or_default().into()),
restart_sec: Some(duration::to_string(delay.unwrap_or_default())),
})
}
}

/// Possible service restart configurations.
///
/// From [systemd.service](https://www.freedesktop.org/software/systemd/man/systemd.service.html#Restart=).
Expand All @@ -62,3 +111,37 @@ impl From<Restart> for RestartConfig {
}
}
}

impl From<RestartCondition> for RestartConfig {
fn from(value: RestartCondition) -> Self {
match value {
RestartCondition::None => Self::No,
RestartCondition::OnFailure => Self::OnFailure,
RestartCondition::Any => Self::Always,
}
}
}

#[cfg(test)]
mod tests {
use clap::{Command, FromArgMatches};

use super::*;

#[test]
fn restart_sec_cli_and_serialization() -> color_eyre::Result<()> {
let matches = Service::augment_args(Command::new("test")).try_get_matches_from([
"test",
"--restart-sec",
"5s",
])?;
let service = Service::from_arg_matches(&matches)?;

assert!(!service.is_empty());
assert_eq!(
crate::serde::quadlet::to_string_join_all(service)?,
"[Service]\nRestartSec=5s\n"
);
Ok(())
}
}
Loading