├── .github └── workflows │ ├── ci.yaml │ ├── release-container.yml │ └── release.yml ├── .gitignore ├── CHANGELOG.md ├── CODE-OF-CONDUCT.md ├── CONTRIBUTING.md ├── Cargo.lock ├── Cargo.toml ├── Containerfile ├── LICENSE ├── README.md ├── SECURITY.md ├── clippy.toml ├── compose-example.yaml ├── demo.gif ├── demo.yaml └── src ├── cli.rs ├── cli ├── build.rs ├── compose.rs ├── container.rs ├── container │ ├── compose.rs │ ├── podman.rs │ ├── quadlet.rs │ └── security_opt.rs ├── generate.rs ├── global_args.rs ├── image.rs ├── install.rs ├── k8s.rs ├── k8s │ ├── service.rs │ ├── service │ │ └── mount.rs │ └── volume.rs ├── kube.rs ├── network.rs ├── pod.rs ├── service.rs ├── systemd_dbus.rs ├── unit.rs ├── volume.rs └── volume │ └── opt.rs ├── escape.rs ├── main.rs ├── quadlet.rs ├── quadlet ├── build.rs ├── container.rs ├── container │ ├── device.rs │ ├── mount.rs │ ├── mount │ │ ├── idmap.rs │ │ ├── mode.rs │ │ └── tmpfs.rs │ ├── rootfs.rs │ └── volume.rs ├── globals.rs ├── image.rs ├── install.rs ├── kube.rs ├── network.rs ├── pod.rs └── volume.rs ├── serde.rs └── serde ├── args.rs ├── mount_options.rs ├── mount_options ├── de.rs └── ser.rs └── quadlet.rs /.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 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | # Copyright 2022-2024, axodotdev 2 | # SPDX-License-Identifier: MIT or Apache-2.0 3 | # 4 | # CI that: 5 | # 6 | # * checks for a Git Tag that looks like a release 7 | # * builds artifacts with cargo-dist (archives, installers, hashes) 8 | # * uploads those artifacts to temporary workflow zip 9 | # * on success, uploads the artifacts to a GitHub Release 10 | # 11 | # Note that the GitHub Release will be created with a generated 12 | # title/body based on your changelogs. 13 | 14 | name: Release 15 | 16 | permissions: 17 | contents: write 18 | 19 | # This task will run whenever you push a git tag that looks like a version 20 | # like "1.0.0", "v0.1.0-prerelease.1", "my-app/0.1.0", "releases/v1.0.0", etc. 21 | # Various formats will be parsed into a VERSION and an optional PACKAGE_NAME, where 22 | # PACKAGE_NAME must be the name of a Cargo package in your workspace, and VERSION 23 | # must be a Cargo-style SemVer Version (must have at least major.minor.patch). 24 | # 25 | # If PACKAGE_NAME is specified, then the announcement will be for that 26 | # package (erroring out if it doesn't have the given version or isn't cargo-dist-able). 27 | # 28 | # If PACKAGE_NAME isn't specified, then the announcement will be for all 29 | # (cargo-dist-able) packages in the workspace with that version (this mode is 30 | # intended for workspaces with only one dist-able package, or with all dist-able 31 | # packages versioned/released in lockstep). 32 | # 33 | # If you push multiple tags at once, separate instances of this workflow will 34 | # spin up, creating an independent announcement for each one. However, GitHub 35 | # will hard limit this to 3 tags per commit, as it will assume more tags is a 36 | # mistake. 37 | # 38 | # If there's a prerelease-style suffix to the version, then the release(s) 39 | # will be marked as a prerelease. 40 | on: 41 | push: 42 | tags: 43 | - '**[0-9]+.[0-9]+.[0-9]+*' 44 | pull_request: 45 | 46 | jobs: 47 | # Run 'cargo dist plan' (or host) to determine what tasks we need to do 48 | plan: 49 | runs-on: ubuntu-latest 50 | outputs: 51 | val: ${{ steps.plan.outputs.manifest }} 52 | tag: ${{ !github.event.pull_request && github.ref_name || '' }} 53 | tag-flag: ${{ !github.event.pull_request && format('--tag={0}', github.ref_name) || '' }} 54 | publishing: ${{ !github.event.pull_request }} 55 | env: 56 | GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} 57 | steps: 58 | - uses: actions/checkout@v4 59 | with: 60 | submodules: recursive 61 | - name: Install cargo-dist 62 | # we specify bash to get pipefail; it guards against the `curl` command 63 | # failing. otherwise `sh` won't catch that `curl` returned non-0 64 | shell: bash 65 | run: "curl --proto '=https' --tlsv1.2 -LsSf https://github.com/axodotdev/cargo-dist/releases/download/v0.14.1/cargo-dist-installer.sh | sh" 66 | # sure would be cool if github gave us proper conditionals... 67 | # so here's a doubly-nested ternary-via-truthiness to try to provide the best possible 68 | # functionality based on whether this is a pull_request, and whether it's from a fork. 69 | # (PRs run on the *source* but secrets are usually on the *target* -- that's *good* 70 | # but also really annoying to build CI around when it needs secrets to work right.) 71 | - id: plan 72 | run: | 73 | cargo dist ${{ (!github.event.pull_request && format('host --steps=create --tag={0}', github.ref_name)) || 'plan' }} --output-format=json > plan-dist-manifest.json 74 | echo "cargo dist ran successfully" 75 | cat plan-dist-manifest.json 76 | echo "manifest=$(jq -c "." plan-dist-manifest.json)" >> "$GITHUB_OUTPUT" 77 | - name: "Upload dist-manifest.json" 78 | uses: actions/upload-artifact@v4 79 | with: 80 | name: artifacts-plan-dist-manifest 81 | path: plan-dist-manifest.json 82 | 83 | # Build and packages all the platform-specific things 84 | build-local-artifacts: 85 | name: build-local-artifacts (${{ join(matrix.targets, ', ') }}) 86 | # Let the initial task tell us to not run (currently very blunt) 87 | needs: 88 | - plan 89 | if: ${{ fromJson(needs.plan.outputs.val).ci.github.artifacts_matrix.include != null && (needs.plan.outputs.publishing == 'true' || fromJson(needs.plan.outputs.val).ci.github.pr_run_mode == 'upload') }} 90 | strategy: 91 | fail-fast: false 92 | # Target platforms/runners are computed by cargo-dist in create-release. 93 | # Each member of the matrix has the following arguments: 94 | # 95 | # - runner: the github runner 96 | # - dist-args: cli flags to pass to cargo dist 97 | # - install-dist: expression to run to install cargo-dist on the runner 98 | # 99 | # Typically there will be: 100 | # - 1 "global" task that builds universal installers 101 | # - N "local" tasks that build each platform's binaries and platform-specific installers 102 | matrix: ${{ fromJson(needs.plan.outputs.val).ci.github.artifacts_matrix }} 103 | runs-on: ${{ matrix.runner }} 104 | env: 105 | GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} 106 | BUILD_MANIFEST_NAME: target/distrib/${{ join(matrix.targets, '-') }}-dist-manifest.json 107 | steps: 108 | - name: enable windows longpaths 109 | run: | 110 | git config --global core.longpaths true 111 | - uses: actions/checkout@v4 112 | with: 113 | submodules: recursive 114 | - uses: swatinem/rust-cache@v2 115 | with: 116 | key: ${{ join(matrix.targets, '-') }} 117 | - name: Install cargo-dist 118 | run: ${{ matrix.install_dist }} 119 | # Get the dist-manifest 120 | - name: Fetch local artifacts 121 | uses: actions/download-artifact@v4 122 | with: 123 | pattern: artifacts-* 124 | path: target/distrib/ 125 | merge-multiple: true 126 | - name: Install dependencies 127 | run: | 128 | ${{ matrix.packages_install }} 129 | - name: Build artifacts 130 | run: | 131 | # Actually do builds and make zips and whatnot 132 | cargo dist build ${{ needs.plan.outputs.tag-flag }} --print=linkage --output-format=json ${{ matrix.dist_args }} > dist-manifest.json 133 | echo "cargo dist ran successfully" 134 | - id: cargo-dist 135 | name: Post-build 136 | # We force bash here just because github makes it really hard to get values up 137 | # to "real" actions without writing to env-vars, and writing to env-vars has 138 | # inconsistent syntax between shell and powershell. 139 | shell: bash 140 | run: | 141 | # Parse out what we just built and upload it to scratch storage 142 | echo "paths<> "$GITHUB_OUTPUT" 143 | jq --raw-output ".upload_files[]" dist-manifest.json >> "$GITHUB_OUTPUT" 144 | echo "EOF" >> "$GITHUB_OUTPUT" 145 | 146 | cp dist-manifest.json "$BUILD_MANIFEST_NAME" 147 | - name: "Upload artifacts" 148 | uses: actions/upload-artifact@v4 149 | with: 150 | name: artifacts-build-local-${{ join(matrix.targets, '_') }} 151 | path: | 152 | ${{ steps.cargo-dist.outputs.paths }} 153 | ${{ env.BUILD_MANIFEST_NAME }} 154 | 155 | # Build and package all the platform-agnostic(ish) things 156 | build-global-artifacts: 157 | needs: 158 | - plan 159 | - build-local-artifacts 160 | runs-on: "ubuntu-20.04" 161 | env: 162 | GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} 163 | BUILD_MANIFEST_NAME: target/distrib/global-dist-manifest.json 164 | steps: 165 | - uses: actions/checkout@v4 166 | with: 167 | submodules: recursive 168 | - name: Install cargo-dist 169 | shell: bash 170 | run: "curl --proto '=https' --tlsv1.2 -LsSf https://github.com/axodotdev/cargo-dist/releases/download/v0.14.1/cargo-dist-installer.sh | sh" 171 | # Get all the local artifacts for the global tasks to use (for e.g. checksums) 172 | - name: Fetch local artifacts 173 | uses: actions/download-artifact@v4 174 | with: 175 | pattern: artifacts-* 176 | path: target/distrib/ 177 | merge-multiple: true 178 | - id: cargo-dist 179 | shell: bash 180 | run: | 181 | cargo dist build ${{ needs.plan.outputs.tag-flag }} --output-format=json "--artifacts=global" > dist-manifest.json 182 | echo "cargo dist ran successfully" 183 | 184 | # Parse out what we just built and upload it to scratch storage 185 | echo "paths<> "$GITHUB_OUTPUT" 186 | jq --raw-output ".upload_files[]" dist-manifest.json >> "$GITHUB_OUTPUT" 187 | echo "EOF" >> "$GITHUB_OUTPUT" 188 | 189 | cp dist-manifest.json "$BUILD_MANIFEST_NAME" 190 | - name: "Upload artifacts" 191 | uses: actions/upload-artifact@v4 192 | with: 193 | name: artifacts-build-global 194 | path: | 195 | ${{ steps.cargo-dist.outputs.paths }} 196 | ${{ env.BUILD_MANIFEST_NAME }} 197 | # Determines if we should publish/announce 198 | host: 199 | needs: 200 | - plan 201 | - build-local-artifacts 202 | - build-global-artifacts 203 | # Only run if we're "publishing", and only if local and global didn't fail (skipped is fine) 204 | if: ${{ always() && needs.plan.outputs.publishing == 'true' && (needs.build-global-artifacts.result == 'skipped' || needs.build-global-artifacts.result == 'success') && (needs.build-local-artifacts.result == 'skipped' || needs.build-local-artifacts.result == 'success') }} 205 | env: 206 | GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} 207 | runs-on: "ubuntu-20.04" 208 | outputs: 209 | val: ${{ steps.host.outputs.manifest }} 210 | steps: 211 | - uses: actions/checkout@v4 212 | with: 213 | submodules: recursive 214 | - name: Install cargo-dist 215 | run: "curl --proto '=https' --tlsv1.2 -LsSf https://github.com/axodotdev/cargo-dist/releases/download/v0.14.1/cargo-dist-installer.sh | sh" 216 | # Fetch artifacts from scratch-storage 217 | - name: Fetch artifacts 218 | uses: actions/download-artifact@v4 219 | with: 220 | pattern: artifacts-* 221 | path: target/distrib/ 222 | merge-multiple: true 223 | # This is a harmless no-op for GitHub Releases, hosting for that happens in "announce" 224 | - id: host 225 | shell: bash 226 | run: | 227 | cargo dist host ${{ needs.plan.outputs.tag-flag }} --steps=upload --steps=release --output-format=json > dist-manifest.json 228 | echo "artifacts uploaded and released successfully" 229 | cat dist-manifest.json 230 | echo "manifest=$(jq -c "." dist-manifest.json)" >> "$GITHUB_OUTPUT" 231 | - name: "Upload dist-manifest.json" 232 | uses: actions/upload-artifact@v4 233 | with: 234 | # Overwrite the previous copy 235 | name: artifacts-dist-manifest 236 | path: dist-manifest.json 237 | 238 | # Create a GitHub Release while uploading all files to it 239 | announce: 240 | needs: 241 | - plan 242 | - host 243 | # use "always() && ..." to allow us to wait for all publish jobs while 244 | # still allowing individual publish jobs to skip themselves (for prereleases). 245 | # "host" however must run to completion, no skipping allowed! 246 | if: ${{ always() && needs.host.result == 'success' }} 247 | runs-on: "ubuntu-20.04" 248 | env: 249 | GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} 250 | steps: 251 | - uses: actions/checkout@v4 252 | with: 253 | submodules: recursive 254 | - name: "Download GitHub Artifacts" 255 | uses: actions/download-artifact@v4 256 | with: 257 | pattern: artifacts-* 258 | path: artifacts 259 | merge-multiple: true 260 | - name: Cleanup 261 | run: | 262 | # Remove the granular manifests 263 | rm -f artifacts/*-dist-manifest.json 264 | - name: Create GitHub Release 265 | uses: ncipollo/release-action@v1 266 | with: 267 | tag: ${{ needs.plan.outputs.tag }} 268 | name: ${{ fromJson(needs.host.outputs.val).announcement_title }} 269 | body: ${{ fromJson(needs.host.outputs.val).announcement_github_body }} 270 | prerelease: ${{ fromJson(needs.host.outputs.val).announcement_is_prerelease }} 271 | artifacts: "artifacts/*" 272 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to Podlet 2 | 3 | We'd love to have you join the community! Below summarizes the processes that we follow. 4 | 5 | ## Reporting Issues 6 | 7 | Before reporting an issue, check our backlog of 8 | [open issues](https://github.com/containers/podlet/issues) 9 | to see if someone else has already reported it. If so, feel free to add 10 | your scenario, or additional information, to the discussion. Or simply 11 | "subscribe" to it to be notified when it is updated. 12 | 13 | If you find a new issue with the project we'd love to hear about it! The most 14 | important aspect of a bug report is that it includes enough information for 15 | us to reproduce it. So, please include as much detail as possible and try 16 | to remove the extra stuff that doesn't really relate to the issue itself. 17 | The easier it is for us to reproduce it, the faster it'll be fixed! 18 | 19 | Please don't include any private/sensitive information in your issue! 20 | 21 | ## Submitting Pull Requests 22 | 23 | No [pull request] (PR) is too small! Typos, additional comments in the code, 24 | new test cases, bug fixes, new features, more documentation, ... it's all 25 | welcome! 26 | 27 | While bug fixes can first be identified via an [issue], that is not required. 28 | It's ok to just open up a PR with the fix, but make sure you include the same 29 | information you would have included in an issue - like how to reproduce it. 30 | 31 | PRs for new features should include some background on what use cases the 32 | new code is trying to address. When possible and when it makes sense, try to break up 33 | larger PRs into smaller ones - it's easier to review smaller 34 | code changes. But only if those smaller ones make sense as stand-alone PRs. 35 | 36 | Commits that fix issues should include one or more footers like `Closes: #XXX` or `Fixes: #XXX` at the 37 | end of the commit message. GitHub will automatically close the referenced issue when the PR is merged 38 | and the [changelog] will include the issue. 39 | 40 | ### Use Conventional Commits 41 | 42 | While not a requirement, try to use [conventional commits](https://www.conventionalcommits.org) for 43 | your commit messages. It makes creating the [changelog] via [git-cliff](https://git-cliff.org/) easier. 44 | 45 | ### Sign Your Commits 46 | 47 | For a PR to be merged, each commit must contain a `Signed-off-by` footer. The sign-off is a 48 | line at the end of the explanation for the commit. Your signature certifies that you wrote the patch 49 | or otherwise have the right to pass it on as an open-source patch. 50 | 51 | The rules are simple: if you can certify the following (from [developercertificate.org](https://developercertificate.org/)): 52 | 53 | ``` 54 | Developer Certificate of Origin 55 | Version 1.1 56 | 57 | Copyright (C) 2004, 2006 The Linux Foundation and its contributors. 58 | 660 York Street, Suite 102, 59 | San Francisco, CA 94110 USA 60 | 61 | Everyone is permitted to copy and distribute verbatim copies of this 62 | license document, but changing it is not allowed. 63 | 64 | Developer's Certificate of Origin 1.1 65 | 66 | By making a contribution to this project, I certify that: 67 | 68 | (a) The contribution was created in whole or in part by me and I 69 | have the right to submit it under the open source license 70 | indicated in the file; or 71 | 72 | (b) The contribution is based upon previous work that, to the best 73 | of my knowledge, is covered under an appropriate open source 74 | license and I have the right under that license to submit that 75 | work with modifications, whether created in whole or in part 76 | by me, under the same open source license (unless I am 77 | permitted to submit under a different license), as indicated 78 | in the file; or 79 | 80 | (c) The contribution was provided directly to me by some other 81 | person who certified (a), (b) or (c) and I have not modified 82 | it. 83 | 84 | (d) I understand and agree that this project and the contribution 85 | are public and that a record of the contribution (including all 86 | personal information I submit with it, including my sign-off) is 87 | maintained indefinitely and may be redistributed consistent with 88 | this project or the open source license(s) involved. 89 | ``` 90 | 91 | Then you just add a line to every git commit message: 92 | 93 | ``` 94 | Signed-off-by: Joe Smith 95 | ``` 96 | 97 | Use your real name (sorry, no pseudonyms or anonymous contributions). 98 | 99 | If you set your `user.name` and `user.email` git configs, you can sign your 100 | commit automatically with `git commit -s`. 101 | 102 | ## Building 103 | 104 | Podlet is a normal Rust project, so once [Rust is installed], 105 | the source code can be cloned and built with: 106 | 107 | ```shell 108 | git clone git@github.com:containers/podlet.git 109 | cd podlet 110 | cargo build 111 | ``` 112 | 113 | Release builds are created with the `dist` profile: 114 | 115 | ```shell 116 | cargo build --profile dist 117 | ``` 118 | 119 | ## Continuous Integration 120 | 121 | A number of jobs are automatically run for each pull request and merge. 122 | If you are submitting code changes and would like to run the CI jobs locally, 123 | below is a list of all the jobs with explanations and the commands that they run. 124 | 125 | - format: 126 | - Ensures consistent formatting for all Rust code. 127 | - `cargo fmt --check` 128 | - clippy: 129 | - [Clippy](https://github.com/rust-lang/rust-clippy) is a collection of lints for Rust. 130 | - If [Rust is installed] via `rustup`, install Clippy with `rustup component add clippy`. 131 | - Lints are configured in the [`Cargo.toml`](./Cargo.toml) file. 132 | - It's ok to use `#[allow(...)]` to override a lint, 133 | but try to document the reasoning if it's not obvious. 134 | - `cargo clippy` 135 | - test: 136 | - Unit tests are defined in the source. 137 | - All tests should pass. 138 | - `cargo test` 139 | - build: 140 | - Ensures Podlet can build on all target platforms. 141 | - `cargo build` 142 | - build-container: 143 | - Ensures that the [Podlet container](./Containerfile) can build for both x86 and ARM platforms. 144 | - First, [install Buildah](https://github.com/containers/buildah/blob/main/install.md). 145 | - `buildah build --platform linux/amd64 -t podlet .` 146 | - `buildah build --platform linux/arm64/v8 -t podlet .` 147 | 148 | ## Communication 149 | 150 | The Podlet project shares communication channels with other projects in the [Containers organization](https://github.com/containers#-community). 151 | 152 | For discussions about issues, bugs, or features, feel free to create an [issue], [discussion], or [pull request] on GitHub. 153 | 154 | 155 | [changelog]: ./CHANGELOG.md 156 | [discussion]: https://github.com/containers/podlet/discussions 157 | [issue]: https://github.com/containers/podlet/issues 158 | [pull request]: https://github.com/containers/podlet/pulls 159 | [Rust is installed]: https://www.rust-lang.org/tools/install 160 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "podlet" 3 | version = "0.3.0" 4 | authors = ["Paul Nettleton "] 5 | edition = "2021" 6 | description = "Generate Podman Quadlet files from a Podman command, compose file, or existing object" 7 | readme = "README.md" 8 | repository = "https://github.com/containers/podlet" 9 | license = "MPL-2.0" 10 | keywords = ["podman", "quadlet", "containers"] 11 | categories = ["command-line-utilities"] 12 | 13 | [lints.rust] 14 | unused_crate_dependencies = "warn" 15 | unused_import_braces = "warn" 16 | unused_lifetimes = "warn" 17 | unused_macro_rules = "warn" 18 | unused_qualifications = "warn" 19 | 20 | [lints.clippy] 21 | pedantic = { level = "warn", priority = -1 } 22 | 23 | cargo = { level = "warn", priority = -1 } 24 | multiple_crate_versions = "allow" 25 | 26 | # restriction lint group 27 | clone_on_ref_ptr = "warn" 28 | dbg_macro = "warn" 29 | empty_drop = "warn" 30 | empty_structs_with_brackets = "warn" 31 | exit = "warn" 32 | format_push_string = "warn" 33 | if_then_some_else_none = "warn" 34 | indexing_slicing = "warn" 35 | integer_division = "warn" 36 | mixed_read_write_in_expression = "warn" 37 | mod_module_files = "warn" 38 | multiple_inherent_impl = "warn" 39 | needless_raw_strings = "warn" 40 | panic = "warn" 41 | pub_without_shorthand = "warn" 42 | rc_buffer = "warn" 43 | rc_mutex = "warn" 44 | redundant_type_annotations = "warn" 45 | rest_pat_in_fully_bound_structs = "warn" 46 | same_name_method = "warn" 47 | semicolon_outside_block = "warn" 48 | string_slice = "warn" 49 | string_to_string = "warn" 50 | suspicious_xor_used_as_pow = "warn" 51 | tests_outside_test_module = "warn" 52 | todo = "warn" 53 | try_err = "warn" 54 | unimplemented = "warn" 55 | unnecessary_self_imports = "warn" 56 | unreachable = "warn" 57 | unwrap_used = "warn" 58 | verbose_file_reads = "warn" 59 | 60 | [dependencies] 61 | clap = { version = "4.2", features = ["derive", "wrap_help"] } 62 | color-eyre = "0.6" 63 | compose_spec = "0.3.0" 64 | indexmap = { version = "2", features = ["serde"] } 65 | ipnet = { version = "2.7", features = ["serde"] } 66 | k8s-openapi = { version = "0.22.0", features = ["latest"] } 67 | path-clean = "1" 68 | serde = { version = "1", features = ["derive"] } 69 | serde_json = "1" 70 | serde_yaml = "0.9.21" 71 | shlex = "1.3" 72 | smart-default = "0.7" 73 | thiserror = "1.0.40" 74 | umask = "2.1.0" 75 | url = "2.3" 76 | 77 | [target.'cfg(unix)'.dependencies] 78 | nix = { version = "0.28.0", features = ["user"] } 79 | zbus = "4.0.0" 80 | 81 | # The profile that 'cargo dist' will build with 82 | [profile.dist] 83 | inherits = "release" 84 | lto = "thin" 85 | 86 | # Config for 'cargo dist' 87 | [workspace.metadata.dist] 88 | # The preferred cargo-dist version to use in CI (Cargo.toml SemVer syntax) 89 | cargo-dist-version = "0.14.1" 90 | # CI backends to support 91 | ci = "github" 92 | # Target platforms to build apps for (Rust target-triple syntax) 93 | targets = [ 94 | "aarch64-apple-darwin", 95 | "x86_64-apple-darwin", 96 | "x86_64-unknown-linux-gnu", 97 | "x86_64-unknown-linux-musl", 98 | "x86_64-pc-windows-msvc", 99 | ] 100 | # The installers to generate for each app 101 | installers = [] 102 | # Publish jobs to run in CI 103 | pr-run-mode = "plan" 104 | 105 | # Config for 'git cliff' 106 | # Run with `GITHUB_TOKEN=$(gh auth token) git cliff --bump -up CHANGELOG.md` 107 | # https://git-cliff.org/docs/configuration 108 | [workspace.metadata.git-cliff.bump] 109 | features_always_bump_minor = false 110 | breaking_always_bump_major = false 111 | 112 | [workspace.metadata.git-cliff.remote.github] 113 | owner = "containers" 114 | repo = "podlet" 115 | 116 | [workspace.metadata.git-cliff.changelog] 117 | # changelog header 118 | header = """ 119 | # Changelog\n 120 | All notable changes to this project will be documented in this file. 121 | 122 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), 123 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).\n 124 | """ 125 | # template for the changelog body 126 | # https://keats.github.io/tera/docs/#introduction 127 | body = """ 128 | {%- macro remote_url() -%} 129 | https://github.com/{{ remote.github.owner }}/{{ remote.github.repo }} 130 | {%- endmacro -%} 131 | 132 | {% if version -%} 133 | ## [{{ version | trim_start_matches(pat="v") }}] - {{ timestamp | date(format="%Y-%m-%d") }} 134 | {% else -%} 135 | ## [Unreleased] 136 | {% endif -%} 137 | 138 | {% for group, commits in commits | group_by(attribute="group") %} 139 | ### {{ group | striptags | trim | upper_first }} 140 | {%- for commit in commits %} 141 | - {% if commit.breaking %}**BREAKING** {% endif -%} 142 | {% if commit.scope %}*({{ commit.scope }})* {% endif -%} 143 | {{ commit.message | trim | upper_first }}\ 144 | {% if commit.github.username and commit.github.username != "k9withabone" %} by \ 145 | [@{{ commit.github.username }}](https://github.com/{{ commit.github.username }})\ 146 | {%- endif -%} 147 | {% if commit.github.pr_number %} in \ 148 | [#{{ commit.github.pr_number }}]({{ self::remote_url() }}/pull/{{ commit.github.pr_number }})\ 149 | {%- endif -%}. 150 | {%- set fixes = commit.footers | filter(attribute="token", value="Fixes") -%} 151 | {%- set closes = commit.footers | filter(attribute="token", value="Closes") -%} 152 | {% for footer in fixes | concat(with=closes) -%} 153 | {%- set issue_number = footer.value | trim_start_matches(pat="#") %} \ 154 | ([{{ footer.value }}]({{ self::remote_url() }}/issues/{{ issue_number }}))\ 155 | {%- endfor -%} 156 | {% if commit.body %} 157 | {%- for section in commit.body | trim | split(pat="\n\n") %} 158 | {% raw %} {% endraw %}- {{ section | replace(from="\n", to=" ") }} 159 | {%- endfor -%} 160 | {%- endif -%} 161 | {% endfor %} 162 | {% endfor %} 163 | 164 | {%- if github.contributors | filter(attribute="is_first_time", value=true) | length != 0 %} 165 | ### New Contributors 166 | {%- endif -%} 167 | 168 | {% for contributor in github.contributors | filter(attribute="is_first_time", value=true) %} 169 | - @{{ contributor.username }} made their first contribution 170 | {%- if contributor.pr_number %} in \ 171 | [#{{ contributor.pr_number }}]({{ self::remote_url() }}/pull/{{ contributor.pr_number }}) \ 172 | {%- endif %} 173 | {%- endfor %}\n 174 | """ 175 | # template for the changelog footer 176 | footer = """ 177 | {%- macro remote_url() -%} 178 | https://github.com/{{ remote.github.owner }}/{{ remote.github.repo }} 179 | {%- endmacro -%} 180 | 181 | {% for release in releases -%} 182 | {% if release.version -%} 183 | {% if release.previous.version -%} 184 | [{{ release.version | trim_start_matches(pat="v") }}]: \ 185 | {{ self::remote_url() }}/compare/{{ release.previous.version }}...{{ release.version }} 186 | {% else -%} 187 | {#- compare against the initial commit for the first version -#} 188 | [{{ release.version | trim_start_matches(pat="v") }}]: \ 189 | {{ self::remote_url() }}/compare/f9a7aadf5fca4966c3e8c7e6e495749d93029c80...v0.1.0 190 | {% endif -%} 191 | {% else -%} 192 | [Unreleased]: {{ self::remote_url() }}/compare/{{ release.previous.version }}...HEAD 193 | {% endif -%} 194 | {%- endfor -%} 195 | """ 196 | # remove the leading and trailing whitespace from the templates 197 | trim = true 198 | # postprocessors 199 | postprocessors = [] 200 | 201 | [workspace.metadata.git-cliff.git] 202 | # parse the commits based on https://www.conventionalcommits.org 203 | conventional_commits = true 204 | # filter out the commits that are not conventional 205 | filter_unconventional = true 206 | # process each line of a commit as an individual commit 207 | split_commits = false 208 | # regex for preprocessing the commit messages 209 | commit_preprocessors = [] 210 | # regex for parsing and grouping commits 211 | commit_parsers = [ 212 | { message = "^feat", group = "Features" }, 213 | { body = ".*security", group = "Security" }, 214 | { message = "^fix", group = "Bug Fixes" }, 215 | { message = "^perf", group = "Performance" }, 216 | { message = "^doc", group = "Documentation" }, 217 | { message = "^test", group = "Tests" }, 218 | { message = "^refactor", group = "Refactor" }, 219 | { message = "^style", group = "Style" }, 220 | { message = "^chore", group = "Miscellaneous" }, 221 | { message = "^ci", default_scope = "ci", group = "Miscellaneous" }, 222 | { message = "^release", skip = true }, 223 | ] 224 | # protect breaking changes from being skipped due to matching a skipping commit_parser 225 | protect_breaking_commits = false 226 | # filter out the commits that are not matched by commit parsers 227 | filter_commits = false 228 | # regex for matching git tags 229 | tag_pattern = "v[0-9].*" 230 | 231 | # regex for skipping tags 232 | skip_tags = "v0.1.0-beta.1" 233 | # regex for ignoring tags 234 | ignore_tags = "" 235 | # sort the tags topologically 236 | topo_order = false 237 | # sort the commits inside sections by oldest/newest order 238 | sort_commits = "oldest" 239 | # limit the number of commits included in the changelog. 240 | # limit_commits = 42 241 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /clippy.toml: -------------------------------------------------------------------------------- 1 | # Clippy configuration 2 | 3 | doc-valid-idents = ["SELinux", ".."] 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 | -------------------------------------------------------------------------------- /demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/containers/podlet/7f2c9c4b012dece5fa00be2aba13b8556d579c79/demo.gif -------------------------------------------------------------------------------- /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/cli/container.rs: -------------------------------------------------------------------------------- 1 | mod compose; 2 | mod podman; 3 | mod quadlet; 4 | pub mod security_opt; 5 | 6 | use clap::Args; 7 | use color_eyre::eyre::{Context, OptionExt}; 8 | 9 | use crate::escape::command_join; 10 | 11 | use self::{podman::PodmanArgs, quadlet::QuadletOptions, security_opt::SecurityOpt}; 12 | 13 | use super::image_to_name; 14 | 15 | #[allow(clippy::doc_markdown)] 16 | #[derive(Args, Default, Debug, Clone, PartialEq)] 17 | pub struct Container { 18 | #[command(flatten)] 19 | quadlet_options: QuadletOptions, 20 | 21 | /// Converts to "PodmanArgs=ARGS" 22 | #[command(flatten)] 23 | podman_args: PodmanArgs, 24 | 25 | /// Security options 26 | /// 27 | /// Converts to a number of different Quadlet options or, 28 | /// if a Quadlet option for the specified security option doesn't exist, 29 | /// is placed in "PodmanArgs=" 30 | /// 31 | /// Can be specified multiple times 32 | #[arg(long, value_name = "OPTION")] 33 | security_opt: Vec, 34 | 35 | /// The image to run in the container 36 | /// 37 | /// Converts to "Image=IMAGE" 38 | image: String, 39 | 40 | /// Optionally, the command to run in the container 41 | /// 42 | /// Converts to "Exec=COMMAND..." 43 | #[arg(trailing_var_arg = true, allow_hyphen_values = true)] 44 | command: Vec, 45 | } 46 | 47 | impl Container { 48 | /// The name that should be used for the generated [`File`](crate::quadlet::File). 49 | /// 50 | /// It is either the set container name or taken from the image. 51 | pub fn name(&self) -> &str { 52 | self.quadlet_options 53 | .name 54 | .as_deref() 55 | .unwrap_or_else(|| image_to_name(&self.image)) 56 | } 57 | 58 | /// Set the `--pod` option. 59 | pub(super) fn set_pod(&mut self, pod: Option) { 60 | self.podman_args.set_pod(pod); 61 | } 62 | } 63 | 64 | impl TryFrom for Container { 65 | type Error = color_eyre::Report; 66 | 67 | fn try_from(value: compose_spec::Service) -> Result { 68 | let compose::Service { 69 | unsupported, 70 | quadlet, 71 | podman_args, 72 | container: 73 | compose::Container { 74 | command, 75 | image, 76 | security_opt, 77 | }, 78 | } = compose::Service::from(value); 79 | 80 | unsupported.ensure_empty()?; 81 | 82 | let security_opt = security_opt 83 | .into_iter() 84 | .filter_map(|s| { 85 | if s == "no-new-privileges:true" { 86 | Some(Ok(SecurityOpt::NoNewPrivileges)) 87 | } else if s == "no-new-privileges:false" { 88 | None 89 | } else { 90 | Some(s.replacen(':', "=", 1).parse()) 91 | } 92 | }) 93 | .collect::>() 94 | .wrap_err("invalid security option")?; 95 | 96 | Ok(Self { 97 | quadlet_options: quadlet.try_into()?, 98 | podman_args: podman_args.try_into()?, 99 | security_opt, 100 | image: image.ok_or_eyre("`image` or `build` is required")?.into(), 101 | command: command 102 | .map(super::compose::command_try_into_vec) 103 | .transpose()? 104 | .unwrap_or_default(), 105 | }) 106 | } 107 | } 108 | 109 | impl From for crate::quadlet::Container { 110 | fn from( 111 | Container { 112 | quadlet_options, 113 | podman_args, 114 | security_opt, 115 | image, 116 | command, 117 | }: Container, 118 | ) -> Self { 119 | let mut podman_args = podman_args.to_string(); 120 | 121 | let security_opt::QuadletOptions { 122 | mask, 123 | no_new_privileges, 124 | seccomp_profile, 125 | security_label_disable, 126 | security_label_file_type, 127 | security_label_level, 128 | security_label_nested, 129 | security_label_type, 130 | unmask, 131 | podman_args: security_podman_args, 132 | } = security_opt.into_iter().fold( 133 | security_opt::QuadletOptions::default(), 134 | |mut security_options, security_opt| { 135 | security_options.add_security_opt(security_opt); 136 | security_options 137 | }, 138 | ); 139 | 140 | for arg in security_podman_args { 141 | podman_args.push_str(" --security-opt "); 142 | podman_args.push_str(&arg); 143 | } 144 | 145 | Self { 146 | image, 147 | mask, 148 | no_new_privileges, 149 | seccomp_profile, 150 | security_label_disable, 151 | security_label_file_type, 152 | security_label_level, 153 | security_label_nested, 154 | security_label_type, 155 | unmask, 156 | podman_args: (!podman_args.is_empty()).then(|| podman_args.trim().to_string()), 157 | exec: (!command.is_empty()).then(|| command_join(command)), 158 | ..quadlet_options.into() 159 | } 160 | } 161 | } 162 | 163 | impl From for crate::quadlet::Resource { 164 | fn from(value: Container) -> Self { 165 | crate::quadlet::Container::from(value).into() 166 | } 167 | } 168 | 169 | #[cfg(test)] 170 | mod tests { 171 | use super::*; 172 | 173 | mod name { 174 | use super::*; 175 | 176 | #[test] 177 | fn container_name() { 178 | let name = "test"; 179 | let mut sut = Container::default(); 180 | sut.quadlet_options.name = Some(String::from(name)); 181 | 182 | assert_eq!(sut.name(), name); 183 | } 184 | 185 | #[test] 186 | fn image_no_tag() { 187 | let sut = Container { 188 | image: String::from("quay.io/podman/hello"), 189 | ..Default::default() 190 | }; 191 | assert_eq!(sut.name(), "hello"); 192 | } 193 | 194 | #[test] 195 | fn image_with_tag() { 196 | let sut = Container { 197 | image: String::from("quay.io/podman/hello:latest"), 198 | ..Default::default() 199 | }; 200 | assert_eq!(sut.name(), "hello"); 201 | } 202 | } 203 | } 204 | -------------------------------------------------------------------------------- /src/cli/container/security_opt.rs: -------------------------------------------------------------------------------- 1 | use std::{path::PathBuf, str::FromStr}; 2 | 3 | use thiserror::Error; 4 | 5 | use crate::quadlet::container::Unmask; 6 | 7 | #[derive(Debug, Clone, PartialEq)] 8 | pub enum SecurityOpt { 9 | Apparmor(String), 10 | Label(LabelOpt), 11 | Mask(String), 12 | NoNewPrivileges, 13 | Seccomp(PathBuf), 14 | ProcOpts(String), 15 | Unmask(String), 16 | } 17 | 18 | impl FromStr for SecurityOpt { 19 | type Err = ParseSecurityOptError; 20 | 21 | fn from_str(s: &str) -> Result { 22 | if let Some(policy) = s.strip_prefix("apparmor=") { 23 | Ok(Self::Apparmor(policy.to_owned())) 24 | } else if let Some(label) = s.strip_prefix("label=") { 25 | Ok(Self::Label(label.parse()?)) 26 | } else if let Some(mask) = s.strip_prefix("mask=") { 27 | Ok(Self::Mask(mask.to_owned())) 28 | } else if s == "no-new-privileges" { 29 | Ok(Self::NoNewPrivileges) 30 | } else if let Some(profile) = s.strip_prefix("seccomp=") { 31 | Ok(Self::Seccomp(profile.into())) 32 | } else if let Some(opts) = s.strip_prefix("proc-opts=") { 33 | Ok(Self::ProcOpts(opts.to_owned())) 34 | } else if let Some(unmask) = s.strip_prefix("unmask=") { 35 | Ok(Self::Unmask(unmask.to_owned())) 36 | } else { 37 | Err(ParseSecurityOptError::InvalidSecurityOpt(s.to_owned())) 38 | } 39 | } 40 | } 41 | 42 | #[derive(Error, Debug, Clone, PartialEq)] 43 | pub enum ParseSecurityOptError { 44 | #[error(transparent)] 45 | InvalidLabelOpt(#[from] InvalidLabelOpt), 46 | #[error("`{0}` is not a valid security option")] 47 | InvalidSecurityOpt(String), 48 | } 49 | 50 | #[derive(Debug, Clone, PartialEq)] 51 | pub enum LabelOpt { 52 | User(String), 53 | Role(String), 54 | Type(String), 55 | Level(String), 56 | Filetype(String), 57 | Disable, 58 | Nested, 59 | } 60 | 61 | impl FromStr for LabelOpt { 62 | type Err = InvalidLabelOpt; 63 | 64 | fn from_str(s: &str) -> Result { 65 | if let Some(user) = s.strip_prefix("user:") { 66 | Ok(Self::User(user.to_owned())) 67 | } else if let Some(role) = s.strip_prefix("role:") { 68 | Ok(Self::Role(role.to_owned())) 69 | } else if let Some(label_type) = s.strip_prefix("type:") { 70 | Ok(Self::Type(label_type.to_owned())) 71 | } else if let Some(level) = s.strip_prefix("level:") { 72 | Ok(Self::Level(level.to_owned())) 73 | } else if let Some(filetype) = s.strip_prefix("filetype:") { 74 | Ok(Self::Filetype(filetype.to_owned())) 75 | } else if s == "disable" { 76 | Ok(Self::Disable) 77 | } else if s == "nested" { 78 | Ok(Self::Nested) 79 | } else { 80 | Err(InvalidLabelOpt(s.to_owned())) 81 | } 82 | } 83 | } 84 | 85 | #[derive(Error, Debug, Clone, PartialEq)] 86 | #[error("`{0}` is not a valid label option")] 87 | pub struct InvalidLabelOpt(pub String); 88 | 89 | #[derive(Debug, Default, Clone, PartialEq)] 90 | pub struct QuadletOptions { 91 | pub mask: Vec, 92 | pub no_new_privileges: bool, 93 | pub seccomp_profile: Option, 94 | pub security_label_disable: bool, 95 | pub security_label_file_type: Option, 96 | pub security_label_level: Option, 97 | pub security_label_nested: bool, 98 | pub security_label_type: Option, 99 | pub unmask: Option, 100 | pub podman_args: Vec, 101 | } 102 | 103 | impl QuadletOptions { 104 | pub fn add_security_opt(&mut self, security_opt: SecurityOpt) { 105 | match security_opt { 106 | SecurityOpt::Apparmor(policy) => self.podman_args.push(format!("apparmor={policy}")), 107 | SecurityOpt::Label(label_opt) => self.add_label_opt(label_opt), 108 | SecurityOpt::Mask(mask) => self.mask.extend(mask.split(':').map(Into::into)), 109 | SecurityOpt::NoNewPrivileges => self.no_new_privileges = true, 110 | SecurityOpt::Seccomp(profile) => self.seccomp_profile = Some(profile), 111 | SecurityOpt::ProcOpts(proc_opts) => { 112 | self.podman_args.push(format!("proc-opts={proc_opts}")); 113 | } 114 | SecurityOpt::Unmask(paths) => self 115 | .unmask 116 | .get_or_insert_with(Unmask::new) 117 | .extend(paths.split(':')), 118 | } 119 | } 120 | 121 | pub fn add_label_opt(&mut self, label_opt: LabelOpt) { 122 | match label_opt { 123 | LabelOpt::User(user) => self.podman_args.push(format!("label=user:{user}")), 124 | LabelOpt::Role(role) => self.podman_args.push(format!("label=role:{role}")), 125 | LabelOpt::Type(label_type) => self.security_label_type = Some(label_type), 126 | LabelOpt::Level(level) => self.security_label_level = Some(level), 127 | LabelOpt::Filetype(file_type) => self.security_label_file_type = Some(file_type), 128 | LabelOpt::Disable => self.security_label_disable = true, 129 | LabelOpt::Nested => self.security_label_nested = true, 130 | } 131 | } 132 | } 133 | -------------------------------------------------------------------------------- /src/cli/global_args.rs: -------------------------------------------------------------------------------- 1 | use std::{mem, ops::Not, path::PathBuf}; 2 | 3 | use clap::{Args, ValueEnum}; 4 | use serde::Serialize; 5 | 6 | use crate::quadlet::Globals; 7 | 8 | /// Podman global options 9 | /// 10 | /// Converted into [`Globals`] for inclusion in [`crate::quadlet::File`]s. 11 | /// All options, except `module`, are serialized with the args serializer. 12 | #[derive(Args, Serialize, Debug, Default, Clone, PartialEq)] 13 | #[command(next_help_heading = "Podman Global Options")] 14 | #[serde(rename_all = "kebab-case")] 15 | pub struct GlobalArgs { 16 | /// Cgroup manager to use 17 | #[arg(long, global = true, value_name = "MANAGER")] 18 | cgroup_manager: Option, 19 | 20 | /// Location of the authentication config file 21 | #[arg(long, global = true, value_name = "PATH")] 22 | config: Option, 23 | 24 | /// Path of the conmon binary 25 | #[arg(long, global = true, value_name = "PATH")] 26 | conmon: Option, 27 | 28 | /// Connection to use for remote Podman service 29 | #[arg(long, global = true, value_name = "CONNECTION_URI")] 30 | connection: Option, 31 | 32 | /// Backend to use for storing events 33 | #[arg(long, global = true, value_name = "TYPE")] 34 | events_backend: Option, 35 | 36 | /// Set the OCI hooks directory path 37 | /// 38 | /// Can be specified multiple times 39 | #[arg(long, global = true, value_name = "PATH")] 40 | hooks_dir: Vec, 41 | 42 | /// Path to ssh identity file 43 | #[arg(long, global = true, value_name = "PATH")] 44 | identity: Option, 45 | 46 | /// Path to the 'image store' 47 | /// 48 | /// Different from 'graph root' 49 | /// 50 | /// Use this to split storing the image into a separate 'image store', 51 | /// see 'man containers-storage.conf' for details. 52 | #[arg(long, global = true, value_name = "PATH")] 53 | imagestore: Option, 54 | 55 | /// Log messages at and above specified level 56 | #[arg(long, global = true, value_name = "LEVEL", default_value = "warn")] 57 | #[serde(skip_serializing_if = "LogLevel::is_warn")] 58 | log_level: LogLevel, 59 | 60 | /// Load the specified `containers.conf(5)` module 61 | /// 62 | /// Converts to "ContainersConfModule=PATH" 63 | /// 64 | /// Can be specified multiple times 65 | #[arg(long, global = true, value_name = "PATH")] 66 | #[serde(skip_serializing)] 67 | module: Vec, 68 | 69 | /// Path to the `slirp4netns(1)` command binary 70 | /// 71 | /// Note: This option is deprecated and will be removed with Podman 5.0. 72 | /// Use the `helper_binaries_dir` option in `containers.conf` instead. 73 | #[arg(long, global = true, value_name = "PATH")] 74 | network_cmd_path: Option, 75 | 76 | /// Path of the configuration directory for networks 77 | #[arg(long, global = true, value_name = "DIRECTORY")] 78 | network_config_dir: Option, 79 | 80 | /// Redirect the output of Podman to a file without affecting the container output or its logs 81 | #[arg(long, global = true, value_name = "PATH")] 82 | out: Option, 83 | 84 | /// Access remote Podman service 85 | #[arg( 86 | short, 87 | long, 88 | global = true, 89 | num_args = 0..=1, 90 | require_equals = true, 91 | default_missing_value = "true", 92 | )] 93 | remote: Option, 94 | 95 | /// Path to the graph root directory where images, containers, etc. are stored 96 | #[arg(long, global = true, value_name = "VALUE")] 97 | root: Option, 98 | 99 | /// Storage state directory where all state information is stored 100 | #[arg(long, global = true, value_name = "VALUE")] 101 | runroot: Option, 102 | 103 | /// Path to the OCI-compatible binary used to run containers 104 | #[arg(long, global = true, value_name = "VALUE")] 105 | runtime: Option, 106 | 107 | /// Add global flags for the container runtime 108 | /// 109 | /// Can be specified multiple times 110 | #[arg(long, global = true, value_name = "FLAG")] 111 | runtime_flag: Vec, 112 | 113 | /// Define the ssh mode 114 | #[arg(long, global = true, value_name = "VALUE")] 115 | ssh: Option, 116 | 117 | /// Select which storage driver is used to manage storage of images and containers 118 | #[arg(long, global = true, value_name = "VALUE")] 119 | storage_driver: Option, 120 | 121 | /// Specify a storage driver option 122 | /// 123 | /// Can be specified multiple times 124 | #[arg(long, global = true, value_name = "VALUE")] 125 | storage_opt: Vec, 126 | 127 | /// Output logging information to syslog as well as the console 128 | #[arg(long, global = true)] 129 | #[serde(skip_serializing_if = "Not::not")] 130 | syslog: bool, 131 | 132 | /// Path to the tmp directory for libpod state content 133 | #[arg(long, global = true, value_name = "PATH")] 134 | tmpdir: Option, 135 | 136 | /// Enable transient container storage 137 | #[arg( 138 | long, 139 | global = true, 140 | num_args = 0..=1, 141 | require_equals = true, 142 | default_missing_value = "true", 143 | )] 144 | transient_store: Option, 145 | 146 | /// URL to access Podman service 147 | #[arg(long, global = true, value_name = "VALUE")] 148 | url: Option, 149 | 150 | /// Volume directory where builtin volume information is stored 151 | #[arg(long, global = true, value_name = "VALUE")] 152 | volumepath: Option, 153 | } 154 | 155 | impl GlobalArgs { 156 | /// Construct [`GlobalArgs`] by taking fields from a [`compose_spec::Service`]. 157 | /// 158 | /// Takes the `runtime` and `storage_opt` fields. 159 | pub fn from_compose(service: &mut compose_spec::Service) -> Self { 160 | Self { 161 | runtime: service.runtime.take().map(Into::into), 162 | storage_opt: mem::take(&mut service.storage_opt) 163 | .into_iter() 164 | .map(|(key, value)| { 165 | let mut opt = String::from(key); 166 | opt.push('='); 167 | if let Some(value) = value { 168 | opt.push_str(&String::from(value)); 169 | } 170 | opt 171 | }) 172 | .collect(), 173 | ..Self::default() 174 | } 175 | } 176 | } 177 | 178 | impl From for Globals { 179 | fn from(value: GlobalArgs) -> Self { 180 | let global_args = 181 | crate::serde::args::to_string(&value).expect("GlobalArgs serializes to args"); 182 | Self { 183 | containers_conf_module: value.module, 184 | global_args: (!global_args.is_empty()).then_some(global_args), 185 | } 186 | } 187 | } 188 | 189 | /// Valid values for `podman --cgroup-manager` 190 | /// 191 | /// See 192 | #[derive(ValueEnum, Serialize, Debug, Clone, Copy, PartialEq, Eq)] 193 | #[value(rename_all = "lower")] 194 | #[serde(rename_all = "lowercase")] 195 | enum CGroupManager { 196 | CGroupFs, 197 | Systemd, 198 | } 199 | 200 | /// Valid values for `podman --events-backend` 201 | /// 202 | /// See 203 | #[derive(ValueEnum, Serialize, Debug, Clone, Copy, PartialEq, Eq)] 204 | #[value(rename_all = "lower")] 205 | #[serde(rename_all = "lowercase")] 206 | enum EventsBackend { 207 | File, 208 | Journald, 209 | None, 210 | } 211 | 212 | /// Valid values for `podman --log-level` 213 | /// 214 | /// See 215 | #[derive(ValueEnum, Serialize, Debug, Default, Clone, Copy, PartialEq, Eq)] 216 | #[value(rename_all = "lower")] 217 | #[serde(rename_all = "lowercase")] 218 | enum LogLevel { 219 | Debug, 220 | Info, 221 | #[default] 222 | Warn, 223 | Error, 224 | Fatal, 225 | Panic, 226 | } 227 | 228 | impl LogLevel { 229 | /// Returns `true` if the log level is [`Warn`]. 230 | /// 231 | /// [`Warn`]: LogLevel::Warn 232 | #[allow(clippy::trivially_copy_pass_by_ref)] 233 | #[must_use] 234 | fn is_warn(&self) -> bool { 235 | matches!(self, Self::Warn) 236 | } 237 | } 238 | 239 | /// Valid values for `podman --ssh` 240 | /// 241 | /// See 242 | #[derive(ValueEnum, Serialize, Debug, Clone, Copy, PartialEq, Eq)] 243 | #[value(rename_all = "lower")] 244 | #[serde(rename_all = "lowercase")] 245 | enum SshMode { 246 | GoLang, 247 | Native, 248 | } 249 | 250 | #[cfg(test)] 251 | #[allow(clippy::unwrap_used)] 252 | mod tests { 253 | use super::*; 254 | 255 | #[test] 256 | fn default_args_serialize_empty() { 257 | let global_args = crate::serde::args::to_string(GlobalArgs::default()).unwrap(); 258 | assert!(global_args.is_empty()); 259 | } 260 | } 261 | -------------------------------------------------------------------------------- /src/cli/image.rs: -------------------------------------------------------------------------------- 1 | use std::{path::PathBuf, str::FromStr}; 2 | 3 | use clap::{Args, Subcommand}; 4 | use thiserror::Error; 5 | 6 | use crate::quadlet::{self, image::DecryptionKey}; 7 | 8 | use super::image_to_name; 9 | 10 | /// [`Subcommand`]s for `podlet podman image` 11 | #[derive(Subcommand, Debug, Clone, PartialEq)] 12 | pub enum Image { 13 | /// Generate a Podman Quadlet `.image` file 14 | /// 15 | /// For details on options see: 16 | /// https://docs.podman.io/en/stable/markdown/podman-pull.1.html and 17 | /// https://docs.podman.io/en/stable/markdown/podman-systemd.unit.5.html#image-units-image 18 | #[allow(clippy::doc_markdown)] 19 | #[group(skip)] 20 | Pull { 21 | #[command(flatten)] 22 | pull: Pull, 23 | }, 24 | } 25 | 26 | impl Image { 27 | /// Name suitable for use as the filename of the generated Quadlet file. 28 | pub fn name(&self) -> &str { 29 | let Self::Pull { 30 | pull: Pull { source, .. }, 31 | } = self; 32 | 33 | image_to_name(source) 34 | } 35 | } 36 | 37 | impl From for quadlet::Image { 38 | fn from(value: Image) -> Self { 39 | let Image::Pull { pull } = value; 40 | pull.into() 41 | } 42 | } 43 | 44 | impl From for quadlet::Resource { 45 | fn from(value: Image) -> Self { 46 | quadlet::Image::from(value).into() 47 | } 48 | } 49 | 50 | /// [`Args`] for `podman image pull` 51 | #[allow(clippy::doc_markdown)] 52 | #[derive(Args, Default, Debug, Clone, PartialEq)] 53 | pub struct Pull { 54 | /// All tagged images in the repository are pulled. 55 | /// 56 | /// Converts to "AllTags=true" 57 | #[arg(short, long)] 58 | pub all_tags: bool, 59 | 60 | /// Override the architecture, defaults to hosts, of the image to be pulled. 61 | /// 62 | /// Converts to "Arch=ARCH" 63 | #[arg(long)] 64 | pub arch: Option, 65 | 66 | /// Path of the authentication file. 67 | /// 68 | /// Converts to "AuthFile=PATH" 69 | #[arg(long, value_name = "PATH")] 70 | pub authfile: Option, 71 | 72 | /// Use certificates at path (*.crt, *.cert, *.key) to connect to the registry. 73 | /// 74 | /// Converts to "CertDir=PATH" 75 | #[arg(long, value_name = "PATH")] 76 | pub cert_dir: Option, 77 | 78 | /// The username and/or password to use to authenticate with the registry, if required. 79 | /// 80 | /// Converts to "Creds=[USERNAME][:PASSWORD]" 81 | #[arg(long, value_name = "[USERNAME][:PASSWORD]")] 82 | pub creds: Option, 83 | 84 | /// The key and optional passphrase to be used for decryption of images. 85 | /// 86 | /// Converts to "DecryptionKey=KEY[:PASSPHRASE]" 87 | #[arg(long, value_name = "KEY[:PASSPHRASE]")] 88 | pub decryption_key: Option, 89 | 90 | /// Docker-specific option to disable image verification to a container registry. 91 | /// 92 | /// Not supported by Podman 93 | /// 94 | /// This option is a NOOP and provided solely for scripting compatibility. 95 | #[arg(long)] 96 | pub disable_content_trust: bool, 97 | 98 | /// Override the OS, defaults to hosts, of the image to be pulled. 99 | /// 100 | /// Converts to "OS=OS" 101 | #[arg(long)] 102 | pub os: Option, 103 | 104 | /// Specify the platform for selecting the image. 105 | /// 106 | /// Converts to "OS=OS" and "Arch=ARCH" 107 | #[arg(long, conflicts_with_all = ["os", "arch"], value_name = "OS/ARCH")] 108 | pub platform: Option, 109 | 110 | /// Require HTTPS and verify certificates when contacting registries 111 | /// 112 | /// Converts to "TLSVerify=TLS_VERIFY" 113 | #[arg(long, num_args = 0..=1, require_equals = true, default_missing_value = "true")] 114 | pub tls_verify: Option, 115 | 116 | /// Use the given variant instead of the running architecture variant for choosing images. 117 | /// 118 | /// Converts to "Variant=VARIANT" 119 | #[arg(long)] 120 | pub variant: Option, 121 | 122 | /// Location from which the container image is pulled from. 123 | /// 124 | /// Converts to "Image=SOURCE" 125 | pub source: String, 126 | } 127 | 128 | impl From for quadlet::Image { 129 | fn from( 130 | Pull { 131 | all_tags, 132 | arch, 133 | authfile: auth_file, 134 | cert_dir, 135 | creds, 136 | decryption_key, 137 | disable_content_trust: _, 138 | os, 139 | platform, 140 | tls_verify, 141 | variant, 142 | source: image, 143 | }: Pull, 144 | ) -> Self { 145 | let (os, arch) = platform.map_or((os, arch), |platform| { 146 | (Some(platform.os), Some(platform.arch)) 147 | }); 148 | 149 | Self { 150 | all_tags, 151 | arch, 152 | auth_file, 153 | cert_dir, 154 | creds, 155 | decryption_key, 156 | image, 157 | image_tag: None, 158 | os, 159 | podman_args: None, 160 | tls_verify, 161 | variant, 162 | } 163 | } 164 | } 165 | 166 | /// `podman image pull --platform` option 167 | #[derive(Debug, Clone, PartialEq)] 168 | pub struct Platform { 169 | pub os: String, 170 | pub arch: String, 171 | } 172 | 173 | impl FromStr for Platform { 174 | type Err = ParsePlatformError; 175 | 176 | fn from_str(s: &str) -> Result { 177 | let (os, arch) = s.split_once('/').ok_or(ParsePlatformError::MissingArch)?; 178 | Ok(Self { 179 | os: os.to_owned(), 180 | arch: arch.to_owned(), 181 | }) 182 | } 183 | } 184 | 185 | #[derive(Error, Debug)] 186 | pub enum ParsePlatformError { 187 | #[error("platform must be in the form \"OS/ARCH\"")] 188 | MissingArch, 189 | } 190 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /src/cli/k8s.rs: -------------------------------------------------------------------------------- 1 | //! Kubernetes YAML [`File`] for converting a [`Compose`] file into a [`Pod`] and 2 | //! [`PersistentVolumeClaim`]s. 3 | 4 | mod service; 5 | mod volume; 6 | 7 | use std::fmt::{self, Display, Formatter}; 8 | 9 | use color_eyre::eyre::{ensure, OptionExt, WrapErr}; 10 | use compose_spec::{Compose, Resource}; 11 | use k8s_openapi::{ 12 | api::core::v1::{PersistentVolumeClaim, Pod, PodSpec}, 13 | apimachinery::pkg::apis::meta::v1::ObjectMeta, 14 | }; 15 | 16 | use self::service::Service; 17 | 18 | /// A Kubernetes YAML file representing a [`Pod`] and optional [`PersistentVolumeClaim`]s. 19 | /// 20 | /// Created by converting from a [`Compose`] file. 21 | #[derive(Debug)] 22 | pub struct File { 23 | /// The name of the file, without the extension. 24 | pub name: String, 25 | 26 | /// The Kubernetes [`Pod`]. 27 | pub pod: Pod, 28 | 29 | /// Optional Kubernetes [`PersistentVolumeClaim`]s. 30 | /// 31 | /// Needed if a [`compose_spec::Volume`] has additional options set. 32 | pub persistent_volume_claims: Vec, 33 | } 34 | 35 | impl TryFrom for File { 36 | type Error = color_eyre::Report; 37 | 38 | fn try_from( 39 | Compose { 40 | version: _, 41 | name, 42 | include, 43 | services, 44 | networks, 45 | volumes, 46 | configs, 47 | secrets, 48 | extensions, 49 | }: Compose, 50 | ) -> Result { 51 | ensure!(include.is_empty(), "`include` is not supported"); 52 | ensure!(networks.is_empty(), "`networks` is not supported"); 53 | ensure!(configs.is_empty(), "`configs` is not supported"); 54 | ensure!(secrets.is_empty(), "`secrets` is not supported"); 55 | ensure!( 56 | extensions.is_empty(), 57 | "compose extensions are not supported" 58 | ); 59 | 60 | let name = name.map(String::from).ok_or_eyre("`name` is required")?; 61 | 62 | let spec = 63 | services 64 | .into_iter() 65 | .try_fold(PodSpec::default(), |mut spec, (name, service)| { 66 | Service::from_compose(&name, service) 67 | .add_to_pod_spec(&mut spec) 68 | .wrap_err_with(|| { 69 | format!("error adding service `{name}` to Kubernetes pod spec") 70 | }) 71 | .map(|()| spec) 72 | })?; 73 | 74 | let pod = Pod { 75 | metadata: ObjectMeta { 76 | name: Some(name.clone()), 77 | ..ObjectMeta::default() 78 | }, 79 | spec: Some(spec), 80 | status: None, 81 | }; 82 | 83 | let persistent_volume_claims = volumes 84 | .into_iter() 85 | .filter_map(|(name, volume)| match volume { 86 | Some(Resource::Compose(volume)) if !volume.is_empty() => Some( 87 | volume::try_into_persistent_volume_claim(name.clone(), volume).wrap_err_with( 88 | || format!("error converting volume `{name}` to a persistent volume claim"), 89 | ), 90 | ), 91 | _ => None, 92 | }) 93 | .collect::>()?; 94 | 95 | Ok(Self { 96 | name, 97 | pod, 98 | persistent_volume_claims, 99 | }) 100 | } 101 | } 102 | 103 | impl Display for File { 104 | fn fmt(&self, f: &mut Formatter) -> fmt::Result { 105 | let Self { 106 | name: _, 107 | pod, 108 | persistent_volume_claims, 109 | } = self; 110 | 111 | for volume in persistent_volume_claims { 112 | f.write_str(&serde_yaml::to_string(volume).map_err(|_| fmt::Error)?)?; 113 | writeln!(f, "---")?; 114 | } 115 | 116 | f.write_str(&serde_yaml::to_string(pod).map_err(|_| fmt::Error)?) 117 | } 118 | } 119 | -------------------------------------------------------------------------------- /src/cli/k8s/service/mount.rs: -------------------------------------------------------------------------------- 1 | //! Utilities for converting a volume [`Mount`] from a [`compose_spec::Service`] into a Kubernetes 2 | //! [`VolumeMount`] and [`Volume`] for a [`Container`](k8s_openapi::api::core::v1::Container) and 3 | //! its [`PodSpec`](k8s_openapi::api::core::v1::PodSpec). 4 | 5 | use color_eyre::eyre::{ensure, eyre, WrapErr}; 6 | use compose_spec::{ 7 | service::{ 8 | volumes::{ 9 | self, 10 | mount::{self, Bind, BindOptions, Common, Tmpfs, TmpfsOptions, VolumeOptions}, 11 | Mount, 12 | }, 13 | AbsolutePath, Volumes, 14 | }, 15 | Identifier, ItemOrList, 16 | }; 17 | use k8s_openapi::{ 18 | api::core::v1::{ 19 | EmptyDirVolumeSource, HostPathVolumeSource, PersistentVolumeClaimVolumeSource, Volume, 20 | VolumeMount, 21 | }, 22 | apimachinery::pkg::api::resource::Quantity, 23 | }; 24 | 25 | /// Attempt to convert the `tmpfs` and `volumes` fields from a [`compose_spec::Service`] into 26 | /// [`VolumeMount`]s. 27 | /// 28 | /// The corresponding [`Volume`]s are added to `pod_volumes`. 29 | /// 30 | /// # Errors 31 | /// 32 | /// Returns an error if an unsupported option is present or the [`Mount`] type is not supported. 33 | pub(super) fn tmpfs_and_volumes_try_into_volume_mounts( 34 | tmpfs: Option>, 35 | volumes: Volumes, 36 | container_name: &Identifier, 37 | pod_volumes: &mut Option>, 38 | ) -> color_eyre::Result> { 39 | tmpfs 40 | .into_iter() 41 | .flat_map(ItemOrList::into_list) 42 | .map(Tmpfs::from_target) 43 | .map(Into::into) 44 | .chain(volumes::into_long_iter(volumes)) 45 | .map(|mount| { 46 | let (volume_mount, volume) = try_into_volume_mount(mount, container_name)?; 47 | pod_volumes.get_or_insert_with(Vec::new).push(volume); 48 | Ok(volume_mount) 49 | }) 50 | .collect() 51 | } 52 | 53 | /// Attempt to convert a volume [`Mount`] from a [`compose_spec::Service`] into a [`VolumeMount`] 54 | /// and its corresponding [`Volume`]. 55 | /// 56 | /// # Errors 57 | /// 58 | /// Returns an error if an unsupported option is present or the [`Mount`] type is not supported. 59 | fn try_into_volume_mount( 60 | mount: Mount, 61 | container_name: &Identifier, 62 | ) -> color_eyre::Result<(VolumeMount, Volume)> { 63 | match mount { 64 | Mount::Volume(volume) => volume_try_into_volume_mount(volume, container_name) 65 | .wrap_err("error converting `volume` type volume mount"), 66 | Mount::Bind(bind) => bind_try_into_volume_mount(bind, container_name) 67 | .wrap_err("error converting `bind` type volume mount"), 68 | Mount::Tmpfs(tmpfs) => tmpfs_try_into_volume_mount(tmpfs, container_name) 69 | .wrap_err("error converting `tmpfs` type volume mount"), 70 | Mount::NamedPipe(_) => Err(eyre!("`npipe` volume mount type is not supported")), 71 | Mount::Cluster(_) => Err(eyre!("`cluster` volume mount type is not supported")), 72 | } 73 | } 74 | 75 | /// Attempt to convert a [`mount::Volume`] into a [`VolumeMount`]. 76 | /// 77 | /// # Errors 78 | /// 79 | /// Returns an error if an unsupported option is present. 80 | fn volume_try_into_volume_mount( 81 | mount::Volume { 82 | source, 83 | volume, 84 | common, 85 | }: mount::Volume, 86 | container_name: &Identifier, 87 | ) -> color_eyre::Result<(VolumeMount, Volume)> { 88 | ensure!( 89 | volume.as_ref().map_or(true, VolumeOptions::is_empty), 90 | "additional `volume` options are not supported" 91 | ); 92 | 93 | let anonymous_volume = source.is_none(); 94 | let source = source.map_or(Source::Other { container_name }, Source::Volume); 95 | let volume_mount = common_try_into_volume_mount(common, source)?; 96 | 97 | let name = volume_mount.name.clone(); 98 | let volume = if anonymous_volume { 99 | Volume { 100 | name, 101 | empty_dir: Some(EmptyDirVolumeSource::default()), 102 | ..Volume::default() 103 | } 104 | } else { 105 | Volume { 106 | name: name.clone(), 107 | persistent_volume_claim: Some(PersistentVolumeClaimVolumeSource { 108 | claim_name: name, 109 | read_only: None, 110 | }), 111 | ..Volume::default() 112 | } 113 | }; 114 | 115 | Ok((volume_mount, volume)) 116 | } 117 | 118 | /// Attempt to convert a [`Bind`] volume [`Mount`] into a [`VolumeMount`]. 119 | /// 120 | /// # Errors 121 | /// 122 | /// Returns an error if an unsupported option is present. 123 | fn bind_try_into_volume_mount( 124 | Bind { 125 | source, 126 | bind, 127 | common, 128 | }: Bind, 129 | container_name: &Identifier, 130 | ) -> color_eyre::Result<(VolumeMount, Volume)> { 131 | let BindOptions { 132 | propagation, 133 | create_host_path, 134 | selinux, 135 | extensions, 136 | } = bind.unwrap_or_default(); 137 | 138 | ensure!(propagation.is_none(), "`bind.propagation` is not supported"); 139 | ensure!( 140 | create_host_path, 141 | "`bind.create_host_path: false` is not supported" 142 | ); 143 | ensure!( 144 | extensions.is_empty(), 145 | "compose extensions are not supported" 146 | ); 147 | 148 | let mut volume_mount = common_try_into_volume_mount(common, Source::Other { container_name })?; 149 | if let Some(selinux) = selinux { 150 | let mount_path = &mut volume_mount.mount_path; 151 | mount_path.push(':'); 152 | mount_path.push(selinux.as_char()); 153 | } 154 | 155 | let volume = Volume { 156 | name: volume_mount.name.clone(), 157 | host_path: Some(HostPathVolumeSource { 158 | path: source 159 | .into_inner() 160 | .into_os_string() 161 | .into_string() 162 | .map_err(|_| eyre!("`source` must only contain valid UTF-8"))?, 163 | type_: None, 164 | }), 165 | ..Volume::default() 166 | }; 167 | 168 | Ok((volume_mount, volume)) 169 | } 170 | 171 | /// Attempt to convert a [`Tmpfs`] volume [`Mount`] into a [`VolumeMount`]. 172 | /// 173 | /// # Errors 174 | /// 175 | /// Returns an error if an unsupported option is present. 176 | fn tmpfs_try_into_volume_mount( 177 | Tmpfs { tmpfs, common }: Tmpfs, 178 | container_name: &Identifier, 179 | ) -> color_eyre::Result<(VolumeMount, Volume)> { 180 | let TmpfsOptions { 181 | size, 182 | mode, 183 | extensions, 184 | } = tmpfs.unwrap_or_default(); 185 | 186 | ensure!(mode.is_none(), "`tmpfs.mode` is not supported"); 187 | ensure!( 188 | extensions.is_empty(), 189 | "compose extensions are not supported" 190 | ); 191 | 192 | let volume_mount = common_try_into_volume_mount(common, Source::Other { container_name })?; 193 | 194 | let volume = Volume { 195 | name: volume_mount.name.clone(), 196 | empty_dir: Some(EmptyDirVolumeSource { 197 | medium: Some("Memory".to_owned()), 198 | size_limit: size.map(|size| Quantity(size.to_string())), 199 | }), 200 | ..Volume::default() 201 | }; 202 | 203 | Ok((volume_mount, volume)) 204 | } 205 | 206 | /// Attempt to convert [`Common`] volume [`Mount`] options into a [`VolumeMount`]. 207 | /// 208 | /// `source` is used to create the [`VolumeMount`]'s `name`. 209 | /// 210 | /// # Errors 211 | /// 212 | /// Returns an error if an unsupported [`Common`] option is present. 213 | fn common_try_into_volume_mount( 214 | Common { 215 | target, 216 | read_only, 217 | consistency, 218 | extensions, 219 | }: Common, 220 | source: Source, 221 | ) -> color_eyre::Result { 222 | ensure!(consistency.is_none(), "`consistency` is not supported"); 223 | ensure!( 224 | extensions.is_empty(), 225 | "compose extensions are not supported" 226 | ); 227 | 228 | let mount_path = target 229 | .into_inner() 230 | .into_os_string() 231 | .into_string() 232 | .map_err(|_| eyre!("`target` must only contain valid UTF-8"))?; 233 | 234 | let name = source.into_volume_name(&mount_path); 235 | 236 | Ok(VolumeMount { 237 | mount_path, 238 | name, 239 | read_only: read_only.then_some(true), 240 | ..VolumeMount::default() 241 | }) 242 | } 243 | 244 | /// Source for a [`VolumeMount`]. 245 | enum Source<'a> { 246 | /// Source is a [`Volume`] with a [`PersistentVolumeClaimVolumeSource`]. 247 | Volume(Identifier), 248 | /// Source is a [`Volume`] with some other source type. 249 | Other { container_name: &'a Identifier }, 250 | } 251 | 252 | impl<'a> Source<'a> { 253 | /// Convert source into a `name` for a [`Volume`]. 254 | /// 255 | /// If [`Other`](Self::Other), the `container_name` is combined with the `mount_path` to create 256 | /// the `name`. 257 | fn into_volume_name(self, mount_path: &str) -> String { 258 | match self { 259 | Self::Volume(volume) => volume.into(), 260 | Self::Other { container_name } => { 261 | format!("{container_name}{}", mount_path.replace(['/', '\\'], "-")) 262 | } 263 | } 264 | } 265 | } 266 | -------------------------------------------------------------------------------- /src/cli/k8s/volume.rs: -------------------------------------------------------------------------------- 1 | //! Utilities for converting a compose [`Volume`] into a Kubernetes [`PersistentVolumeClaim`]. 2 | 3 | use color_eyre::eyre::{bail, ensure, Context}; 4 | use compose_spec::{Identifier, MapKey, Number, StringOrNumber, Volume}; 5 | use indexmap::IndexMap; 6 | use k8s_openapi::{ 7 | api::core::v1::PersistentVolumeClaim, apimachinery::pkg::apis::meta::v1::ObjectMeta, 8 | }; 9 | 10 | /// Attempt to convert a compose [`Volume`] into a [`PersistentVolumeClaim`]. 11 | /// 12 | /// # Errors 13 | /// 14 | /// Returns an error if the [`Volume`] has unsupported options set or there was an error converting 15 | /// an option. 16 | pub(super) fn try_into_persistent_volume_claim( 17 | name: Identifier, 18 | Volume { 19 | driver, 20 | driver_opts, 21 | labels, 22 | name: volume_name, 23 | extensions, 24 | }: Volume, 25 | ) -> color_eyre::Result { 26 | ensure!(volume_name.is_none(), "`name` is not supported"); 27 | ensure!( 28 | extensions.is_empty(), 29 | "compose extensions are not supported" 30 | ); 31 | 32 | Ok(PersistentVolumeClaim { 33 | metadata: ObjectMeta { 34 | name: Some(name.into()), 35 | annotations: (driver.is_some() || !driver_opts.is_empty()) 36 | .then(|| { 37 | DriverOpts::try_from_compose(driver, driver_opts) 38 | .map(|driver_opts| driver_opts.into_annotations().collect()) 39 | }) 40 | .transpose() 41 | .wrap_err("error converting `driver_opts`")?, 42 | labels: (!labels.is_empty()) 43 | .then(|| { 44 | labels.into_map().map(|labels| { 45 | labels 46 | .into_iter() 47 | .map(|(key, value)| { 48 | (key.into(), value.map(Into::into).unwrap_or_default()) 49 | }) 50 | .collect() 51 | }) 52 | }) 53 | .transpose() 54 | .wrap_err("error converting `labels`")?, 55 | ..ObjectMeta::default() 56 | }, 57 | spec: None, 58 | status: None, 59 | }) 60 | } 61 | 62 | /// Supported volume driver options for a [`PersistentVolumeClaim`] through the use of Kubernetes 63 | /// annotations. 64 | /// 65 | /// See the "Kubernetes Persistent Volume Claims" section of the docs for 66 | /// [**podman-kube-play**(1)](https://docs.podman.io/en/stable/markdown/podman-kube-play.1.html). 67 | #[derive(Debug, Default)] 68 | struct DriverOpts { 69 | driver: Option, 70 | device: Option, 71 | fs_type: Option, 72 | uid: Option, 73 | gid: Option, 74 | mount_options: Option, 75 | import_source: Option, 76 | image: Option, 77 | } 78 | 79 | impl DriverOpts { 80 | /// Attempt to create [`DriverOpts`] from a [`compose_spec::Volume`]'s `driver` and 81 | /// `driver_opts` fields. 82 | fn try_from_compose( 83 | driver: Option, 84 | driver_opts: IndexMap, 85 | ) -> color_eyre::Result { 86 | driver_opts.into_iter().try_fold( 87 | Self { 88 | driver, 89 | ..Self::default() 90 | }, 91 | |mut driver_opts, (key, value)| { 92 | driver_opts.parse_add(key.as_str(), value)?; 93 | Ok(driver_opts) 94 | }, 95 | ) 96 | } 97 | 98 | /// Parse `key` as a driver option and add `value` to `self` as appropriate. 99 | /// 100 | /// # Errors 101 | /// 102 | /// Returns an error if the `key` is an unknown option or there is an error converting the 103 | /// `value`. 104 | fn parse_add(&mut self, key: &str, value: StringOrNumber) -> color_eyre::Result<()> { 105 | match key { 106 | "device" => self.device = Some(value.into()), 107 | "type" => self.fs_type = Some(value.into()), 108 | "uid" => { 109 | let StringOrNumber::Number(Number::UnsignedInt(uid)) = value else { 110 | bail!("`uid` must be a positive integer"); 111 | }; 112 | self.uid = uid 113 | .try_into() 114 | .map(Some) 115 | .wrap_err_with(|| format!("UID `{uid}` is too large"))?; 116 | } 117 | "gid" => { 118 | let StringOrNumber::Number(Number::UnsignedInt(gid)) = value else { 119 | bail!("`gid` must be a positive integer"); 120 | }; 121 | self.gid = gid 122 | .try_into() 123 | .map(Some) 124 | .wrap_err_with(|| format!("GID `{gid}` is too large"))?; 125 | } 126 | "import-source" => self.import_source = Some(value.into()), 127 | "image" => self.image = Some(value.into()), 128 | "o" => { 129 | let StringOrNumber::String(mount_options) = value else { 130 | bail!("`o` value must be a string"); 131 | }; 132 | self.add_mount_options(&mount_options)?; 133 | } 134 | key => bail!("unknown volume driver option `{key}`"), 135 | } 136 | Ok(()) 137 | } 138 | 139 | /// Add the `mount_options` to `self`. 140 | /// 141 | /// # Errors 142 | /// 143 | /// Returns an error if a `uid=` or `gid=` mount option value could not be parsed as a [`u32`]. 144 | fn add_mount_options(&mut self, mount_options: &str) -> color_eyre::Result<()> { 145 | for mount_option in mount_options.split(',') { 146 | if let Some(uid) = mount_option.strip_prefix("uid=") { 147 | self.uid = uid.parse().map(Some).wrap_err_with(|| { 148 | format!("error parsing UID `{uid}` as an unsigned integer") 149 | })?; 150 | } else if let Some(gid) = mount_option.strip_prefix("gid=") { 151 | self.gid = gid.parse().map(Some).wrap_err_with(|| { 152 | format!("error parsing GID `{gid}` as an unsigned integer") 153 | })?; 154 | } else if let Some(mount_options) = &mut self.mount_options { 155 | mount_options.push(','); 156 | mount_options.push_str(mount_option); 157 | } else { 158 | self.mount_options = Some(mount_option.to_owned()); 159 | } 160 | } 161 | Ok(()) 162 | } 163 | 164 | /// Convert driver options into an [`Iterator`] of key-value pairs for use as 165 | /// [`PersistentVolumeClaim`] annotations. 166 | fn into_annotations(self) -> impl Iterator { 167 | let Self { 168 | driver, 169 | device, 170 | fs_type, 171 | uid, 172 | gid, 173 | mount_options, 174 | import_source, 175 | image, 176 | } = self; 177 | 178 | [ 179 | ("volume.podman.io/driver", driver), 180 | ("volume.podman.io/device", device), 181 | ("volume.podman.io/type", fs_type), 182 | ("volume.podman.io/uid", uid.as_ref().map(u32::to_string)), 183 | ("volume.podman.io/gid", gid.as_ref().map(u32::to_string)), 184 | ("volume.podman.io/mount-options", mount_options), 185 | ("volume.podman.io/import-source", import_source), 186 | ("volume.podman.io/image", image), 187 | ] 188 | .into_iter() 189 | .filter_map(|(key, value)| value.map(|value| (key.to_owned(), value))) 190 | } 191 | } 192 | -------------------------------------------------------------------------------- /src/cli/kube.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | fmt::{self, Display, Formatter}, 3 | net::IpAddr, 4 | ops::Not, 5 | path::PathBuf, 6 | }; 7 | 8 | use clap::{Args, Subcommand}; 9 | use serde::Serialize; 10 | 11 | use crate::quadlet::kube::{AutoUpdate, YamlFile}; 12 | 13 | #[derive(Subcommand, Debug, Clone, PartialEq)] 14 | pub enum Kube { 15 | /// Generate a Podman Quadlet `.kube` file, 16 | /// 17 | /// Only options supported by Quadlet are present, 18 | /// 19 | /// For details on options see: 20 | /// https://docs.podman.io/en/stable/markdown/podman-kube-play.1.html and 21 | /// https://docs.podman.io/en/stable/markdown/podman-systemd.unit.5.html#kube-units-kube 22 | #[allow(clippy::doc_markdown)] 23 | #[group(skip)] 24 | Play { 25 | #[command(flatten)] 26 | play: Play, 27 | }, 28 | } 29 | 30 | impl From for crate::quadlet::Kube { 31 | fn from(value: Kube) -> Self { 32 | let Kube::Play { play } = value; 33 | play.into() 34 | } 35 | } 36 | 37 | impl From for crate::quadlet::Resource { 38 | fn from(value: Kube) -> Self { 39 | crate::quadlet::Kube::from(value).into() 40 | } 41 | } 42 | 43 | impl Kube { 44 | pub fn name(&self) -> &str { 45 | let Kube::Play { play } = self; 46 | 47 | play.file.name().unwrap_or("pod") 48 | } 49 | } 50 | 51 | #[derive(Args, Debug, Clone, PartialEq)] 52 | pub struct Play { 53 | /// The path to a Kubernetes YAML file containing a configmap 54 | /// 55 | /// Converts to "ConfigMap=PATH" 56 | /// 57 | /// Can be specified multiple times 58 | #[arg(long, value_name = "PATH", value_delimiter = ',')] 59 | configmap: Vec, 60 | 61 | /// Set logging driver for the pod 62 | /// 63 | /// Converts to "LogDriver=DRIVER" 64 | #[arg(long, value_name = "DRIVER")] 65 | log_driver: Option, 66 | 67 | /// Specify a custom network for the pod 68 | /// 69 | /// Converts to "Network=MODE" 70 | /// 71 | /// Can be specified multiple times 72 | #[arg(long, visible_alias = "net", value_name = "MODE")] 73 | network: Vec, 74 | 75 | /// Define or override a port definition in the YAML file 76 | /// 77 | /// Converts to "PublishPort=PORT" 78 | /// 79 | /// Can be specified multiple times 80 | #[arg(long, value_name = "[[IP:][HOST_PORT]:]CONTAINER_PORT[/PROTOCOL]")] 81 | publish: Vec, 82 | 83 | /// Set the user namespace mode for the pod 84 | /// 85 | /// Converts to "UserNS=MODE" 86 | #[arg(long, value_name = "MODE")] 87 | userns: Option, 88 | 89 | /// Converts to "PodmanArgs=ARGS" 90 | #[command(flatten)] 91 | podman_args: PodmanArgs, 92 | 93 | /// The path to the Kubernetes YAML file to use 94 | /// 95 | /// Converts to "Yaml=FILE" 96 | file: YamlFile, 97 | } 98 | 99 | impl From for crate::quadlet::Kube { 100 | fn from(mut value: Play) -> Self { 101 | let auto_update = AutoUpdate::extract_from_annotations(&mut value.podman_args.annotation); 102 | let podman_args = value.podman_args.to_string(); 103 | Self { 104 | auto_update, 105 | config_map: value.configmap, 106 | log_driver: value.log_driver, 107 | network: value.network, 108 | podman_args: (!podman_args.is_empty()).then_some(podman_args), 109 | publish_port: value.publish, 110 | user_ns: value.userns, 111 | yaml: value.file, 112 | } 113 | } 114 | } 115 | 116 | #[derive(Args, Serialize, Debug, Default, Clone, PartialEq)] 117 | #[serde(rename_all = "kebab-case")] 118 | pub struct PodmanArgs { 119 | /// Add an annotation to the container or pod 120 | /// 121 | /// Can be specified multiple times 122 | #[arg(long, value_name = "KEY=VALUE")] 123 | annotation: Vec, 124 | 125 | /// Build images even if they are found in the local storage 126 | /// 127 | /// Use `--build=false` to completely disable builds 128 | #[arg(long, num_args = 0..=1, require_equals = true, default_missing_value = "true")] 129 | build: Option, 130 | 131 | /// Use certificates at `path` (*.crt, *.cert, *.key) to connect to the registry 132 | #[arg(long, value_name = "PATH")] 133 | cert_dir: Option, 134 | 135 | /// Use `path` as the build context directory for each image 136 | #[arg(long, requires = "build", value_name = "PATH")] 137 | context_dir: Option, 138 | 139 | /// The username and password to use to authenticate with the registry, if required 140 | #[arg(long, value_name = "USERNAME[:PASSWORD]")] 141 | creds: Option, 142 | 143 | /// Assign a static ip address to the pod 144 | /// 145 | /// Can be specified multiple times 146 | #[arg(long)] 147 | ip: Vec, 148 | 149 | /// Logging driver specific options 150 | /// 151 | /// Can be specified multiple times 152 | #[arg(long, value_name = "NAME=VALUE")] 153 | log_opt: Vec, 154 | 155 | /// Assign a static mac address to the pod 156 | /// 157 | /// Can be specified multiple times 158 | #[arg(long)] 159 | mac_address: Vec, 160 | 161 | /// Do not create `/etc/hosts` for the pod 162 | #[arg(long)] 163 | #[serde(skip_serializing_if = "Not::not")] 164 | no_hosts: bool, 165 | 166 | /// Directory path for seccomp profiles 167 | #[arg(long, value_name = "PATH")] 168 | seccomp_profile_root: Option, 169 | 170 | /// Require HTTPS and verify certificates when contacting registries 171 | #[arg(long, num_args = 0..=1, require_equals = true, default_missing_value = "true")] 172 | tls_verify: Option, 173 | } 174 | 175 | impl Display for PodmanArgs { 176 | fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { 177 | let args = crate::serde::args::to_string(self).map_err(|_| fmt::Error)?; 178 | f.write_str(&args) 179 | } 180 | } 181 | 182 | #[cfg(test)] 183 | mod tests { 184 | use super::*; 185 | 186 | #[test] 187 | fn podman_args_default_display_empty() { 188 | let args = PodmanArgs::default(); 189 | assert!(args.to_string().is_empty()); 190 | } 191 | } 192 | -------------------------------------------------------------------------------- /src/cli/network.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | fmt::{self, Display, Formatter}, 3 | net::IpAddr, 4 | }; 5 | 6 | use clap::{Args, Subcommand}; 7 | use ipnet::IpNet; 8 | use serde::Serialize; 9 | 10 | use crate::quadlet::IpRange; 11 | 12 | #[derive(Subcommand, Debug, Clone, PartialEq)] 13 | pub enum Network { 14 | /// Generate a Podman Quadlet `.network` file 15 | /// 16 | /// For details on options see: 17 | /// https://docs.podman.io/en/stable/markdown/podman-network-create.1.html and 18 | /// https://docs.podman.io/en/stable/markdown/podman-systemd.unit.5.html#network-units-network 19 | #[allow(clippy::doc_markdown)] 20 | #[group(skip)] 21 | Create { 22 | #[command(flatten)] 23 | create: Create, 24 | }, 25 | } 26 | 27 | impl From for crate::quadlet::Network { 28 | fn from(value: Network) -> Self { 29 | let Network::Create { create } = value; 30 | create.into() 31 | } 32 | } 33 | 34 | impl From for crate::quadlet::Resource { 35 | fn from(value: Network) -> Self { 36 | crate::quadlet::Network::from(value).into() 37 | } 38 | } 39 | 40 | impl Network { 41 | pub fn name(&self) -> &str { 42 | let Self::Create { create } = self; 43 | &create.name 44 | } 45 | } 46 | 47 | #[allow(clippy::doc_markdown)] 48 | #[derive(Args, Debug, Clone, PartialEq)] 49 | pub struct Create { 50 | /// Disable the DNS plugin for the network 51 | /// 52 | /// Converts to "DisableDNS=true" 53 | #[arg(long)] 54 | pub disable_dns: bool, 55 | 56 | /// Set network-scoped DNS resolver/nameserver for containers in this network 57 | /// 58 | /// Converts to "DNS=IP" 59 | /// 60 | /// Can be specified multiple times 61 | #[arg(long, value_name = "IP")] 62 | pub dns: Vec, 63 | 64 | /// Driver to manage the network 65 | /// 66 | /// Converts to "Driver=DRIVER" 67 | #[arg(short, long)] 68 | pub driver: Option, 69 | 70 | /// Define a gateway for the subnet 71 | /// 72 | /// Converts to "Gateway=GATEWAY" 73 | /// 74 | /// Can be specified multiple times 75 | #[arg(long)] 76 | pub gateway: Vec, 77 | 78 | /// Restrict external access of the network 79 | /// 80 | /// Converts to "Internal=true" 81 | #[arg(long)] 82 | pub internal: bool, 83 | 84 | /// Set the IPAM driver (IP Address Management Driver) for the network 85 | /// 86 | /// Converts to "IPAMDriver=DRIVER" 87 | #[arg(long, value_name = "DRIVER")] 88 | pub ipam_driver: Option, 89 | 90 | /// Allocate container IP from a range 91 | /// 92 | /// The range must be a complete subnet in CIDR notation, or be in the `-` 93 | /// syntax which allows for a more flexible range compared to the CIDR subnet. 94 | /// 95 | /// Converts to "IPRange=IP_RANGE" 96 | #[arg(long)] 97 | pub ip_range: Vec, 98 | 99 | /// Enable IPv6 (Dual Stack) networking 100 | /// 101 | /// Converts to "IPv6=true" 102 | #[arg(long)] 103 | pub ipv6: bool, 104 | 105 | /// Set one or more OCI labels on the network 106 | /// 107 | /// Converts to "Label=KEY=VALUE" 108 | /// 109 | /// Can be specified multiple times 110 | #[arg(long, value_name = "KEY=VALUE")] 111 | pub label: Vec, 112 | 113 | /// Set driver specific options 114 | /// 115 | /// Converts to "Options=OPTION[,...]" 116 | /// 117 | /// Can be specified multiple times 118 | #[arg(short, long, value_name = "OPTION", value_delimiter = ',')] 119 | pub opt: Vec, 120 | 121 | /// The subnet in CIDR notation 122 | /// 123 | /// Converts to "Subnet=SUBNET" 124 | /// 125 | /// Can be specified multiple times 126 | #[arg(long)] 127 | pub subnet: Vec, 128 | 129 | /// Converts to "PodmanArgs=ARGS" 130 | #[command(flatten)] 131 | pub podman_args: PodmanArgs, 132 | 133 | /// The name of the network to create 134 | /// 135 | /// This will be used as the name of the generated file when used with 136 | /// the --file option without a filename 137 | pub name: String, 138 | } 139 | 140 | impl From for crate::quadlet::Network { 141 | fn from(value: Create) -> Self { 142 | let podman_args = value.podman_args.to_string(); 143 | Self { 144 | disable_dns: value.disable_dns, 145 | dns: value.dns, 146 | driver: value.driver, 147 | gateway: value.gateway, 148 | internal: value.internal, 149 | ipam_driver: value.ipam_driver, 150 | ip_range: value.ip_range, 151 | ipv6: value.ipv6, 152 | label: value.label, 153 | options: value.opt, 154 | podman_args: (!podman_args.is_empty()).then_some(podman_args), 155 | subnet: value.subnet, 156 | } 157 | } 158 | } 159 | 160 | #[derive(Args, Serialize, Debug, Default, Clone, PartialEq)] 161 | #[serde(rename_all = "kebab-case")] 162 | pub struct PodmanArgs { 163 | /// Maps to the `network_interface` option in the network config 164 | #[arg(long, value_name = "NAME")] 165 | pub interface_name: Option, 166 | 167 | /// A static route to add to every container in this network 168 | /// 169 | /// Can be specified multiple times 170 | #[arg(long)] 171 | pub route: Vec, 172 | } 173 | 174 | impl Display for PodmanArgs { 175 | fn fmt(&self, f: &mut Formatter) -> fmt::Result { 176 | let args = crate::serde::args::to_string(self).map_err(|_| fmt::Error)?; 177 | f.write_str(&args) 178 | } 179 | } 180 | 181 | #[cfg(test)] 182 | mod tests { 183 | use super::*; 184 | 185 | #[test] 186 | fn podman_args_default_display_empty() { 187 | let args = PodmanArgs::default(); 188 | assert!(args.to_string().is_empty()); 189 | } 190 | } 191 | -------------------------------------------------------------------------------- /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/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/unit.rs: -------------------------------------------------------------------------------- 1 | use std::fmt::{self, Display, Formatter}; 2 | 3 | use clap::Args; 4 | use color_eyre::{ 5 | eyre::{self, bail, eyre}, 6 | Section, 7 | }; 8 | use compose_spec::service::{Condition, Dependency}; 9 | use serde::Serialize; 10 | 11 | use crate::serde::quadlet::quote_spaces_join_space; 12 | 13 | // Common systemd unit options 14 | // From [systemd.unit](https://www.freedesktop.org/software/systemd/man/systemd.unit.html) 15 | #[allow(clippy::doc_markdown)] 16 | #[derive(Serialize, Args, Default, Debug, Clone, PartialEq)] 17 | #[serde(rename_all = "PascalCase")] 18 | pub struct Unit { 19 | /// Add a description to the unit 20 | /// 21 | /// A description should be a short, human readable title of the unit 22 | /// 23 | /// Converts to "Description=DESCRIPTION" 24 | #[arg(short, long)] 25 | description: Option, 26 | 27 | /// Add (weak) requirement dependencies to the unit 28 | /// 29 | /// Converts to "Wants=WANTS[ ...]" 30 | /// 31 | /// Can be specified multiple times 32 | #[arg(long)] 33 | #[serde( 34 | serialize_with = "quote_spaces_join_space", 35 | skip_serializing_if = "Vec::is_empty" 36 | )] 37 | wants: Vec, 38 | 39 | /// Similar to --wants, but adds stronger requirement dependencies 40 | /// 41 | /// Converts to "Requires=REQUIRES[ ...]" 42 | /// 43 | /// Can be specified multiple times 44 | #[arg(long)] 45 | #[serde( 46 | serialize_with = "quote_spaces_join_space", 47 | skip_serializing_if = "Vec::is_empty" 48 | )] 49 | requires: Vec, 50 | 51 | /// Similar to --requires, but when the dependency stops, this unit also stops 52 | /// 53 | /// Converts to "BindsTo=BINDS_TO[ ...]" 54 | /// 55 | /// Can be specified multiple times 56 | #[arg(long)] 57 | #[serde( 58 | serialize_with = "quote_spaces_join_space", 59 | skip_serializing_if = "Vec::is_empty" 60 | )] 61 | binds_to: Vec, 62 | 63 | /// Configure ordering dependency between units 64 | /// 65 | /// Converts to "Before=BEFORE[ ...]" 66 | /// 67 | /// Can be specified multiple times 68 | #[arg(long)] 69 | #[serde( 70 | serialize_with = "quote_spaces_join_space", 71 | skip_serializing_if = "Vec::is_empty" 72 | )] 73 | before: Vec, 74 | 75 | /// Configure ordering dependency between units 76 | /// 77 | /// Converts to "After=AFTER[ ...]" 78 | /// 79 | /// Can be specified multiple times 80 | #[arg(long)] 81 | #[serde( 82 | serialize_with = "quote_spaces_join_space", 83 | skip_serializing_if = "Vec::is_empty" 84 | )] 85 | after: Vec, 86 | } 87 | 88 | impl Unit { 89 | /// Returns `true` if all fields are empty. 90 | pub fn is_empty(&self) -> bool { 91 | let Self { 92 | description, 93 | wants, 94 | requires, 95 | binds_to, 96 | before, 97 | after, 98 | } = self; 99 | 100 | description.is_none() 101 | && wants.is_empty() 102 | && requires.is_empty() 103 | && binds_to.is_empty() 104 | && before.is_empty() 105 | && after.is_empty() 106 | } 107 | 108 | /// Add a compose [`Service`](compose_spec::Service) [`Dependency`] to the unit. 109 | /// 110 | /// # Errors 111 | /// 112 | /// Returns an error if the [`Condition`] is not [`ServiceStarted`](Condition::ServiceStarted) 113 | /// or the [`Dependency`] is set to `restart` but is not `required`. 114 | pub fn add_dependency( 115 | &mut self, 116 | mut name: String, 117 | Dependency { 118 | condition, 119 | restart, 120 | required, 121 | }: Dependency, 122 | ) -> eyre::Result<()> { 123 | match condition { 124 | Condition::ServiceStarted => {} 125 | Condition::ServiceHealthy => { 126 | return Err(condition_eyre(condition, "Notify=healthy", "Container")); 127 | } 128 | Condition::ServiceCompletedSuccessfully => { 129 | return Err(condition_eyre(condition, "Type=oneshot", "Service")); 130 | } 131 | } 132 | 133 | // Which list to add the dependency to depends on whether to restart this unit and if the 134 | // dependency is required. 135 | let list = match (restart, required) { 136 | (true, true) => &mut self.binds_to, 137 | (true, false) => { 138 | bail!("restarting a service for a dependency that is not required is unsupported"); 139 | } 140 | (false, true) => &mut self.requires, 141 | (false, false) => &mut self.wants, 142 | }; 143 | 144 | name.push_str(".service"); 145 | list.push(name.clone()); 146 | self.after.push(name); 147 | 148 | Ok(()) 149 | } 150 | } 151 | 152 | /// Create an [`eyre::Report`] for an unsupported compose [`Dependency`] [`Condition`]. 153 | /// 154 | /// Suggests using `option` in `section` instead. 155 | fn condition_eyre(condition: Condition, option: &str, section: &str) -> eyre::Report { 156 | eyre!("dependency condition `{condition}` is not directly supported").suggestion(format!( 157 | "try using `{option}` in the [{section}] section of the dependency" 158 | )) 159 | } 160 | 161 | impl Display for Unit { 162 | fn fmt(&self, f: &mut Formatter) -> fmt::Result { 163 | let unit = crate::serde::quadlet::to_string(self).map_err(|_| fmt::Error)?; 164 | f.write_str(&unit) 165 | } 166 | } 167 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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