Skip to content

Commit

Permalink
feat(compose): .build Quadlet files from Compose
Browse files Browse the repository at this point in the history
Added support to `podlet compose` for converting the `build` section of
a Compose service to a `.build` Quadlet file.

Closes: #100
Signed-off-by: Paul Nettleton <[email protected]>
  • Loading branch information
k9withabone committed Oct 18, 2024
1 parent 33be7e4 commit f3a88d9
Show file tree
Hide file tree
Showing 3 changed files with 239 additions and 31 deletions.
156 changes: 153 additions & 3 deletions src/cli/build.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,15 @@ use std::{
};

use clap::{ArgAction, Args};
use compose_spec::service::{build::Context, ByteValue, Limit};
use color_eyre::eyre::{bail, ensure, eyre, OptionExt, WrapErr};
use compose_spec::{
service::{
self,
build::{Cache, CacheType, Context, Dockerfile},
ByteValue, Limit, Ulimit,
},
ShortOrLong,
};
use serde::Serialize;
use smart_default::SmartDefault;

Expand All @@ -22,7 +30,7 @@ use super::image_to_name;

/// [`Args`] for `podman build`.
#[allow(clippy::doc_markdown)]
#[derive(Args, Debug, Clone, PartialEq, Eq)]
#[derive(Args, Debug, SmartDefault, Clone, PartialEq, Eq)]
#[group(skip)]
pub struct Build {
/// Add an image annotation (e.g. annotation=value) to the image metadata.
Expand Down Expand Up @@ -95,6 +103,7 @@ pub struct Build {
///
/// Converts to "ForceRM=FORCE_RM".
#[arg(long, action = ArgAction::Set, default_value_t = true)]
#[default = true]
force_rm: bool,

/// Assign additional groups to the primary user running within the container process.
Expand Down Expand Up @@ -251,6 +260,145 @@ impl From<Build> for quadlet::Resource {
}
}

impl TryFrom<service::Build> for Build {
type Error = color_eyre::Report;

fn try_from(
service::Build {
context,
dockerfile,
args,
ssh,
cache_from,
cache_to,
additional_contexts,
entitlements,
extra_hosts,
isolation,
privileged,
labels,
no_cache,
pull,
network,
shm_size,
target,
secrets,
tags,
ulimits,
platforms,
extensions,
}: service::Build,
) -> Result<Self, Self::Error> {
ensure!(entitlements.is_empty(), "`entitlements` are not supported");
ensure!(!privileged, "`privileged` is not supported");
ensure!(secrets.is_empty(), "`secrets` are not supported");
ensure!(
extensions.is_empty(),
"compose extensions are not supported"
);

let file = dockerfile
.map(|dockerfile| match dockerfile {
Dockerfile::File(file) => Ok(file.into()),
Dockerfile::Inline(_) => Err(eyre!("`dockerfile_inline` is not supported")),
})
.transpose()?;

ensure!(
context.is_some() || file.is_some(),
"`context` or `dockerfile` is required"
);

let podman_args = PodmanArgs {
add_host: extra_hosts
.into_iter()
.map(|(hostname, ip)| format!("{hostname}:{ip}"))
.collect(),
build_arg: args.into_list().into_iter().collect(),
build_context: additional_contexts
.iter()
.map(|(id, context)| format!("{id}={context}"))
.collect(),
cache_from: cache_from
.into_iter()
.map(cache_try_into_image)
.collect::<Result<_, _>>()
.wrap_err("error converting `cache_from`")?,
cache_to: cache_to
.into_iter()
.map(cache_try_into_image)
.collect::<Result<_, _>>()
.wrap_err("error converting `cache_to`")?,
isolation,
no_cache,
platform: platforms.iter().map(ToString::to_string).collect(),
shm_size,
ssh: ssh.iter().map(ToString::to_string).collect(),
ulimit: ulimits
.into_iter()
.map(|(resource, ulimit)| match ulimit {
ShortOrLong::Short(limit) => Ok(format!("{resource}={limit}")),
ShortOrLong::Long(Ulimit {
soft,
hard,
extensions,
}) => {
ensure!(
extensions.is_empty(),
"compose extensions are not supported"
);
Ok(format!("{resource}={soft}:{hard}"))
}
})
.collect::<Result<_, _>>()
.wrap_err("error converting `ulimits`")?,
..PodmanArgs::default()
};

let mut tags = tags.into_iter();
let tag = tags
.next()
.ok_or_eyre("an image tag is required")?
.into_inner();
ensure!(
tags.next().is_none(),
"Quadlet only supports setting a single tag"
);

Ok(Self {
file,
tag,
label: labels.into_list().into_iter().collect(),
network: network.map(Into::into).into_iter().collect(),
pull: pull.then_some(PullPolicy::Always),
target,
podman_args,
context,
..Self::default()
})
}
}

/// Attempt to convert [`Cache`] into an image name.
///
/// # Errors
///
/// Returns an error if the cache type is not [`Registry`](CacheType::Registry) or any other cache
/// options are set.
fn cache_try_into_image(
Cache {
cache_type,
options,
}: Cache,
) -> 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"),
};
ensure!(options.is_empty(), "cache options are not supported");
Ok(image)
}

/// [`Args`] for `podman build` (i.e. [`Build`]) that convert into `PodmanArgs=ARGS`.
#[allow(clippy::struct_excessive_bools)]
#[derive(Args, Serialize, Debug, SmartDefault, Clone, PartialEq, Eq)]
Expand Down Expand Up @@ -516,8 +664,10 @@ struct PodmanArgs {
pid: Option<String>,

/// Set the os/arch of the built image, instead of using the build host's.
///
/// Can be specified multiple times.
#[arg(long, value_name = "OS/ARCH[/VARIANT][,...]")]
platform: Option<String>,
platform: Vec<String>,

/// Suppress output messages.
#[arg(short, long)]
Expand Down
112 changes: 85 additions & 27 deletions src/cli/compose.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ use std::{
collections::HashMap,
fs,
io::{self, IsTerminal},
mem,
iter, mem,
path::{Path, PathBuf},
};

Expand All @@ -18,7 +18,7 @@ use indexmap::IndexMap;

use crate::quadlet::{self, container::volume::Source, Globals};

use super::{k8s, Container, File, GlobalArgs, Unit};
use super::{k8s, Build, Container, File, GlobalArgs, Unit};

/// Converts a [`Command`] into a [`Vec<String>`], splitting the [`String`](Command::String) variant
/// as a shell would.
Expand Down Expand Up @@ -259,31 +259,26 @@ fn parts_try_into_files(
.collect();

let mut pod_ports = Vec::new();
let mut files = services
.into_iter()
.map(|(name, service)| {
service_try_into_quadlet_file(
service,
name,
unit.clone(),
install.clone(),
&volume_has_options,
pod_name.as_deref(),
&mut pod_ports,
)
})
.chain(networks_try_into_quadlet_files(
networks,
unit.as_ref(),
install.as_ref(),
))
.chain(volumes_try_into_quadlet_files(
volumes,
unit.as_ref(),
install.as_ref(),
))
.map(|result| result.map(Into::into))
.collect::<Result<Vec<File>, _>>()?;
let mut files = services_try_into_quadlet_files(
services,
unit.as_ref(),
install.as_ref(),
&volume_has_options,
pod_name.as_deref(),
&mut pod_ports,
)
.chain(networks_try_into_quadlet_files(
networks,
unit.as_ref(),
install.as_ref(),
))
.chain(volumes_try_into_quadlet_files(
volumes,
unit.as_ref(),
install.as_ref(),
))
.map(|result| result.map(Into::into))
.collect::<Result<Vec<File>, _>>()?;

if let Some(name) = pod_name {
let pod = quadlet::Pod {
Expand All @@ -304,6 +299,69 @@ fn parts_try_into_files(
Ok(files)
}

/// Attempt to convert Compose [`Service`]s into [`quadlet::File`]s.
///
/// `volume_has_options` should be a map from volume [`Identifier`]s to whether the volume has any
/// options set. It is used to determine whether to link to a [`quadlet::Volume`] in the created
/// [`quadlet::Container`].
///
/// If `pod_name` is [`Some`] and a service has any published ports, they are taken from the
/// created [`quadlet::Container`] and added to `pod_ports`.
///
/// # Errors
///
/// Returns an error if there was an error [adding](Unit::add_dependency()) a service
/// [`Dependency`](compose_spec::service::Dependency) to the [`Unit`], converting the
/// [`Build`](compose_spec::service::Build) section into a [`quadlet::Build`] file, or converting
/// the [`Service`] into a [`quadlet::Container`] file.
fn services_try_into_quadlet_files<'a>(
services: IndexMap<Identifier, Service>,
unit: Option<&'a Unit>,
install: Option<&'a quadlet::Install>,
volume_has_options: &'a HashMap<Identifier, bool>,
pod_name: Option<&'a str>,
pod_ports: &'a mut Vec<String>,
) -> impl Iterator<Item = color_eyre::Result<quadlet::File>> + 'a {
services.into_iter().flat_map(move |(name, mut service)| {
if service.image.is_some() && service.build.is_some() {
return iter::once(Err(eyre!(
"error converting service `{name}`: `image` and `build` cannot both be set"
)))
.chain(None);
}

let build = service.build.take().map(|build| {
let build = Build::try_from(build.into_long()).wrap_err_with(|| {
format!(
"error converting `build` for service `{name}` into a Quadlet `.build` file"
)
})?;
let image = format!("{}.build", build.name()).try_into()?;
service.image = Some(image);
Ok(quadlet::File {
name: build.name().to_owned(),
unit: unit.cloned(),
resource: build.into(),
globals: Globals::default(),
service: None,
install: install.cloned(),
})
});

let container = service_try_into_quadlet_file(
service,
name,
unit.cloned(),
install.cloned(),
volume_has_options,
pod_name,
pod_ports,
);

iter::once(container).chain(build)
})
}

/// Attempt to convert a compose [`Service`] into a [`quadlet::File`].
///
/// `volume_has_options` should be a map from volume [`Identifier`]s to whether the volume has any
Expand Down
2 changes: 1 addition & 1 deletion src/cli/container.rs
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,7 @@ impl TryFrom<compose_spec::Service> for Container {
quadlet_options: quadlet.try_into()?,
podman_args: podman_args.try_into()?,
security_opt,
image: image.ok_or_eyre("`image` is required")?.into(),
image: image.ok_or_eyre("`image` or `build` is required")?.into(),
command: command
.map(super::compose::command_try_into_vec)
.transpose()?
Expand Down

0 comments on commit f3a88d9

Please sign in to comment.