├── demo.gif ├── clippy.toml ├── .gitignore ├── CODE-OF-CONDUCT.md ├── SECURITY.md ├── compose-example.yaml ├── src ├── main.rs ├── quadlet │ ├── install.rs │ ├── container │ │ ├── mount │ │ │ ├── mode.rs │ │ │ └── idmap.rs │ │ ├── rootfs.rs │ │ └── device.rs │ ├── globals.rs │ ├── pod.rs │ ├── image.rs │ ├── volume.rs │ ├── build.rs │ ├── network.rs │ └── kube.rs ├── cli │ ├── systemd_dbus.rs │ ├── install.rs │ ├── service.rs │ ├── volume.rs │ ├── volume │ │ └── opt.rs │ ├── k8s.rs │ ├── container │ │ └── security_opt.rs │ ├── unit.rs │ ├── network.rs │ ├── image.rs │ ├── kube.rs │ ├── container.rs │ ├── k8s │ │ ├── volume.rs │ │ └── service │ │ │ └── mount.rs │ └── global_args.rs ├── escape.rs ├── serde.rs └── serde │ ├── mount_options.rs │ └── mount_options │ ├── de.rs │ └── ser.rs ├── Containerfile ├── .github └── workflows │ ├── release-container.yml │ ├── ci.yaml │ └── release.yml ├── demo.yaml ├── CONTRIBUTING.md └── Cargo.toml /demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/containers/podlet/HEAD/demo.gif -------------------------------------------------------------------------------- /clippy.toml: -------------------------------------------------------------------------------- 1 | # Clippy configuration 2 | 3 | doc-valid-idents = ["SELinux", ".."] 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Cargo build directory 2 | /target 3 | 4 | # VS Code user settings 5 | /.vscode 6 | 7 | # Helix user settings 8 | /.helix 9 | 10 | # demo output 11 | demo.cast 12 | -------------------------------------------------------------------------------- /CODE-OF-CONDUCT.md: -------------------------------------------------------------------------------- 1 | ## The Podlet Project Community Code of Conduct 2 | 3 | The Podlet project follows the [Containers Community Code of Conduct](https://github.com/containers/common/blob/main/CODE-OF-CONDUCT.md). 4 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | ## Security and Disclosure Information Policy for the Podlet Project 2 | 3 | The Podlet project follows the [Security and Disclosure Information Policy](https://github.com/containers/common/blob/main/SECURITY.md) for the Containers Projects. 4 | -------------------------------------------------------------------------------- /compose-example.yaml: -------------------------------------------------------------------------------- 1 | name: caddy 2 | services: 3 | caddy: 4 | image: docker.io/library/caddy:latest 5 | ports: 6 | - 8000:80 7 | - 8443:443 8 | volumes: 9 | - ./Caddyfile:/etc/caddy/Caddyfile:Z 10 | - caddy-data:/data 11 | volumes: 12 | caddy-data: 13 | -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- 1 | //! Podlet generates [Podman](https://podman.io/) 2 | //! [Quadlet](https://docs.podman.io/en/latest/markdown/podman-systemd.unit.5.html) 3 | //! (systemd-like) files from a Podman command, compose file, or existing object. 4 | //! 5 | //! # Usage 6 | //! 7 | //! ```shell 8 | //! $ podlet podman run quay.io/podman/hello 9 | //! [Container] 10 | //! Image=quay.io/podman/hello 11 | //! ``` 12 | //! 13 | //! Run `podlet --help` for more information. 14 | 15 | mod cli; 16 | mod escape; 17 | mod quadlet; 18 | mod serde; 19 | 20 | use clap::Parser; 21 | use color_eyre::eyre; 22 | 23 | use self::cli::Cli; 24 | 25 | fn main() -> eyre::Result<()> { 26 | color_eyre::install()?; 27 | 28 | Cli::parse().print_or_write_files() 29 | } 30 | -------------------------------------------------------------------------------- /src/quadlet/install.rs: -------------------------------------------------------------------------------- 1 | use std::fmt::{self, Display, Formatter}; 2 | 3 | use serde::Serialize; 4 | 5 | use crate::serde::quadlet::quote_spaces_join_space; 6 | 7 | #[derive(Serialize, Debug, Clone, PartialEq)] 8 | #[serde(rename_all = "PascalCase")] 9 | pub struct Install { 10 | /// Add weak parent dependencies to the unit. 11 | #[serde( 12 | serialize_with = "quote_spaces_join_space", 13 | skip_serializing_if = "Vec::is_empty" 14 | )] 15 | pub wanted_by: Vec, 16 | 17 | /// Add stronger parent dependencies to the unit. 18 | #[serde( 19 | serialize_with = "quote_spaces_join_space", 20 | skip_serializing_if = "Vec::is_empty" 21 | )] 22 | pub required_by: Vec, 23 | } 24 | 25 | impl Display for Install { 26 | fn fmt(&self, f: &mut Formatter) -> fmt::Result { 27 | let install = crate::serde::quadlet::to_string(self).map_err(|_| fmt::Error)?; 28 | f.write_str(&install) 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/cli/systemd_dbus.rs: -------------------------------------------------------------------------------- 1 | #![allow(clippy::same_name_method)] // triggered by `proxy` macro 2 | 3 | use nix::unistd::Uid; 4 | use zbus::{blocking::Connection, proxy}; 5 | 6 | pub fn unit_files() -> zbus::Result> { 7 | let connection = Connection::system()?; 8 | let manager = ManagerProxyBlocking::new(&connection)?; 9 | let mut unit_files = manager.list_unit_files()?; 10 | 11 | if !Uid::current().is_root() { 12 | let connection = Connection::session()?; 13 | let manager = ManagerProxyBlocking::new(&connection)?; 14 | unit_files.extend(manager.list_unit_files()?); 15 | } 16 | 17 | Ok(unit_files.into_iter().map(Into::into)) 18 | } 19 | 20 | #[proxy( 21 | interface = "org.freedesktop.systemd1.Manager", 22 | default_service = "org.freedesktop.systemd1", 23 | default_path = "/org/freedesktop/systemd1" 24 | )] 25 | trait Manager { 26 | fn list_unit_files(&self) -> zbus::Result>; 27 | } 28 | 29 | #[derive(Debug, Clone, PartialEq)] 30 | pub struct UnitFile { 31 | pub file_name: String, 32 | pub status: String, 33 | } 34 | 35 | impl From<(String, String)> for UnitFile { 36 | fn from((file_name, status): (String, String)) -> Self { 37 | Self { file_name, status } 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/cli/install.rs: -------------------------------------------------------------------------------- 1 | use clap::Args; 2 | 3 | #[allow(clippy::doc_markdown)] 4 | #[derive(Args, Debug, Clone, PartialEq)] 5 | pub struct Install { 6 | /// Add an [Install] section to the unit 7 | /// 8 | /// By default, if the --wanted-by and --required-by options are not used, 9 | /// the section will have "WantedBy=default.target". 10 | #[allow(clippy::struct_field_names)] 11 | #[arg(short, long)] 12 | pub install: bool, 13 | 14 | /// Add (weak) parent dependencies to the unit 15 | /// 16 | /// Requires the --install option 17 | /// 18 | /// Converts to "WantedBy=WANTED_BY" 19 | /// 20 | /// Can be specified multiple times 21 | #[arg(long, requires = "install")] 22 | wanted_by: Vec, 23 | 24 | /// Similar to --wanted-by, but adds stronger parent dependencies 25 | /// 26 | /// Requires the --install option 27 | /// 28 | /// Converts to "RequiredBy=REQUIRED_BY" 29 | /// 30 | /// Can be specified multiple times 31 | #[arg(long, requires = "install")] 32 | required_by: Vec, 33 | } 34 | 35 | impl From for crate::quadlet::Install { 36 | fn from(value: Install) -> Self { 37 | Self { 38 | wanted_by: if value.wanted_by.is_empty() && value.required_by.is_empty() { 39 | vec![String::from("default.target")] 40 | } else { 41 | value.wanted_by 42 | }, 43 | required_by: value.required_by, 44 | } 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /Containerfile: -------------------------------------------------------------------------------- 1 | FROM --platform=$BUILDPLATFORM docker.io/library/rust:1 AS chef 2 | WORKDIR /app 3 | ENV CARGO_TARGET_AARCH64_UNKNOWN_LINUX_MUSL_LINKER=aarch64-linux-gnu-gcc 4 | RUN ["/bin/bash", "-c", "set -o pipefail && curl -L --proto '=https' --tlsv1.2 -sSf https://raw.githubusercontent.com/cargo-bins/cargo-binstall/main/install-from-binstall-release.sh | bash"] 5 | RUN cargo binstall -y cargo-chef 6 | ARG TARGETPLATFORM 7 | RUN case "$TARGETPLATFORM" in \ 8 | "linux/amd64") echo x86_64-unknown-linux-musl > /rust_target.txt ;; \ 9 | "linux/arm64/v8") echo aarch64-unknown-linux-musl > /rust_target.txt && \ 10 | apt update && apt install -y gcc-aarch64-linux-gnu ;; \ 11 | *) exit 1 ;; \ 12 | esac 13 | RUN rustup target add $(cat /rust_target.txt) 14 | 15 | FROM chef AS planner 16 | COPY Cargo.toml Cargo.lock ./ 17 | COPY src ./src 18 | RUN cargo chef prepare --recipe-path recipe.json 19 | 20 | FROM chef AS builder 21 | COPY --from=planner /app/recipe.json recipe.json 22 | RUN cargo chef cook \ 23 | --profile dist \ 24 | --target $(cat /rust_target.txt) \ 25 | --recipe-path recipe.json 26 | COPY Cargo.toml Cargo.lock ./ 27 | COPY src ./src 28 | RUN cargo build \ 29 | --profile dist \ 30 | --target $(cat /rust_target.txt) 31 | RUN cp target/$(cat /rust_target.txt)/dist/podlet . 32 | 33 | FROM scratch 34 | LABEL org.opencontainers.image.source="https://github.com/containers/podlet" 35 | LABEL org.opencontainers.image.description="Generate Podman Quadlet files from a Podman command, compose file, or existing object" 36 | LABEL org.opencontainers.image.licenses="MPL-2.0" 37 | COPY --from=builder /app/podlet /usr/local/bin/ 38 | ENTRYPOINT [ "/usr/local/bin/podlet" ] 39 | -------------------------------------------------------------------------------- /.github/workflows/release-container.yml: -------------------------------------------------------------------------------- 1 | # Builds and pushes container images upon release 2 | name: Release Container 3 | 4 | on: 5 | push: 6 | tags: 7 | - "v[0-9]+*" 8 | 9 | env: 10 | MANIFEST: podlet-multiarch 11 | 12 | jobs: 13 | build-and-push: 14 | runs-on: ubuntu-latest 15 | container: 16 | image: quay.io/containers/buildah:latest 17 | options: --security-opt seccomp=unconfined --security-opt apparmor=unconfined --device /dev/fuse:rw 18 | permissions: 19 | packages: write 20 | contents: read 21 | steps: 22 | - name: Checkout 23 | uses: actions/checkout@v4 24 | 25 | - run: buildah version 26 | 27 | - name: Create manifest 28 | run: | 29 | buildah manifest create \ 30 | --annotation "org.opencontainers.image.source=https://github.com/containers/podlet" \ 31 | --annotation '"org.opencontainers.image.description=Generate Podman Quadlet files from a Podman command, compose file, or existing object"' \ 32 | --annotation "org.opencontainers.image.licenses=MPL-2.0" \ 33 | "${MANIFEST}" 34 | 35 | - name: Build image 36 | run: | 37 | buildah build --manifest "${MANIFEST}" \ 38 | --platform linux/amd64,linux/arm64/v8 -t podlet . 39 | 40 | - name: Push to ghcr.io 41 | env: 42 | USERNAME: ${{ github.actor }} 43 | PASSWORD: ${{ secrets.GITHUB_TOKEN }} 44 | run: | 45 | buildah manifest push "${MANIFEST}:latest" --all \ 46 | --creds "${USERNAME}:${PASSWORD}" \ 47 | "docker://ghcr.io/containers/podlet:${GITHUB_REF_NAME}" && \ 48 | buildah manifest push "${MANIFEST}:latest" --all \ 49 | --creds "${USERNAME}:${PASSWORD}" \ 50 | "docker://ghcr.io/containers/podlet:latest" 51 | -------------------------------------------------------------------------------- /src/cli/service.rs: -------------------------------------------------------------------------------- 1 | use std::fmt::{self, Display, Formatter}; 2 | 3 | use clap::{Args, ValueEnum}; 4 | use compose_spec::service::Restart; 5 | 6 | #[derive(Args, Default, Debug, Clone, PartialEq, Eq)] 7 | pub struct Service { 8 | /// Configure if and when the service should be restarted 9 | #[arg(long, value_name = "POLICY")] 10 | restart: Option, 11 | } 12 | 13 | impl Service { 14 | pub fn is_empty(&self) -> bool { 15 | *self == Self::default() 16 | } 17 | } 18 | 19 | impl Display for Service { 20 | fn fmt(&self, f: &mut Formatter) -> fmt::Result { 21 | writeln!(f, "[Service]")?; 22 | if let Some(restart) = self.restart.and_then(|restart| restart.to_possible_value()) { 23 | writeln!(f, "Restart={}", restart.get_name())?; 24 | } 25 | Ok(()) 26 | } 27 | } 28 | 29 | impl From for Service { 30 | fn from(restart: RestartConfig) -> Self { 31 | Self { 32 | restart: Some(restart), 33 | } 34 | } 35 | } 36 | 37 | impl From for Service { 38 | fn from(restart: Restart) -> Self { 39 | RestartConfig::from(restart).into() 40 | } 41 | } 42 | 43 | /// Possible service restart configurations 44 | /// 45 | /// From [systemd.service](https://www.freedesktop.org/software/systemd/man/systemd.service.html#Restart=) 46 | #[derive(ValueEnum, Debug, Clone, Copy, PartialEq, Eq)] 47 | enum RestartConfig { 48 | No, 49 | OnSuccess, 50 | OnFailure, 51 | OnAbnormal, 52 | OnWatchdog, 53 | OnAbort, 54 | #[value(alias = "unless-stopped")] 55 | Always, 56 | } 57 | 58 | impl From for RestartConfig { 59 | fn from(value: Restart) -> Self { 60 | match value { 61 | Restart::No => Self::No, 62 | Restart::Always | Restart::UnlessStopped => Self::Always, 63 | Restart::OnFailure => Self::OnFailure, 64 | } 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/quadlet/container/mount/mode.rs: -------------------------------------------------------------------------------- 1 | //! (De)serialize [`Mode`] as a string. For use in `#[serde(with = "mode")]`. 2 | 3 | use serde::{de, Deserialize, Deserializer, Serialize, Serializer}; 4 | use umask::{Mode, READ, USER, WRITE}; 5 | 6 | /// Serialize [`Mode`] as a string. 7 | #[allow(clippy::trivially_copy_pass_by_ref)] 8 | pub fn serialize(mode: &Mode, serializer: S) -> Result { 9 | let mode = u32::from(mode); 10 | format_args!("{mode:o}").serialize(serializer) 11 | } 12 | 13 | /// Deserialize [`Mode`] from a string. 14 | pub fn deserialize<'de, D: Deserializer<'de>>(deserializer: D) -> Result { 15 | let mode = Deserialize::deserialize(deserializer)?; 16 | u32::from_str_radix(mode, 8) 17 | .map(Into::into) 18 | .map_err(de::Error::custom) 19 | } 20 | 21 | /// Default [`Mode`] for [`DevPts`](super::DevPts): `0o600`. 22 | pub const fn default() -> Mode { 23 | Mode::new().with_class_perm(USER, READ | WRITE) 24 | } 25 | 26 | /// Skip serializing for default [`Mode`]. 27 | #[allow(clippy::trivially_copy_pass_by_ref)] 28 | pub fn skip_default(mode: &Mode) -> bool { 29 | *mode == default() 30 | } 31 | 32 | #[cfg(test)] 33 | #[allow(clippy::unwrap_used)] 34 | mod tests { 35 | use std::fmt::{self, Display, Formatter}; 36 | 37 | use serde::de::value::{BorrowedStrDeserializer, Error}; 38 | 39 | use super::*; 40 | 41 | #[test] 42 | fn devpts_default() { 43 | assert_eq!(default(), Mode::from(0o600)); 44 | } 45 | 46 | #[test] 47 | fn serialize_string() { 48 | struct Test(Mode); 49 | 50 | impl Display for Test { 51 | fn fmt(&self, f: &mut Formatter) -> fmt::Result { 52 | serialize(&self.0, f) 53 | } 54 | } 55 | 56 | let test = Test(Mode::from(0o755)); 57 | assert_eq!(test.to_string(), "755"); 58 | } 59 | 60 | #[test] 61 | fn deserialize_string() { 62 | let mode = deserialize(BorrowedStrDeserializer::::new("755")).unwrap(); 63 | assert_eq!(mode, Mode::from(0o755)); 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /src/escape.rs: -------------------------------------------------------------------------------- 1 | //! Utilities for escaping strings. 2 | 3 | use std::borrow::Cow; 4 | 5 | /// Join an iterator of command arguments into a [`String`], [quoting](arg_quote()) when necessary. 6 | /// 7 | /// Each argument is separated by a space. 8 | pub(crate) fn command_join(args: I) -> String 9 | where 10 | I: IntoIterator, 11 | I::Item: AsRef, 12 | { 13 | let mut args = args.into_iter(); 14 | 15 | let (lower, upper) = args.size_hint(); 16 | let mut string = String::with_capacity(upper.unwrap_or(lower) * 2); 17 | 18 | if let Some(first) = args.next() { 19 | string.push_str(&arg_quote(first.as_ref())); 20 | } 21 | 22 | for arg in args { 23 | string.push(' '); 24 | string.push_str(&arg_quote(arg.as_ref())); 25 | } 26 | 27 | string 28 | } 29 | 30 | /// Encode a string for use as a shell argument. 31 | /// 32 | /// ASCII control characters that are not whitespace are silently removed. 33 | pub(crate) fn arg_quote(arg: &str) -> Cow { 34 | if arg.contains(char_is_ascii_control_not_whitespace) { 35 | let arg = arg.replace(char_is_ascii_control_not_whitespace, ""); 36 | shlex::try_quote(&arg) 37 | .expect("null characters have been removed") 38 | .into_owned() 39 | .into() 40 | } else { 41 | shlex::try_quote(arg).expect("string does not contain null character") 42 | } 43 | } 44 | 45 | /// Checks if the character is an ASCII control character and is not an ASCII whitespace character. 46 | fn char_is_ascii_control_not_whitespace(char: char) -> bool { 47 | // Do not match on "Horizontal Tab" (\t, \x09), "Line Feed" (\n, \x0A), "Vertical Tab" (\x0B), 48 | // "Form Feed" (\x0C), or "Carriage Return" (\r, \x0D). 49 | char.is_ascii_control() && !matches!(char, '\x09'..='\x0D') 50 | } 51 | 52 | #[cfg(test)] 53 | mod tests { 54 | use super::*; 55 | 56 | #[test] 57 | fn quote_remove_control() { 58 | assert_eq!(arg_quote("te\0st"), "test"); 59 | assert_eq!(arg_quote("hello\nworld"), "'hello\nworld'"); 60 | } 61 | 62 | #[test] 63 | fn join() { 64 | assert_eq!(command_join(["test", "hello world"]), "test 'hello world'"); 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/quadlet/globals.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | fmt::{self, Display, Formatter}, 3 | path::PathBuf, 4 | }; 5 | 6 | use serde::Serialize; 7 | 8 | use super::{Downgrade, DowngradeError, HostPaths, PodmanVersion}; 9 | 10 | /// Global Quadlet options that apply to all resource types. 11 | #[derive(Serialize, Debug, Default, Clone, PartialEq)] 12 | #[serde(rename_all = "PascalCase")] 13 | pub struct Globals { 14 | /// Load the specified containers.conf module. 15 | pub containers_conf_module: Vec, 16 | 17 | /// A list of arguments passed directly after `podman`. 18 | pub global_args: Option, 19 | } 20 | 21 | impl Downgrade for Globals { 22 | fn downgrade(&mut self, version: PodmanVersion) -> Result<(), DowngradeError> { 23 | if version < PodmanVersion::V4_8 { 24 | if let Some(containers_conf_module) = 25 | std::mem::take(&mut self.containers_conf_module).first() 26 | { 27 | return Err(DowngradeError::Option { 28 | quadlet_option: "ContainersConfModule", 29 | value: containers_conf_module.display().to_string(), 30 | supported_version: PodmanVersion::V4_8, 31 | }); 32 | } 33 | 34 | if let Some(global_args) = self.global_args.take() { 35 | return Err(DowngradeError::Option { 36 | quadlet_option: "GlobalArgs", 37 | value: global_args, 38 | supported_version: PodmanVersion::V4_8, 39 | }); 40 | } 41 | } 42 | 43 | Ok(()) 44 | } 45 | } 46 | 47 | impl HostPaths for Globals { 48 | fn host_paths(&mut self) -> impl Iterator { 49 | self.containers_conf_module.iter_mut() 50 | } 51 | } 52 | 53 | impl Display for Globals { 54 | fn fmt(&self, f: &mut Formatter) -> fmt::Result { 55 | let globals = 56 | crate::serde::quadlet::to_string_no_table_name(self).map_err(|_| fmt::Error)?; 57 | f.write_str(&globals) 58 | } 59 | } 60 | 61 | #[cfg(test)] 62 | mod tests { 63 | use super::*; 64 | 65 | #[test] 66 | fn default_display_empty() { 67 | let globals = Globals::default(); 68 | assert!(globals.to_string().is_empty(), "globals: {globals}"); 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /.github/workflows/ci.yaml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | 9 | env: 10 | CARGO_TERM_COLOR: always 11 | 12 | jobs: 13 | format: 14 | runs-on: ubuntu-latest 15 | steps: 16 | - name: Checkout 17 | uses: actions/checkout@v4 18 | 19 | - name: Rust Toolchain 20 | uses: dtolnay/rust-toolchain@stable 21 | with: 22 | components: rustfmt 23 | 24 | - run: cargo fmt --verbose --check 25 | 26 | clippy: 27 | runs-on: ubuntu-latest 28 | steps: 29 | - name: Checkout 30 | uses: actions/checkout@v4 31 | 32 | - name: Rust Toolchain 33 | uses: dtolnay/rust-toolchain@stable 34 | with: 35 | components: clippy 36 | 37 | - run: cargo clippy -- -Dwarnings 38 | 39 | test: 40 | runs-on: ubuntu-latest 41 | steps: 42 | - name: Checkout 43 | uses: actions/checkout@v4 44 | 45 | - name: Rust Toolchain 46 | uses: dtolnay/rust-toolchain@stable 47 | 48 | - run: cargo test --verbose 49 | 50 | build: 51 | strategy: 52 | fail-fast: false 53 | matrix: 54 | runner: [ubuntu-latest, windows-latest, macos-latest] 55 | runs-on: ${{ matrix.runner }} 56 | steps: 57 | - name: Checkout 58 | uses: actions/checkout@v4 59 | 60 | - name: Rust Toolchain 61 | uses: dtolnay/rust-toolchain@stable 62 | 63 | - run: cargo build --verbose 64 | 65 | build-container: 66 | needs: build 67 | runs-on: ubuntu-latest 68 | env: 69 | MANIFEST: podlet-multiarch 70 | container: 71 | image: quay.io/containers/buildah:latest 72 | options: --security-opt seccomp=unconfined --security-opt apparmor=unconfined --device /dev/fuse:rw 73 | steps: 74 | - name: Checkout 75 | uses: actions/checkout@v4 76 | 77 | - run: buildah version 78 | 79 | - name: Create manifest 80 | run: | 81 | buildah manifest create \ 82 | --annotation "org.opencontainers.image.source=https://github.com/containers/podlet" \ 83 | --annotation '"org.opencontainers.image.description=Generate Podman Quadlet files from a Podman command, compose file, or existing object"' \ 84 | --annotation "org.opencontainers.image.licenses=MPL-2.0" \ 85 | "${MANIFEST}" 86 | 87 | - name: Build ARM image 88 | run: buildah build --manifest "${MANIFEST}" --platform linux/arm64/v8 -t podlet . 89 | 90 | - name: Build x86 image 91 | run: buildah build --manifest "${MANIFEST}" --platform linux/amd64 -t podlet . 92 | -------------------------------------------------------------------------------- /src/quadlet/pod.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | fmt::{self, Display, Formatter}, 3 | path::PathBuf, 4 | }; 5 | 6 | use serde::Serialize; 7 | 8 | use super::{container::Volume, Downgrade, DowngradeError, HostPaths, PodmanVersion, ResourceKind}; 9 | 10 | /// Options for the \[Pod\] section of a `.pod` Quadlet file. 11 | #[derive(Serialize, Debug, Default, Clone, PartialEq)] 12 | #[serde(rename_all = "PascalCase")] 13 | pub struct Pod { 14 | /// Specify a custom network for the pod. 15 | pub network: Vec, 16 | 17 | /// Add a network-scoped alias for the pod. 18 | pub network_alias: Vec, 19 | 20 | /// A list of arguments passed directly to the end of the `podman pod create` command in the 21 | /// generated file. 22 | pub podman_args: Option, 23 | 24 | /// The name of the Podman pod. 25 | /// 26 | /// If not set, the default value is `systemd-%N`. 27 | #[allow(clippy::struct_field_names)] 28 | pub pod_name: Option, 29 | 30 | /// Exposes a port, or a range of ports, from the pod to the host. 31 | pub publish_port: Vec, 32 | 33 | /// Mount a volume in the pod. 34 | pub volume: Vec, 35 | } 36 | 37 | impl Display for Pod { 38 | fn fmt(&self, f: &mut Formatter) -> fmt::Result { 39 | let pod = crate::serde::quadlet::to_string(self).map_err(|_| fmt::Error)?; 40 | f.write_str(&pod) 41 | } 42 | } 43 | 44 | impl HostPaths for Pod { 45 | fn host_paths(&mut self) -> impl Iterator { 46 | self.volume.host_paths() 47 | } 48 | } 49 | 50 | impl Downgrade for Pod { 51 | fn downgrade(&mut self, version: PodmanVersion) -> Result<(), DowngradeError> { 52 | if version < PodmanVersion::V5_2 { 53 | for network_alias in std::mem::take(&mut self.network_alias) { 54 | self.push_arg("network-alias", &network_alias); 55 | } 56 | } 57 | 58 | if version < PodmanVersion::V5_0 { 59 | return Err(DowngradeError::Kind { 60 | kind: ResourceKind::Pod, 61 | supported_version: PodmanVersion::V5_0, 62 | }); 63 | } 64 | 65 | Ok(()) 66 | } 67 | } 68 | 69 | impl Pod { 70 | /// Add `--{flag} {arg}` to `PodmanArgs=`. 71 | fn push_arg(&mut self, flag: &str, arg: &str) { 72 | let podman_args = self.podman_args.get_or_insert_with(String::new); 73 | if !podman_args.is_empty() { 74 | podman_args.push(' '); 75 | } 76 | podman_args.push_str("--"); 77 | podman_args.push_str(flag); 78 | podman_args.push(' '); 79 | podman_args.push_str(arg); 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /src/cli/volume.rs: -------------------------------------------------------------------------------- 1 | mod opt; 2 | 3 | use clap::{Args, Subcommand}; 4 | 5 | pub use self::opt::Opt; 6 | 7 | #[derive(Subcommand, Debug, Clone, PartialEq)] 8 | pub enum Volume { 9 | /// Generate a Podman Quadlet `.volume` file 10 | /// 11 | /// For details on options see: 12 | /// https://docs.podman.io/en/stable/markdown/podman-volume-create.1.html and 13 | /// https://docs.podman.io/en/stable/markdown/podman-systemd.unit.5.html#volume-units-volume 14 | #[allow(clippy::doc_markdown)] 15 | #[group(skip)] 16 | Create { 17 | #[command(flatten)] 18 | create: Create, 19 | }, 20 | } 21 | 22 | impl From for crate::quadlet::Volume { 23 | fn from(value: Volume) -> Self { 24 | let Volume::Create { create } = value; 25 | create.into() 26 | } 27 | } 28 | 29 | impl From for crate::quadlet::Resource { 30 | fn from(value: Volume) -> Self { 31 | crate::quadlet::Volume::from(value).into() 32 | } 33 | } 34 | 35 | impl Volume { 36 | pub fn name(&self) -> &str { 37 | let Self::Create { create } = self; 38 | &create.name 39 | } 40 | } 41 | 42 | #[derive(Args, Debug, Clone, PartialEq)] 43 | pub struct Create { 44 | /// Specify the volume driver name 45 | /// 46 | /// Converts to "Driver=DRIVER" 47 | #[arg(short, long)] 48 | pub driver: Option, 49 | 50 | /// Set driver specific options 51 | /// 52 | /// "copy" converts to "Copy=true" 53 | /// 54 | /// "device=DEVICE" converts to "Device=DEVICE" 55 | /// 56 | /// "type=TYPE" converts to "Type=TYPE" 57 | /// 58 | /// "o=uid=UID" converts to "User=UID" 59 | /// 60 | /// "o=gid=GID" converts to "Group=GID" 61 | /// 62 | /// "o=OPTIONS" converts to "Options=OPTIONS" 63 | /// 64 | /// Can be specified multiple times 65 | #[arg(short, long, value_name = "OPTION")] 66 | pub opt: Vec, 67 | 68 | /// Set one or more OCI labels on the volume 69 | /// 70 | /// Converts to "Label=KEY=VALUE" 71 | /// 72 | /// Can be specified multiple times 73 | #[arg(short, long, value_name = "KEY=VALUE")] 74 | pub label: Vec, 75 | 76 | /// The name of the volume to create 77 | /// 78 | /// This will be used as the name of the generated file when used with 79 | /// the --file option without a filename 80 | pub name: String, 81 | } 82 | 83 | impl From for crate::quadlet::Volume { 84 | fn from( 85 | Create { 86 | driver, 87 | opt, 88 | label, 89 | name: _, 90 | }: Create, 91 | ) -> Self { 92 | Self { 93 | driver, 94 | label, 95 | ..opt.into() 96 | } 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /demo.yaml: -------------------------------------------------------------------------------- 1 | # demo.yaml 2 | # autocast (https://github.com/k9withabone/autocast) configuration for podlet demo 3 | 4 | # Convert to a GIF and optimize: 5 | # agg --theme monokai --idle-time-limit 20 --font-size 20 demo.cast demo.gif 6 | # gifsicle -O2 -k 64 -Okeep-empty --lossy=80 demo.gif -o demo-opt.gif 7 | # mv demo-opt.gif demo.gif 8 | 9 | settings: 10 | width: 123 11 | height: 47 12 | title: Podlet v0.3.0 Demo 13 | timeout: 90s 14 | type_speed: 90ms 15 | 16 | instructions: 17 | # setup 18 | - !Command 19 | command: cargo build --profile dist 20 | hidden: true 21 | - !Command 22 | command: alias podlet=target/dist/podlet 23 | hidden: true 24 | - !Command 25 | command: podman pull quay.io/podman/hello:latest 26 | hidden: true 27 | 28 | - !Marker podlet help 29 | - !Command 30 | command: podlet -h 31 | - !Wait 7s 32 | - !Clear 33 | 34 | - !Marker podlet podman help 35 | - !Command 36 | command: podlet podman -h 37 | - !Wait 6s 38 | - !Clear 39 | 40 | - !Marker podlet podman run 41 | - !Command 42 | command: | 43 | podlet 44 | podman run 45 | -p 8000:80 46 | -p 8443:443 47 | -v ./Caddyfile:/etc/caddy/Caddyfile:Z 48 | -v caddy-data:/data 49 | docker.io/library/caddy:latest 50 | type_speed: 75ms 51 | - !Wait 6s 52 | - !Clear 53 | - !Command 54 | command: | 55 | podlet --file . --install 56 | podman run 57 | --restart always 58 | -p 8000:80 59 | -p 8443:443 60 | -v ./Caddyfile:/etc/caddy/Caddyfile:Z 61 | -v caddy-data:/data 62 | docker.io/library/caddy:latest 63 | type_speed: 75ms 64 | - !Wait 3s 65 | - !Command 66 | command: cat caddy.container 67 | - !Wait 8s 68 | - !Clear 69 | 70 | - !Marker podlet compose 71 | - !Command 72 | command: cat compose-example.yaml 73 | - !Wait 250ms 74 | - !Command 75 | command: podlet compose compose-example.yaml 76 | - !Wait 5s 77 | - !Command 78 | command: podlet compose --pod compose-example.yaml 79 | type_speed: 80ms 80 | - !Wait 7s 81 | - !Command 82 | command: podlet compose --kube compose-example.yaml 83 | type_speed: 80ms 84 | - !Wait 7s 85 | - !Clear 86 | 87 | - !Marker podlet generate help 88 | - !Command 89 | command: podlet generate -h 90 | - !Wait 6s 91 | - !Clear 92 | 93 | - !Marker podlet generate container 94 | - !Command 95 | command: podman container create --name hello quay.io/podman/hello:latest 96 | type_speed: 80ms 97 | - !Wait 2s 98 | - !Command 99 | command: podlet generate container hello 100 | type_speed: 80ms 101 | - !Wait 5s 102 | 103 | # cleanup 104 | - !Command 105 | command: rm caddy.container 106 | hidden: true 107 | - !Command 108 | command: podman rm hello 109 | hidden: true 110 | - !Command 111 | command: unalias podlet 112 | hidden: true 113 | -------------------------------------------------------------------------------- /src/serde.rs: -------------------------------------------------------------------------------- 1 | //! Provides [`serde::Serializer`]s for serializing command line args and Quadlet files, 2 | //! accessible through [`args::to_string()`] and [`quadlet::to_string()`]. 3 | //! 4 | //! Also provides a [`serde::Serializer`] and [`serde::Deserializer`] for (de)serializing mount 5 | //! options via [`mount_options::to_string()`] and [`mount_options::from_str()`]. 6 | 7 | use std::fmt::Display; 8 | 9 | use serde::{ser::SerializeSeq, Serializer}; 10 | 11 | /// Implement [`serde::Serializer`]'s `serialize_*` functions by returning `Err($error)`. 12 | macro_rules! serialize_invalid_primitives { 13 | ($error:expr, $($f:ident: $t:ty,)*) => { 14 | $( 15 | fn $f(self, _v: $t) -> Result { 16 | Err($error) 17 | } 18 | )* 19 | }; 20 | } 21 | 22 | /// Implement [`serde::Serializer`]'s `serialize_*` functions by executing `self.$write_fn(v)`. 23 | macro_rules! serialize_primitives { 24 | ($write_fn:ident, $($f:ident: $t:ty,)*) => { 25 | $( 26 | fn $f(self, v: $t) -> Result { 27 | self.$write_fn(v); 28 | Ok(()) 29 | } 30 | )* 31 | }; 32 | } 33 | 34 | /// Implement [`serde::Deserializer`]'s `deserialize_*` functions by parsing `input` and calling the 35 | /// appropriate [`serde::de::Visitor`] function. 36 | macro_rules! deserialize_parse { 37 | ($de:lifetime, $input:ident, $($f:ident => $visit:ident,)*) => { 38 | $( 39 | fn $f(self, visitor: V) -> Result 40 | where 41 | V: ::serde::de::Visitor<$de>, 42 | { 43 | if let Ok(value) = self.$input.parse() { 44 | visitor.$visit(value) 45 | } else { 46 | Err(::serde::de::Error::invalid_type( 47 | ::serde::de::Unexpected::Str(self.$input), 48 | &visitor, 49 | )) 50 | } 51 | } 52 | )* 53 | }; 54 | } 55 | 56 | pub mod args; 57 | pub mod mount_options; 58 | pub mod quadlet; 59 | 60 | /// Skip serializing `bool`s that are `true`. 61 | /// For use with `#[serde(skip_serializing_if = "skip_true")]`. 62 | // ref required for serde's skip_serializing_if 63 | #[allow(clippy::trivially_copy_pass_by_ref)] 64 | pub fn skip_true(bool: &bool) -> bool { 65 | *bool 66 | } 67 | 68 | /// Skip serializing default values 69 | pub fn skip_default(value: &T) -> bool 70 | where 71 | T: Default + PartialEq, 72 | { 73 | *value == T::default() 74 | } 75 | 76 | /// Serialize a sequence of items as strings using their [`Display`] implementation. 77 | pub fn serialize_display_seq<'a, T, S>(value: &'a T, serializer: S) -> Result 78 | where 79 | &'a T: IntoIterator, 80 | <&'a T as IntoIterator>::Item: Display, 81 | S: Serializer, 82 | { 83 | let iter = value.into_iter(); 84 | let (_, len) = iter.size_hint(); 85 | 86 | let mut state = serializer.serialize_seq(len)?; 87 | 88 | for item in iter { 89 | state.serialize_element(&item.to_string())?; 90 | } 91 | 92 | state.end() 93 | } 94 | -------------------------------------------------------------------------------- /src/cli/volume/opt.rs: -------------------------------------------------------------------------------- 1 | use std::{convert::Infallible, path::PathBuf, str::FromStr}; 2 | 3 | use thiserror::Error; 4 | 5 | /// Options from `podman volume create --opt` 6 | #[derive(Debug, Clone, PartialEq)] 7 | pub enum Opt { 8 | /// `--opt type=` 9 | Type(String), 10 | /// `--opt device=` 11 | Device(PathBuf), 12 | /// `--opt copy` 13 | Copy, 14 | /// `--opt o=` 15 | Mount(Vec), 16 | /// `--opt image=` 17 | Image(String), 18 | } 19 | 20 | impl Opt { 21 | /// Parse from an `option` and its `value`, 22 | /// equivalent to `podman volume create --opt