From cb658df1828c4f5f06ff9df5135323d5dca09b06 Mon Sep 17 00:00:00 2001 From: Maxime Borges Date: Thu, 9 Jul 2026 00:42:49 +0200 Subject: [PATCH] feat: support use as a library Split the crate into a library (`src/lib.rs`) and a thin binary (`src/main.rs`) so podlet can be used as a dependency. The library exposes a small, string-based public API for in-memory compose -> Quadlet/Kubernetes conversion, keeping internals private: - `compose_to_files(yaml, ComposeOptions) -> Vec` - `Cli::try_into_generated_files()` for all subcommands - `GeneratedFile`, `ComposeOptions` Split `Compose::try_into_files` into an I/O part and a pure `into_files` that takes an already-parsed `compose_spec::Compose`. Patch `compose_spec` (k9withabone/compose_spec_rs#42) so absolute volume paths validate on non-Unix targets. Signed-off-by: Maxime Borges --- Cargo.toml | 8 ++ README.md | 25 ++++++ src/cli.rs | 64 ++++++++++++--- src/cli/compose.rs | 29 +++++-- src/lib.rs | 161 +++++++++++++++++++++++++++++++++++++ src/main.rs | 10 +-- src/quadlet.rs | 6 +- src/serde/args.rs | 2 +- src/serde/mount_options.rs | 4 +- src/serde/quadlet.rs | 2 +- 10 files changed, 287 insertions(+), 24 deletions(-) create mode 100644 src/lib.rs diff --git a/Cargo.toml b/Cargo.toml index 0d63e20..bfdf7e3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,6 +11,14 @@ license = "MPL-2.0" keywords = ["podman", "quadlet", "containers"] categories = ["command-line-utilities"] +[lib] +name = "podlet" +path = "src/lib.rs" + +[[bin]] +name = "podlet" +path = "src/main.rs" + [lints.rust] unused_crate_dependencies = "warn" unused_import_braces = "warn" diff --git a/README.md b/README.md index bd8ef8b..c175a10 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,7 @@ Demo created with [Autocast](https://github.com/k9withabone/autocast). You can a - Opt-out with `--skip-services-check`. - Set Podman version compatibility with `--podman-version`. - Resolve relative host paths with `--absolute-host-paths`. +- Usable as a library crate, in addition to the CLI. ## Communication @@ -396,6 +397,30 @@ Alternatively, if you just want Podlet to read a specific compose file you can u `podman run --rm -v ./compose.yaml:/compose.yaml:Z ghcr.io/containers/podlet compose /compose.yaml` +## Use as a Library + +In addition to the CLI, Podlet can be used as a library. The most common use case is converting a compose file into Quadlet files entirely in memory, without touching the filesystem: + +```rust +use podlet::{compose_to_files, ComposeOptions}; + +let compose = "\ +services: + caddy: + image: docker.io/library/caddy:latest + ports: + - 8000:80 +"; + +let files = compose_to_files(compose, ComposeOptions::default()).unwrap(); +assert_eq!(files[0].name, "caddy.container"); +assert!(files[0].content.contains("Image=docker.io/library/caddy:latest")); +``` + +For full control (equivalent to the CLI, including the `podman ...` and `generate` subcommands), build a `Cli` and call `Cli::try_into_generated_files()` to obtain the generated files in memory instead of printing or writing them. + +See the [API documentation](https://docs.rs/podlet) for details. + ## Cautions Podlet is primarily a tool for helping to get started with Podman systemd units, aka Quadlet files. It is not meant to be an end-all solution for creating and maintaining Quadlet files. Files created with Podlet should always be reviewed before starting the unit. diff --git a/src/cli.rs b/src/cli.rs index f68337d..acadc43 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -1,6 +1,6 @@ mod artifact; mod build; -mod compose; +pub(crate) mod compose; mod container; mod generate; mod global_args; @@ -18,13 +18,15 @@ mod systemd_dbus; use std::{ borrow::Cow, collections::HashSet, - env, - ffi::OsStr, - fs, + env, fs, io::{self, Write}, path::{Path, PathBuf}, }; +// Only used by the `#[cfg(unix)]` existing-services check. +#[cfg(unix)] +use std::ffi::OsStr; + use clap::{ArgAction, Parser, Subcommand, builder::TypedValueParser}; use color_eyre::{ Help, @@ -258,6 +260,16 @@ Image=image Quadlet options can be specified in a comma (,) separated list and/or this option can be specified \ multiple times."; + /// Print the generated file(s) to stdout, or write them to disk, based on the CLI options. + /// + /// This is the entry point used by the `podlet` binary. Library consumers that want the + /// generated files in memory instead should use + /// [`try_into_generated_files`](Self::try_into_generated_files). + /// + /// # Errors + /// + /// Returns an error if the input cannot be read or converted into files, or if writing the + /// generated file(s) to disk fails. pub fn print_or_write_files(self) -> color_eyre::Result<()> { // Determine which Quadlet options to join together into a single line by subtracting the // selected options from the set of all possible options. @@ -410,7 +422,33 @@ multiple times."; .transpose() } - /// Convert into [`File`]s + /// Convert into generated files, held in memory. + /// + /// This applies all of the transformations requested by the CLI options (resolving host + /// paths, downgrading to an older Podman version, etc.) and returns the generated files as + /// [`GeneratedFile`](crate::GeneratedFile)s (file name and serialized contents) without + /// printing or writing them anywhere. It is the in-memory equivalent of + /// [`print_or_write_files`](Self::print_or_write_files). + /// + /// Note that, depending on the command, this may read from the filesystem or standard input + /// (e.g. `podlet compose` reading a compose file) or spawn `podman` (`podlet generate`), which + /// is not available on all targets. For a fully in-memory compose conversion, use + /// [`compose_to_files`](crate::compose_to_files) instead. + /// + /// # Errors + /// + /// Returns an error if the input cannot be read or converted, or if a file fails to serialize. + pub fn try_into_generated_files(self) -> color_eyre::Result> { + // Determine which Quadlet options to join together into a single line by subtracting the + // selected options from the set of all possible options. + let split_options = self.split_options.iter().copied().collect(); + let join_options = &JoinOption::all_set() - &split_options; + + let files = self.try_into_files()?; + crate::serialize_files(&files, &join_options) + } + + /// Convert into [`File`]s. fn try_into_files(mut self) -> color_eyre::Result> { let resolve_dir = self .resolve_dir() @@ -769,9 +807,12 @@ impl PodmanCommands { } } +/// A single file generated by Podlet, held in memory. +/// +/// Either a Quadlet file (`.container`, `.pod`, `.network`, ...) or a Kubernetes YAML file. #[derive(Debug)] #[allow(clippy::large_enum_variant)] // false positive, [Pod] is not zero-sized -enum File { +pub(crate) enum File { Quadlet(quadlet::File), Kubernetes(k8s::File), } @@ -789,14 +830,14 @@ impl From for File { } impl File { - fn name(&self) -> &str { + pub(crate) fn name(&self) -> &str { match self { Self::Quadlet(file) => &file.name, Self::Kubernetes(file) => &file.name, } } - fn extension(&self) -> &str { + pub(crate) fn extension(&self) -> &str { match self { Self::Quadlet(file) => file.resource.extension(), Self::Kubernetes(_) => "yaml", @@ -804,6 +845,8 @@ impl File { } /// Returns [`Some`] if a [`File::Quadlet`]. + // Only used by the `#[cfg(unix)]` existing-services check. + #[cfg(unix)] fn as_quadlet_file(&self) -> Option<&quadlet::File> { match self { Self::Quadlet(file) => Some(file), @@ -843,7 +886,10 @@ impl File { /// /// Returns an error if the contained [`quadlet::File`] or [`k8s::File`] returns an error while /// serializing. - fn serialize(&self, join_options: &HashSet) -> color_eyre::Result { + pub(crate) fn serialize( + &self, + join_options: &HashSet, + ) -> color_eyre::Result { match self { File::Quadlet(file) => file .serialize_to_quadlet(join_options) diff --git a/src/cli/compose.rs b/src/cli/compose.rs index 5a05d08..15aab76 100644 --- a/src/cli/compose.rs +++ b/src/cli/compose.rs @@ -95,17 +95,36 @@ impl Compose { /// - Converting the compose file to Kubernetes YAML. /// - Converting the compose file to Quadlet files. pub fn try_into_files(self, sections: GenericSections) -> color_eyre::Result> { + let mut options = compose_spec::Compose::options(); + options.apply_merge(true); + let compose = read_from_file_or_stdin(self.compose_file.as_deref(), &options) + .wrap_err("error reading compose file")?; + + self.into_files(compose, sections) + } + + /// Convert an already parsed [`compose_spec::Compose`] into [`File`]s. + /// + /// Unlike [`try_into_files`](Self::try_into_files), this performs no filesystem or standard + /// input access, making it suitable for restricted targets such as WASM. The `compose_file` + /// field is ignored. + /// + /// # Errors + /// + /// Returns an error if the compose file fails validation, uses an unsupported option, or + /// cannot be converted into Quadlet or Kubernetes YAML files. + pub fn into_files( + self, + compose: compose_spec::Compose, + sections: GenericSections, + ) -> color_eyre::Result> { let Self { pod, kube, add_container_name, - compose_file, + compose_file: _, } = self; - let mut options = compose_spec::Compose::options(); - options.apply_merge(true); - let compose = read_from_file_or_stdin(compose_file.as_deref(), &options) - .wrap_err("error reading compose file")?; compose .validate_all() .wrap_err("error validating compose file")?; diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..26ba274 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,161 @@ +//! Podlet generates [Podman](https://podman.io/) +//! [Quadlet](https://docs.podman.io/en/latest/markdown/podman-systemd.unit.5.html) +//! (systemd-like) files from a Podman command, compose file, or existing object. +//! +//! This crate can be used both as the `podlet` command-line application and as a library. +//! +//! # Library usage +//! +//! The most common library use case is converting a compose file into Quadlet files entirely +//! in memory, without touching the filesystem. This makes it suitable for use in other +//! applications, including WebAssembly (WASM) targets running in the browser. +//! +//! ``` +//! use podlet::{compose_to_files, ComposeOptions}; +//! +//! let compose = "\ +//! services: +//! caddy: +//! image: docker.io/library/caddy:latest +//! ports: +//! - 8000:80 +//! "; +//! +//! let files = compose_to_files(compose, ComposeOptions::default()).unwrap(); +//! assert_eq!(files[0].name, "caddy.container"); +//! assert!(files[0].content.contains("Image=docker.io/library/caddy:latest")); +//! ``` +//! +//! For full control (equivalent to the CLI, including the `podman ...` and `generate` +//! subcommands), construct a [`Cli`] and use [`Cli::try_into_generated_files`] to obtain the +//! generated files in memory instead of printing or writing them. +//! +//! # Command-line usage +//! +//! ```shell +//! $ podlet podman run quay.io/podman/hello +//! [Container] +//! Image=quay.io/podman/hello +//! ``` +//! +//! Run `podlet --help` for more information. + +mod cli; +mod escape; +mod quadlet; +mod serde; + +use std::collections::HashSet; + +use color_eyre::eyre::WrapErr; +use compose_spec::Compose; + +pub use self::cli::Cli; +use self::{ + cli::{File, compose::Compose as ComposeCommand}, + quadlet::{GenericSections, JoinOption}, +}; + +/// A generated output file, held entirely in memory. +/// +/// Returned by the in-memory conversion helpers such as [`compose_to_files`]. This is the +/// building block for consumers that do not want Podlet to write to the filesystem itself +/// (e.g. a web frontend compiled to WASM). +#[derive(Debug, Clone, PartialEq, Eq, ::serde::Serialize)] +pub struct GeneratedFile { + /// The file name, including its extension (e.g. `caddy.container` or `caddy-kube.yaml`). + pub name: String, + + /// The serialized contents of the file. + pub content: String, +} + +/// Options controlling how a compose file is converted into Quadlet files. +/// +/// The defaults match `podlet compose` with no additional flags. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct ComposeOptions { + /// Create a `.pod` file and link it with each `.container` file. + /// + /// The top-level `name` field in the compose file is required when this is set. + pub pod: bool, + + /// Create a Kubernetes YAML file for a pod instead of separate containers. + /// + /// The top-level `name` field in the compose file is required when this is set. + pub kube: bool, + + /// Set `ContainerName=` for each container to the compose service name. + pub add_container_name: bool, + + /// Quadlet options to split onto separate lines instead of joining them together. + /// + /// When empty (the default), all joinable options are combined onto a single line, matching + /// the CLI's default behavior. + pub split_options: Vec, +} + +/// Convert the YAML contents of a compose file into generated Quadlet (and, optionally, +/// Kubernetes) files, entirely in memory. +/// +/// This performs no filesystem, environment, or process access, making it usable on restricted +/// targets such as `wasm32-unknown-unknown`. +/// +/// # Errors +/// +/// Returns an error if the compose file cannot be parsed or validated, or if it cannot be +/// converted into Quadlet files (for example, when it uses an unsupported option). +pub fn compose_to_files( + yaml: &str, + options: ComposeOptions, +) -> color_eyre::Result> { + let ComposeOptions { + pod, + kube, + add_container_name, + split_options, + } = options; + + let mut parse_options = Compose::options(); + parse_options.apply_merge(true); + let compose = parse_options + .from_yaml_reader(yaml.as_bytes()) + .wrap_err("input is not a valid compose file")?; + + let command = ComposeCommand { + pod, + kube, + add_container_name, + compose_file: None, + }; + + let files = command + .into_files(compose, GenericSections::default()) + .wrap_err("error converting compose file")?; + + let join_options = &JoinOption::all_set() - &split_options.into_iter().collect(); + serialize_files(&files, &join_options) +} + +/// Serialize in-memory [`File`]s into [`GeneratedFile`]s. +/// +/// Quadlet options in `join_options` are combined onto a single line. +/// +/// # Errors +/// +/// Returns an error if any file fails to serialize. +pub(crate) fn serialize_files( + files: &[File], + join_options: &HashSet, +) -> color_eyre::Result> { + files + .iter() + .map(|file| { + let name = format!("{}.{}", file.name(), file.extension()); + let content = file + .serialize(join_options) + .wrap_err_with(|| format!("error serializing file `{name}`"))?; + Ok(GeneratedFile { name, content }) + }) + .collect() +} diff --git a/src/main.rs b/src/main.rs index 73d9c2e..2b355f7 100644 --- a/src/main.rs +++ b/src/main.rs @@ -12,15 +12,15 @@ //! //! Run `podlet --help` for more information. -mod cli; -mod escape; -mod quadlet; -mod serde; +// This binary is a thin wrapper around the `podlet` library, so it only uses a couple of crates +// directly. The remaining dependencies are used by the library target, which still enforces the +// `unused_crate_dependencies` lint. +#![allow(unused_crate_dependencies)] use clap::Parser; use color_eyre::eyre; -use self::cli::Cli; +use podlet::Cli; fn main() -> eyre::Result<()> { color_eyre::install()?; diff --git a/src/quadlet.rs b/src/quadlet.rs index a3a631b..c3bcfa1 100644 --- a/src/quadlet.rs +++ b/src/quadlet.rs @@ -44,7 +44,7 @@ use crate::serde::skip_true; /// Generic Quadlet sections able to be used by all Quadlet types. /// /// Commonly grouped together when creating Quadlet [`File`]s. -#[derive(Debug, Clone, PartialEq)] +#[derive(Debug, Clone, Default, PartialEq)] pub struct GenericSections { /// The `[Unit]` section. pub unit: Unit, @@ -67,6 +67,8 @@ pub struct File { impl File { /// Returns the corresponding service file name generated by Quadlet. + // Only used by the `#[cfg(unix)]` existing-services check. + #[cfg_attr(not(unix), allow(dead_code))] pub fn service_name(&self) -> String { self.resource.name_to_service(&self.name) } @@ -399,6 +401,8 @@ impl Resource { /// Takes a file name (no extension) and returns the corresponding service file name /// generated by Quadlet. + // Only used by the `#[cfg(unix)]` existing-services check. + #[cfg_attr(not(unix), allow(dead_code))] pub fn name_to_service(&self, name: &str) -> String { let mut service = match self { Self::Container(_) | Self::Kube(_) => name.to_owned(), diff --git a/src/serde/args.rs b/src/serde/args.rs index 444ddbe..028c4f5 100644 --- a/src/serde/args.rs +++ b/src/serde/args.rs @@ -15,7 +15,7 @@ use crate::escape::arg_quote; /// Returns an error if the value errors while serializing, the value is a non-serializable type, /// the value has nested maps, or the value is a map without string keys. /// -/// ``` +/// ```ignore /// #[derive(Serialize)] /// struct Example { /// str: &'static str, diff --git a/src/serde/mount_options.rs b/src/serde/mount_options.rs index ce353fa..10f1deb 100644 --- a/src/serde/mount_options.rs +++ b/src/serde/mount_options.rs @@ -20,7 +20,7 @@ mod ser; /// /// # Example /// -/// ``` +/// ```ignore /// use serde::Serialize; /// /// #[derive(Serialize, PartialEq)] @@ -55,7 +55,7 @@ pub fn to_string(value: T) -> Result { /// /// # Example /// -/// ``` +/// ```ignore /// use serde::Deserialize; /// /// #[derive(Deserialize, PartialEq)] diff --git a/src/serde/quadlet.rs b/src/serde/quadlet.rs index d9b23d4..908fc0c 100644 --- a/src/serde/quadlet.rs +++ b/src/serde/quadlet.rs @@ -83,7 +83,7 @@ pub fn to_string_join_all(value: T) -> Result { /// /// # Examples /// -/// ``` +/// ```ignore /// #[derive(Serialize)] /// #[serde(rename_all = "PascalCase")] /// struct Example {