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
8 changes: 8 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
25 changes: 25 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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.
Expand Down
64 changes: 55 additions & 9 deletions src/cli.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
mod artifact;
mod build;
mod compose;
pub(crate) mod compose;
mod container;
mod generate;
mod global_args;
Expand All @@ -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,
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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<Vec<crate::GeneratedFile>> {
// 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<Vec<File>> {
let resolve_dir = self
.resolve_dir()
Expand Down Expand Up @@ -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),
}
Expand All @@ -789,21 +830,23 @@ impl From<k8s::File> 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",
}
}

/// 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),
Expand Down Expand Up @@ -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<JoinOption>) -> color_eyre::Result<String> {
pub(crate) fn serialize(
&self,
join_options: &HashSet<JoinOption>,
) -> color_eyre::Result<String> {
match self {
File::Quadlet(file) => file
.serialize_to_quadlet(join_options)
Expand Down
29 changes: 24 additions & 5 deletions src/cli/compose.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Vec<File>> {
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<Vec<File>> {
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")?;
Expand Down
161 changes: 161 additions & 0 deletions src/lib.rs
Original file line number Diff line number Diff line change
@@ -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<JoinOption>,
}

/// 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<Vec<GeneratedFile>> {
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<JoinOption>,
) -> color_eyre::Result<Vec<GeneratedFile>> {
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()
}
Loading