From f3a88d9f27714cabfc70817435509244e37638bf Mon Sep 17 00:00:00 2001 From: Paul Nettleton Date: Mon, 14 Oct 2024 11:25:38 -0500 Subject: [PATCH] feat(compose): `.build` Quadlet files from Compose 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 --- src/cli/build.rs | 156 ++++++++++++++++++++++++++++++++++++++++++- src/cli/compose.rs | 112 +++++++++++++++++++++++-------- src/cli/container.rs | 2 +- 3 files changed, 239 insertions(+), 31 deletions(-) diff --git a/src/cli/build.rs b/src/cli/build.rs index 5ea7d94..c32918e 100644 --- a/src/cli/build.rs +++ b/src/cli/build.rs @@ -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; @@ -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. @@ -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. @@ -251,6 +260,145 @@ impl From for quadlet::Resource { } } +impl TryFrom 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 { + 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::>() + .wrap_err("error converting `cache_from`")?, + cache_to: cache_to + .into_iter() + .map(cache_try_into_image) + .collect::>() + .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::>() + .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 { + 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)] @@ -516,8 +664,10 @@ struct PodmanArgs { pid: Option, /// 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, + platform: Vec, /// Suppress output messages. #[arg(short, long)] diff --git a/src/cli/compose.rs b/src/cli/compose.rs index 0018338..ba7d551 100644 --- a/src/cli/compose.rs +++ b/src/cli/compose.rs @@ -2,7 +2,7 @@ use std::{ collections::HashMap, fs, io::{self, IsTerminal}, - mem, + iter, mem, path::{Path, PathBuf}, }; @@ -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`], splitting the [`String`](Command::String) variant /// as a shell would. @@ -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::, _>>()?; + 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::, _>>()?; if let Some(name) = pod_name { let pod = quadlet::Pod { @@ -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, + unit: Option<&'a Unit>, + install: Option<&'a quadlet::Install>, + volume_has_options: &'a HashMap, + pod_name: Option<&'a str>, + pod_ports: &'a mut Vec, +) -> impl Iterator> + '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 diff --git a/src/cli/container.rs b/src/cli/container.rs index b0989b0..b9f69bc 100644 --- a/src/cli/container.rs +++ b/src/cli/container.rs @@ -97,7 +97,7 @@ impl TryFrom 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()?