From e2fefcdf9c6927a54ca559f4b81959ea1bb27989 Mon Sep 17 00:00:00 2001 From: magges Date: Sat, 11 Jul 2026 10:51:14 +0000 Subject: [PATCH] Add reproducible build system, native Unraid plugin, and WebUI - versions.env pins podman, conmon, crun, netavark, aardvark-dns, passt, and fuse-overlayfs to verified upstream source checksums; SlackBuild recipes, scripts/build-packages.sh, checksums.sh, release.sh, and update-versions.sh implement the reproducible pipeline; GitHub Actions workflows build in a Slackware container and publish releases without committing any binaries. - plugin/podman.plg installs/updates/removes all eight packages (the seven components plus the plugin's own unraid-podman scaffolding package) via upgradepkg, using the official Unraid array-event hook mechanism (event/disks_mounted, event/stopping) instead of editing /boot/config/go. rc.podman and the sbin/ helper scripts implement storage creation, config seeding/sync, preflight checks, autostart with per-container Safe-Mode, and package verify/update/rollback. - webui/plugins/podman implements the Dashboard, Containers, Pods, Images, Volumes, Networks, Logs, Terminal, Compose, and Settings panels against the approved mockup (webui/mockups/prototype.html), talking to podman system service exclusively via PodmanClient.php (libpod REST API over the Unix socket), with two documented exceptions: Terminal's one-shot exec model and Compose's use of the podman compose CLI, since libpod has no REST equivalent for either. - docs/ARCHITECTURE.md and docs/ROADMAP.md record the design decisions and honest current status (syntax-checked, unit- and integration-tested against fake sockets/servers; not yet run against a real Unraid/Podman/Slackware system). Co-Authored-By: Claude Sonnet 5 --- .editorconfig | 21 + .gitattributes | 12 + .github/CODEOWNERS | 9 + .github/CODE_OF_CONDUCT.md | 51 + .github/ISSUE_TEMPLATE/bug_report.md | 39 + .github/ISSUE_TEMPLATE/config.yml | 8 + .github/ISSUE_TEMPLATE/feature_request.md | 23 + .github/PULL_REQUEST_TEMPLATE.md | 32 + .github/SECURITY.md | 37 + .github/workflows/build-packages.yml | 92 ++ .github/workflows/lint.yml | 53 + .github/workflows/release.yml | 96 ++ .gitignore | 46 + CHANGELOG.md | 17 + CONTRIBUTING.md | 83 ++ LICENSE | 21 + assets/README.md | 15 + assets/icons/.gitkeep | 0 assets/screenshots/.gitkeep | 0 config/containers.conf | 23 + config/podman.cfg.example | 25 + config/policy.json | 11 + config/registries.conf | 17 + config/storage.conf | 21 + docs/ARCHITECTURE.md | 656 ++++++++++++ docs/FAQ.md | 30 + docs/INSTALL.md | 39 + docs/ROADMAP.md | 100 ++ docs/TROUBLESHOOTING.md | 41 + packages/README.md | 59 ++ packages/aardvark-dns/README.md | 15 + packages/aardvark-dns/aardvark-dns.SlackBuild | 46 + packages/aardvark-dns/patches/.gitkeep | 0 packages/aardvark-dns/slack-desc | 19 + packages/conmon/README.md | 15 + packages/conmon/conmon.SlackBuild | 46 + packages/conmon/patches/.gitkeep | 0 packages/conmon/slack-desc | 19 + packages/containers-common/README.md | 15 + .../containers-common.SlackBuild | 33 + packages/containers-common/patches/.gitkeep | 0 packages/containers-common/slack-desc | 19 + packages/crun/README.md | 15 + packages/crun/crun.SlackBuild | 59 ++ packages/crun/patches/.gitkeep | 0 packages/crun/slack-desc | 19 + packages/fuse-overlayfs/README.md | 15 + .../fuse-overlayfs/fuse-overlayfs.SlackBuild | 54 + packages/fuse-overlayfs/patches/.gitkeep | 0 packages/fuse-overlayfs/slack-desc | 19 + packages/netavark/README.md | 15 + packages/netavark/netavark.SlackBuild | 49 + packages/netavark/patches/.gitkeep | 0 packages/netavark/slack-desc | 19 + packages/passt/README.md | 21 + packages/passt/passt.SlackBuild | 49 + packages/passt/patches/.gitkeep | 0 packages/passt/slack-desc | 19 + packages/podman/README.md | 15 + packages/podman/patches/.gitkeep | 0 packages/podman/podman.SlackBuild | 74 ++ packages/podman/slack-desc | 19 + packages/unraid-podman/README.md | 16 + packages/unraid-podman/patches/.gitkeep | 0 packages/unraid-podman/slack-desc | 19 + .../unraid-podman/unraid-podman.SlackBuild | 84 ++ plugin/boot-config/plugins/podman/README.md | 27 + plugin/boot-config/plugins/podman/autostart | 0 .../plugins/podman/autostart-delay | 0 .../plugins/podman/backup/config/.gitkeep | 0 .../plugins/podman/backup/packages/.gitkeep | 0 .../plugins/podman/networks/.gitkeep | 0 plugin/event/disks_mounted | 25 + plugin/event/stopping | 19 + plugin/podman.plg | 365 +++++++ plugin/rc.d/rc.podman | 244 +++++ plugin/sbin/podman-autostart.sh | 172 ++++ plugin/sbin/podman-backup.sh | 168 ++++ plugin/sbin/podman-common.sh | 173 ++++ plugin/sbin/podman-config.sh | 141 +++ plugin/sbin/podman-preflight.sh | 150 +++ plugin/sbin/podman-storage.sh | 180 ++++ plugin/sbin/podman-uninstall-cleanup.sh | 94 ++ plugin/sbin/podman-update-packages.sh | 134 +++ plugin/sbin/podman-verify-packages.sh | 127 +++ scripts/build-packages.sh | 103 ++ scripts/checksums.sh | 70 ++ scripts/ci/buildenv-versions.env | 46 + scripts/ci/setup-slackware-buildenv.sh | 152 +++ scripts/dev/lint.sh | 34 + scripts/lib/slackbuild-common.sh | 176 ++++ scripts/release.sh | 146 +++ scripts/update-versions.sh | 151 +++ scripts/version-bump.sh | 21 + versions.env | 86 ++ webui/README.md | 72 ++ webui/mockups/prototype.html | 949 ++++++++++++++++++ webui/plugins/podman/Podman.page | 256 +++++ webui/plugins/podman/ajax/compose.php | 179 ++++ webui/plugins/podman/ajax/containers.php | 132 +++ webui/plugins/podman/ajax/exec.php | 67 ++ webui/plugins/podman/ajax/images.php | 88 ++ webui/plugins/podman/ajax/networks.php | 93 ++ webui/plugins/podman/ajax/pods.php | 103 ++ webui/plugins/podman/ajax/settings.php | 159 +++ webui/plugins/podman/ajax/system.php | 76 ++ webui/plugins/podman/ajax/volumes.php | 79 ++ webui/plugins/podman/images/.gitkeep | 0 webui/plugins/podman/include/Config.php | 106 ++ webui/plugins/podman/include/PodmanClient.php | 404 ++++++++ webui/plugins/podman/include/bootstrap.php | 37 + webui/plugins/podman/include/helpers.php | 107 ++ webui/plugins/podman/javascript/app.js | 186 ++++ webui/plugins/podman/javascript/compose.js | 83 ++ webui/plugins/podman/javascript/containers.js | 125 +++ webui/plugins/podman/javascript/dashboard.js | 52 + webui/plugins/podman/javascript/images.js | 69 ++ webui/plugins/podman/javascript/logs.js | 102 ++ webui/plugins/podman/javascript/networks.js | 72 ++ webui/plugins/podman/javascript/pods.js | 50 + webui/plugins/podman/javascript/settings.js | 95 ++ webui/plugins/podman/javascript/terminal.js | 89 ++ webui/plugins/podman/javascript/volumes.js | 68 ++ webui/plugins/podman/styles/podman.css | 225 +++++ 124 files changed, 9611 insertions(+) create mode 100644 .editorconfig create mode 100644 .gitattributes create mode 100644 .github/CODEOWNERS create mode 100644 .github/CODE_OF_CONDUCT.md create mode 100644 .github/ISSUE_TEMPLATE/bug_report.md create mode 100644 .github/ISSUE_TEMPLATE/config.yml create mode 100644 .github/ISSUE_TEMPLATE/feature_request.md create mode 100644 .github/PULL_REQUEST_TEMPLATE.md create mode 100644 .github/SECURITY.md create mode 100644 .github/workflows/build-packages.yml create mode 100644 .github/workflows/lint.yml create mode 100644 .github/workflows/release.yml create mode 100644 .gitignore create mode 100644 CHANGELOG.md create mode 100644 CONTRIBUTING.md create mode 100644 LICENSE create mode 100644 assets/README.md create mode 100644 assets/icons/.gitkeep create mode 100644 assets/screenshots/.gitkeep create mode 100644 config/containers.conf create mode 100644 config/podman.cfg.example create mode 100644 config/policy.json create mode 100644 config/registries.conf create mode 100644 config/storage.conf create mode 100644 docs/ARCHITECTURE.md create mode 100644 docs/FAQ.md create mode 100644 docs/INSTALL.md create mode 100644 docs/ROADMAP.md create mode 100644 docs/TROUBLESHOOTING.md create mode 100644 packages/README.md create mode 100644 packages/aardvark-dns/README.md create mode 100755 packages/aardvark-dns/aardvark-dns.SlackBuild create mode 100644 packages/aardvark-dns/patches/.gitkeep create mode 100644 packages/aardvark-dns/slack-desc create mode 100644 packages/conmon/README.md create mode 100755 packages/conmon/conmon.SlackBuild create mode 100644 packages/conmon/patches/.gitkeep create mode 100644 packages/conmon/slack-desc create mode 100644 packages/containers-common/README.md create mode 100755 packages/containers-common/containers-common.SlackBuild create mode 100644 packages/containers-common/patches/.gitkeep create mode 100644 packages/containers-common/slack-desc create mode 100644 packages/crun/README.md create mode 100755 packages/crun/crun.SlackBuild create mode 100644 packages/crun/patches/.gitkeep create mode 100644 packages/crun/slack-desc create mode 100644 packages/fuse-overlayfs/README.md create mode 100755 packages/fuse-overlayfs/fuse-overlayfs.SlackBuild create mode 100644 packages/fuse-overlayfs/patches/.gitkeep create mode 100644 packages/fuse-overlayfs/slack-desc create mode 100644 packages/netavark/README.md create mode 100755 packages/netavark/netavark.SlackBuild create mode 100644 packages/netavark/patches/.gitkeep create mode 100644 packages/netavark/slack-desc create mode 100644 packages/passt/README.md create mode 100755 packages/passt/passt.SlackBuild create mode 100644 packages/passt/patches/.gitkeep create mode 100644 packages/passt/slack-desc create mode 100644 packages/podman/README.md create mode 100644 packages/podman/patches/.gitkeep create mode 100755 packages/podman/podman.SlackBuild create mode 100644 packages/podman/slack-desc create mode 100644 packages/unraid-podman/README.md create mode 100644 packages/unraid-podman/patches/.gitkeep create mode 100644 packages/unraid-podman/slack-desc create mode 100755 packages/unraid-podman/unraid-podman.SlackBuild create mode 100644 plugin/boot-config/plugins/podman/README.md create mode 100644 plugin/boot-config/plugins/podman/autostart create mode 100644 plugin/boot-config/plugins/podman/autostart-delay create mode 100644 plugin/boot-config/plugins/podman/backup/config/.gitkeep create mode 100644 plugin/boot-config/plugins/podman/backup/packages/.gitkeep create mode 100644 plugin/boot-config/plugins/podman/networks/.gitkeep create mode 100755 plugin/event/disks_mounted create mode 100755 plugin/event/stopping create mode 100644 plugin/podman.plg create mode 100755 plugin/rc.d/rc.podman create mode 100755 plugin/sbin/podman-autostart.sh create mode 100755 plugin/sbin/podman-backup.sh create mode 100755 plugin/sbin/podman-common.sh create mode 100755 plugin/sbin/podman-config.sh create mode 100755 plugin/sbin/podman-preflight.sh create mode 100755 plugin/sbin/podman-storage.sh create mode 100755 plugin/sbin/podman-uninstall-cleanup.sh create mode 100755 plugin/sbin/podman-update-packages.sh create mode 100755 plugin/sbin/podman-verify-packages.sh create mode 100755 scripts/build-packages.sh create mode 100755 scripts/checksums.sh create mode 100644 scripts/ci/buildenv-versions.env create mode 100755 scripts/ci/setup-slackware-buildenv.sh create mode 100755 scripts/dev/lint.sh create mode 100755 scripts/lib/slackbuild-common.sh create mode 100755 scripts/release.sh create mode 100755 scripts/update-versions.sh create mode 100755 scripts/version-bump.sh create mode 100644 versions.env create mode 100644 webui/README.md create mode 100644 webui/mockups/prototype.html create mode 100644 webui/plugins/podman/Podman.page create mode 100644 webui/plugins/podman/ajax/compose.php create mode 100644 webui/plugins/podman/ajax/containers.php create mode 100644 webui/plugins/podman/ajax/exec.php create mode 100644 webui/plugins/podman/ajax/images.php create mode 100644 webui/plugins/podman/ajax/networks.php create mode 100644 webui/plugins/podman/ajax/pods.php create mode 100644 webui/plugins/podman/ajax/settings.php create mode 100644 webui/plugins/podman/ajax/system.php create mode 100644 webui/plugins/podman/ajax/volumes.php create mode 100644 webui/plugins/podman/images/.gitkeep create mode 100644 webui/plugins/podman/include/Config.php create mode 100644 webui/plugins/podman/include/PodmanClient.php create mode 100644 webui/plugins/podman/include/bootstrap.php create mode 100644 webui/plugins/podman/include/helpers.php create mode 100644 webui/plugins/podman/javascript/app.js create mode 100644 webui/plugins/podman/javascript/compose.js create mode 100644 webui/plugins/podman/javascript/containers.js create mode 100644 webui/plugins/podman/javascript/dashboard.js create mode 100644 webui/plugins/podman/javascript/images.js create mode 100644 webui/plugins/podman/javascript/logs.js create mode 100644 webui/plugins/podman/javascript/networks.js create mode 100644 webui/plugins/podman/javascript/pods.js create mode 100644 webui/plugins/podman/javascript/settings.js create mode 100644 webui/plugins/podman/javascript/terminal.js create mode 100644 webui/plugins/podman/javascript/volumes.js create mode 100644 webui/plugins/podman/styles/podman.css diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..1044e5f --- /dev/null +++ b/.editorconfig @@ -0,0 +1,21 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true +indent_style = space +indent_size = 2 + +[*.sh] +indent_size = 2 + +[*.SlackBuild] +indent_size = 2 + +[Makefile] +indent_style = tab + +[*.md] +trim_trailing_whitespace = false diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..5394c84 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,12 @@ +# Normalize line endings for all text files; Slackware/Unraid scripts and the +# .plg manifest must stay LF, never CRLF. +* text=auto eol=lf + +*.sh text eol=lf +*.SlackBuild text eol=lf +*.plg text eol=lf +rc.* text eol=lf + +*.png binary +*.jpg binary +*.ico binary diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..35b3873 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,9 @@ +# Default owners for everything in the repo, until per-area owners are named. +# See https://docs.github.com/articles/about-codeowners for syntax. + +* @OWNER + +# Example of future area-based ownership once the team grows: +# /packages/ @OWNER @packaging-maintainer +# /webui/ @OWNER @webui-maintainer +# /plugin/ @OWNER diff --git a/.github/CODE_OF_CONDUCT.md b/.github/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..555bd53 --- /dev/null +++ b/.github/CODE_OF_CONDUCT.md @@ -0,0 +1,51 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +We as members, contributors, and leaders pledge to make participation in our +community a harassment-free experience for everyone, regardless of age, body +size, visible or invisible disability, ethnicity, sex characteristics, gender +identity and expression, level of experience, education, socio-economic status, +nationality, personal appearance, race, religion, or sexual identity and +orientation. + +## Our Standards + +Examples of behavior that contributes to a positive environment: + +- Demonstrating empathy and kindness toward other people +- Being respectful of differing opinions, viewpoints, and experiences +- Giving and gracefully accepting constructive feedback +- Focusing on what is best for the community + +Examples of unacceptable behavior: + +- The use of sexualized language or imagery, and unwelcome sexual attention +- Trolling, insulting or derogatory comments, and personal or political attacks +- Public or private harassment +- Publishing others' private information without explicit permission + +## Enforcement Responsibilities + +Project maintainers are responsible for clarifying and enforcing standards of +acceptable behavior and will take appropriate corrective action in response to +any behavior deemed inappropriate, threatening, offensive, or harmful. + +## Scope + +This Code of Conduct applies within all project spaces (issues, pull requests, +discussions) and when an individual is officially representing the project in +public spaces. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported to the maintainers via the contact listed in +[SECURITY.md](SECURITY.md). All complaints will be reviewed and investigated +promptly and fairly. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant](https://www.contributor-covenant.org), +version 2.1, available at +https://www.contributor-covenant.org/version/2/1/code_of_conduct.html. diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..6372ad7 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,39 @@ +--- +name: Bug report +about: Report a problem with the plugin +title: "[Bug] " +labels: bug +assignees: "" +--- + +## Description + +A clear description of what went wrong. + +## Environment + +- Unraid version: +- unraid-podman plugin version: +- Podman version (`podman version`, if available): +- Storage backend (Cache pool / Array disk), pool filesystem (XFS/BTRFS/ZFS): +- Docker also installed/running? (yes/no): + +## Steps to reproduce + +1. ... +2. ... + +## Expected behavior + +## Actual behavior + +## Relevant logs + +Attach or paste relevant excerpts from: +- `/mnt/*/system/podman/logs/podman-service.log` +- Unraid System Log (`Tools -> System Log`) +- `rc.podman status` output + +Please redact any secrets (registry credentials, tokens) before posting. + +## Additional context diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..b4f1037 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,8 @@ +blank_issues_enabled: false +contact_links: + - name: Security vulnerability + url: https://github.com/OWNER/unraid-podman/security/advisories/new + about: Please report security issues privately — see SECURITY.md, not a public issue. + - name: General discussion / questions + url: https://github.com/OWNER/unraid-podman/discussions + about: Usage questions and design discussions that aren't a concrete bug or feature request. diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..9fde2f0 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,23 @@ +--- +name: Feature request +about: Suggest an enhancement or new capability +title: "[Feature] " +labels: enhancement +assignees: "" +--- + +## Problem + +What are you trying to do that isn't currently possible or is unnecessarily hard? + +## Proposed solution + +## Alternatives considered + +## Which architecture phase does this fit? + +See [docs/ROADMAP.md](../../docs/ROADMAP.md) — e.g. MVP / WebUI / Rootless / +Docker-optional / Pods. If unsure, leave blank; this helps triage against the +existing phased plan rather than scope-creeping the current phase. + +## Additional context diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..24a1265 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,32 @@ +## Summary + + + +## Related issue(s) + + + +## Type of change + +- [ ] Documentation +- [ ] Packaging (`packages/`) +- [ ] Plugin core (`.plg`, `rc.podman`, `sbin/*`) +- [ ] WebUI (`webui/`) +- [ ] CI / tooling (`.github/`, `scripts/`) +- [ ] Other + +## Checklist + +- [ ] Changes follow the design in [docs/ARCHITECTURE.md](../docs/ARCHITECTURE.md); + any deliberate deviation is explained below +- [ ] Persistence discipline respected (state written under `/etc`, `/usr`, `/var` + at runtime is also mirrored to `/boot/config/plugins/podman/` or array/cache) +- [ ] No systemd usage introduced +- [ ] Docker coexistence preserved (no shared storage/network/iptables-chain names) +- [ ] `CHANGELOG.md` updated under `[Unreleased]` (if user-facing) +- [ ] Relevant docs under `docs/` updated +- [ ] Tested on a real or virtualized Unraid instance (describe below), where applicable + +## How was this tested? + +## Notes for reviewers diff --git a/.github/SECURITY.md b/.github/SECURITY.md new file mode 100644 index 0000000..875d233 --- /dev/null +++ b/.github/SECURITY.md @@ -0,0 +1,37 @@ +# Security Policy + +## Threat model context + +This plugin runs **rootful Podman**. Its API socket (`/var/run/podman/podman.sock`) +is root-equivalent on the host, in the same way Docker's `docker.sock` is. See +[docs/ARCHITECTURE.md](../docs/ARCHITECTURE.md#19-sicherheitsbetrachtungen-phase-1-rootful) +for the full rationale. Treat any bug that affects socket permissions, WebUI +authentication, or container-to-host isolation as security-sensitive by default. + +## Reporting a vulnerability + +Please **do not** open a public GitHub issue for security vulnerabilities. + +Instead, use one of: + +- GitHub [private security advisories](https://github.com/OWNER/unraid-podman/security/advisories/new) + for this repository, or +- Email the maintainers at `security@OWNER-DOMAIN` (placeholder — update once a + contact address exists). + +Please include: + +- A description of the issue and its potential impact. +- Steps to reproduce (Unraid version, plugin version, storage backend). +- Whether the issue requires local access, network access, or a malicious + container image to trigger. + +## Supported versions + +This project has not yet cut a stable release. Until a `1.0.0` release, only the +latest `main` branch / most recent tag is supported with security fixes. + +## Disclosure process + +We aim to acknowledge reports within 5 business days and to agree on a +coordinated disclosure timeline before any public write-up. diff --git a/.github/workflows/build-packages.yml b/.github/workflows/build-packages.yml new file mode 100644 index 0000000..0f73237 --- /dev/null +++ b/.github/workflows/build-packages.yml @@ -0,0 +1,92 @@ +name: Build Packages + +# Builds the seven Slackware .txz packages defined under packages/ +# (podman, conmon, crun, netavark, aardvark-dns, passt, fuse-overlayfs) +# inside a Slackware container, verifies + consolidates their checksums, and +# uploads the result as a workflow artifact. +# +# Intentionally does NOT commit any built binary back to the repository — +# packages/**, *.txz, dist/ are all git-ignored (see .gitignore). Artifacts +# only ever leave this workflow via the "Upload build artifacts" step below +# (retained by GitHub Actions, not the repo) or, for tagged releases, via +# release.yml attaching them to a GitHub Release. +# +# See docs/ARCHITECTURE.md section 5 (Paketmanagement). + +on: + push: + branches: [main] + paths: + - "packages/**" + - "versions.env" + - "scripts/**" + - ".github/workflows/build-packages.yml" + pull_request: + paths: + - "packages/**" + - "versions.env" + - "scripts/**" + - ".github/workflows/build-packages.yml" + workflow_dispatch: + inputs: + packages: + description: > + Space-separated package names to build (default: all seven). + Example: "podman conmon" + required: false + default: "" + workflow_call: + outputs: + artifact-name: + description: "Name of the uploaded dist/ artifact" + value: ${{ jobs.build.outputs.artifact-name }} + +# Pin the Slackware build image by tag here. vbatts/slackware is a +# long-standing, widely used Slackware Docker image; swap this (and ideally +# pin by digest) if the project standardizes on a different/self-hosted +# base image. See scripts/ci/setup-slackware-buildenv.sh for how missing +# build dependencies are bootstrapped on top of whatever this image ships. +env: + SLACKWARE_IMAGE: "vbatts/slackware:15.0" + +jobs: + build: + name: Build .txz packages + runs-on: ubuntu-latest + container: + image: ${{ env.SLACKWARE_IMAGE }} + outputs: + artifact-name: ${{ steps.artifact-name.outputs.value }} + steps: + - name: Install git and tar (needed before actions/checkout can run) + run: | + (command -v git && command -v tar) || \ + (echo "!! base image is missing git/tar — see SLACKWARE_IMAGE in this workflow" && exit 1) + + - uses: actions/checkout@v4 + + - name: Set up Slackware build environment + run: scripts/ci/setup-slackware-buildenv.sh + + - name: Build packages + run: scripts/build-packages.sh ${{ github.event.inputs.packages }} + + - name: Verify and consolidate checksums + run: scripts/checksums.sh + + - name: Compute artifact name + id: artifact-name + run: echo "value=podman-packages-${{ github.sha }}" >> "$GITHUB_OUTPUT" + + - name: Upload build artifacts + uses: actions/upload-artifact@v4 + with: + name: ${{ steps.artifact-name.outputs.value }} + path: | + dist/*.txz + dist/*.sha256 + dist/*.md5 + dist/CHECKSUMS.sha256 + dist/CHECKSUMS.md5 + if-no-files-found: error + retention-days: 14 diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 0000000..57fe756 --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,53 @@ +name: Lint + +# Static analysis over shell scripts (ShellCheck) and the .plg/XML manifest +# (xmllint) on every push and pull request. Mirrors scripts/dev/lint.sh for +# local use. + +on: + push: + branches: [main] + pull_request: + +jobs: + shellcheck: + name: ShellCheck + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Install ShellCheck + run: | + sudo apt-get update -qq + sudo apt-get install -y --no-install-recommends shellcheck + + - name: Run ShellCheck + run: | + set -eu + # shellcheck disable=SC2038 (find | xargs is fine here, filenames + # in this repo never contain spaces/newlines) + find plugin/rc.d plugin/sbin scripts -type f \ + \( -name '*.sh' -o -name 'rc.*' -o -name '*.SlackBuild' \) \ + -print0 \ + | xargs -0 shellcheck --severity=warning --external-sources + + xmllint: + name: Validate .plg XML + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Install libxml2-utils + run: | + sudo apt-get update -qq + sudo apt-get install -y --no-install-recommends libxml2-utils + + - name: Validate podman.plg is well-formed XML + run: xmllint --noout plugin/podman.plg + + editorconfig: + name: EditorConfig + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: editorconfig-checker/action-editorconfig-checker@main diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..2df34f9 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,96 @@ +name: Release + +# Publishes a GitHub Release for a version tag (vX.Y.Z). +# +# By design, this workflow does NOT bump versions or modify podman.plg +# itself — that happens locally via `scripts/release.sh `, which a +# maintainer reviews, commits, and tags *before* pushing the tag (see that +# script's own printed instructions). This workflow's only job is to: +# 1. Rebuild all packages from the tagged commit in a clean Slackware +# container (reproducibility check + provenance — we don't trust +# whatever a maintainer happened to have in their local dist/). +# 2. Verify checksums match what's already committed in plugin/podman.plg +# at this tag (catches a release.sh run that wasn't followed by a +# matching commit — see the "Verify plg matches build" step). +# 3. Create the GitHub Release and attach the .txz packages, checksum +# manifests, and podman.plg. +# +# See docs/ARCHITECTURE.md section 13 (Updates). + +on: + push: + tags: + - "v*.*.*" + +permissions: + contents: write + +jobs: + build: + name: Build release packages + uses: ./.github/workflows/build-packages.yml + + publish: + name: Publish GitHub Release + needs: build + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Download built packages + uses: actions/download-artifact@v4 + with: + name: ${{ needs.build.outputs.artifact-name }} + path: dist + + - name: Re-verify checksums + run: scripts/checksums.sh dist + + - name: Verify plugin/podman.plg matches these artifacts + # scripts/release.sh should already have been run locally, and its + # resulting podman.plg changes committed as part of this tag, before + # the tag was pushed. This step fails the release loudly if that + # didn't happen, instead of publishing a release whose plg points at + # MD5s that don't match the .txz files actually attached below. + run: | + set -eu + for f in dist/*.txz.md5; do + expected_md5=$(awk '{print $1}' "$f") + txz_name=$(basename "${f%.md5}") + if ! grep -qF "$expected_md5" plugin/podman.plg; then + echo "!! $txz_name's checksum ($expected_md5) is not referenced in plugin/podman.plg." >&2 + echo "!! Did you forget to run scripts/release.sh and commit its changes before tagging?" >&2 + exit 1 + fi + done + echo "All package checksums are referenced in plugin/podman.plg — OK." + + - name: Extract version from tag + id: version + run: echo "value=${GITHUB_REF_NAME#v}" >> "$GITHUB_OUTPUT" + + - name: Extract changelog section for this version + id: changelog + run: | + awk -v ver="${{ steps.version.outputs.value }}" ' + $0 ~ "^## \\[" ver "\\]" { found=1; print; next } + found && /^## \[/ { exit } + found { print } + ' CHANGELOG.md > /tmp/release-notes.md + echo "path=/tmp/release-notes.md" >> "$GITHUB_OUTPUT" + + - name: Create GitHub Release + uses: softprops/action-gh-release@v2 + with: + name: "unraid-podman v${{ steps.version.outputs.value }}" + body_path: ${{ steps.changelog.outputs.path }} + # v0.x tags are treated as pre-releases until the plugin reaches a + # first stable 1.0.0 — see docs/ROADMAP.md. + prerelease: ${{ startsWith(steps.version.outputs.value, '0.') }} + files: | + dist/*.txz + dist/*.sha256 + dist/*.md5 + dist/CHECKSUMS.sha256 + dist/CHECKSUMS.md5 + plugin/podman.plg diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..65c322e --- /dev/null +++ b/.gitignore @@ -0,0 +1,46 @@ +# --- Slackware package build artifacts --- +packages/**/src/ +packages/**/pkg/ +packages/**/work/ +packages/**/*.txz +packages/**/*.tgz +packages/**/*.txz.md5 +packages/**/*.tgz.md5 + +# --- Build / release output --- +/dist/ +/build/ +/release/ +*.plg.bak + +# --- Logs --- +*.log +/scripts/dev/*.log + +# --- Editor / IDE --- +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# --- OS cruft --- +.DS_Store +Thumbs.db + +# --- WebUI tooling (once JS/PHP tooling is added) --- +node_modules/ +vendor/ +composer.lock +package-lock.json + +# --- Local/dev config overrides, never commit real secrets --- +*.local +.env +.env.* +config/*.secret + +# --- Downloaded upstream sources used during package builds --- +packages/**/*.tar.gz +packages/**/*.tar.xz +packages/**/*.tar.bz2 diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..7c880e4 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,17 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html) +for the plugin version (independent of the bundled Podman/upstream package versions, +see [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md#52-build-strategie)). + +## [Unreleased] + +### Added +- Initial repository scaffolding: directory structure, documentation skeleton, + CI workflow stubs, and community health files. +- Architecture documentation (`docs/ARCHITECTURE.md`). + +[Unreleased]: https://github.com/OWNER/unraid-podman/compare/HEAD...HEAD diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..677c31a --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,83 @@ +# Contributing to unraid-podman + +Thanks for your interest in contributing. This project is in an early, +architecture-first stage — please read [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) +before proposing structural changes, so new work stays consistent with the agreed +design (RAM-root persistence model, no systemd, Docker coexistence, etc.). + +## Ground rules + +- Discuss non-trivial changes in an issue before opening a PR — this repo makes + deliberate, sometimes non-obvious trade-offs (see the "Offene Fragen / Risiken" + section of the architecture doc) and we'd rather align early than rework a PR. +- Keep persistence discipline: anything written under `/etc`, `/usr`, `/var` on a + running Unraid system is lost on reboot. Any new config or state must be mirrored + to `/boot/config/plugins/podman/` or an array/cache path — see + [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md#7-persistenz-strategie-zusammenfassung-der-prinzipien). +- No systemd. Init/process management goes through `plugin/rc.d/rc.podman` in the + classic BSD `start|stop|restart|status` style. +- Don't introduce changes that could destabilize a coexisting Docker installation + (shared storage paths, shared bridge/network names, shared iptables chains). + +## Repository layout + +See the "Repository structure" section in [README.md](README.md). In short: + +| Directory | What goes here | +|---|---| +| `packages/` | One subdirectory per Slackware `.txz` package (podman, conmon, crun, netavark, aardvark-dns, containers-common, fuse-overlayfs, passt) | +| `plugin/` | The `.plg` manifest, `rc.d/rc.podman`, `sbin/` helper scripts, and default files staged for `/boot/config/plugins/podman/` | +| `webui/` | Dynamix-style GUI pages under `plugins/podman/` (later phase) | +| `config/` | Default `containers.conf` / `storage.conf` / `registries.conf` / `policy.json` templates | +| `scripts/` | Build and release tooling, not shipped to end users | +| `docs/` | Architecture, install guide, FAQ, troubleshooting, roadmap | + +## Development setup + +Podman package builds must target Slackware compatibility with Unraid's current +base — this generally means building inside a Slackware-compatible container +rather than on your own distro. Run `scripts/build-packages.sh` (optionally +`scripts/build-packages.sh ` for a single package) inside such a +container; `scripts/ci/setup-slackware-buildenv.sh` bootstraps the toolchain +and C library dependencies the build needs. `.github/workflows/build-packages.yml` +runs the same scripts in CI and is the reference for the exact container image +and setup sequence. + +For anything touching `plugin/rc.d/rc.podman`, `plugin/sbin/*.sh`, or `podman.plg`, +test on a real or virtualized Unraid instance — behavior around RAM-root, `/boot` +persistence, and array/cache availability at boot cannot be fully validated on a +generic Linux box. + +## Coding conventions + +- **Shell scripts** (`rc.podman`, `sbin/*.sh`, `scripts/*.sh`): POSIX-compatible + where practical, `set -eu` at minimum, and must pass + [ShellCheck](https://www.shellcheck.net/) (see `.github/workflows/lint.yml`). +- **Slackware package recipes** (`packages/*`): follow standard `SlackBuild` + conventions (`slack-desc` limited to 70 columns / 11 lines, `doinst.sh` for + post-install steps). +- **PHP/JS in `webui/`**: follow the conventions of Unraid's existing Dynamix + plugins for consistency with the rest of the GUI. +- Commit messages: [Conventional Commits](https://www.conventionalcommits.org/) + (`feat:`, `fix:`, `docs:`, `build:`, `ci:`, ...) so changelog generation and + history stay readable. + +## Pull requests + +1. Fork, branch off `main`, keep PRs focused on a single concern. +2. Update `CHANGELOG.md` under `[Unreleased]` for any user-facing change. +3. Update relevant docs under `docs/` if behavior or structure changes. +4. Make sure CI (lint / package build checks) passes. +5. Describe how you tested the change, especially for anything touching boot + behavior, persistence, or Docker coexistence. + +## Reporting bugs / requesting features + +Use the issue templates under `.github/ISSUE_TEMPLATE/`. For security-relevant +reports (rootful socket exposure, privilege escalation, etc.), follow +[SECURITY.md](.github/SECURITY.md) instead of a public issue. + +## Code of Conduct + +This project follows the [Code of Conduct](.github/CODE_OF_CONDUCT.md). By +participating, you agree to uphold it. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..575f94c --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 unraid-podman contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/assets/README.md b/assets/README.md new file mode 100644 index 0000000..bfaa4dc --- /dev/null +++ b/assets/README.md @@ -0,0 +1,15 @@ +# assets/ + +Branding and imagery used by the plugin manifest and (later) the WebUI. + +``` +assets/ +├── icons/ Plugin icon(s) referenced by plugin/podman.plg (icon="podman") +│ and by webui/plugins/podman/. Unraid convention: PNG, square, +│ ~64x64 or SVG. +└── screenshots/ Screenshots used in README.md / docs/ once a WebUI exists. +``` + +No binary assets are checked in yet — this is scaffolding only. When adding the +real plugin icon, keep a source (SVG) alongside the exported PNG(s) so it can be +re-exported at other sizes later. diff --git a/assets/icons/.gitkeep b/assets/icons/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/assets/screenshots/.gitkeep b/assets/screenshots/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/config/containers.conf b/config/containers.conf new file mode 100644 index 0000000..1400990 --- /dev/null +++ b/config/containers.conf @@ -0,0 +1,23 @@ +# Default containers.conf template for unraid-podman. +# +# This is a source template shipped in the repository/package. At install/boot +# time it is copied to /boot/config/plugins/podman/containers.conf (if not +# already present, never overwritten on update) and from there synced to +# /etc/containers/containers.conf by rc.podman start. +# +# See docs/ARCHITECTURE.md section 6.1 (Start-/Stop-Skripte) and section 7 +# (Persistenz-Strategie). Placeholder — real defaults to be tuned during MVP +# implementation. +# +# Full reference: https://github.com/containers/common/blob/main/docs/containers.conf.5.md + +[containers] +# TODO: default ulimits, log driver (k8s-file, see docs/ARCHITECTURE.md section 15), etc. + +[engine] +# TODO: static_dir / volume_path / runroot overrides pointing at the cache-pool +# backed storage location instead of RAM-root defaults. + +[network] +# TODO: default network backend (netavark), default subnet range distinct from +# Docker's docker0 range — see docs/ARCHITECTURE.md section 8. diff --git a/config/podman.cfg.example b/config/podman.cfg.example new file mode 100644 index 0000000..32cbdea --- /dev/null +++ b/config/podman.cfg.example @@ -0,0 +1,25 @@ +# Example plugin settings file for unraid-podman. +# +# The real, user-editable copy of this file lives on the flash device at +# /boot/config/plugins/podman/podman.cfg and is the source of truth read by +# rc.podman at every start. See docs/ARCHITECTURE.md section 4.1 and 7. +# +# Format: simple KEY="value" pairs (bash-sourceable), matching the convention +# used by Unraid's own /boot/config/docker.cfg. + +# Path to the directory holding podman.img (cache pool or array disk). +# TODO: finalize default; must NOT be a path under /mnt/user. +STORAGE_PATH="/mnt/cache/system/podman" + +# Size of the podman.img loopback image, in GiB, used only on first creation. +STORAGE_IMAGE_SIZE_GB="20" + +# Whether rc.podman should start automatically when the array starts. +PODMAN_ENABLED="yes" + +# Per-container stop timeout, in seconds, used by `rc.podman stop`. +STOP_TIMEOUT="10" + +# Config schema version, used by the migration logic described in +# docs/ARCHITECTURE.md section 13.1. +CONFIG_SCHEMA_VERSION="1" diff --git a/config/policy.json b/config/policy.json new file mode 100644 index 0000000..94a2041 --- /dev/null +++ b/config/policy.json @@ -0,0 +1,11 @@ +{ + "_comment": "Default container image signature verification policy. Placeholder: accepts all images without signature verification, matching Docker's default trust model on Unraid today. See docs/ARCHITECTURE.md section 10 (Images). Revisit before a 1.0 release if signed-image verification becomes a goal.", + "default": [ + { "type": "insecureAcceptAnything" } + ], + "transports": { + "docker-daemon": { + "": [{ "type": "insecureAcceptAnything" }] + } + } +} diff --git a/config/registries.conf b/config/registries.conf new file mode 100644 index 0000000..c6ac763 --- /dev/null +++ b/config/registries.conf @@ -0,0 +1,17 @@ +# Default registries.conf template for unraid-podman. +# +# Pre-configures unqualified-search registries explicitly, since there is no +# interactive TTY to prompt the user in a boot-script context. +# See docs/ARCHITECTURE.md section 10 (Images). +# +# Full reference: https://github.com/containers/image/blob/main/docs/containers-registries.conf.5.md + +unqualified-search-registries = ["docker.io"] + +# TODO: consider pre-listing common registries (ghcr.io, quay.io, lscr.io) as +# short-name aliases, matching what Unraid Community Applications users expect +# from Docker Hub-centric templates today. + +[[registry]] +prefix = "docker.io" +location = "docker.io" diff --git a/config/storage.conf b/config/storage.conf new file mode 100644 index 0000000..0ce7e66 --- /dev/null +++ b/config/storage.conf @@ -0,0 +1,21 @@ +# Default storage.conf template for unraid-podman. +# +# Defines the Podman storage graph root/driver. On Unraid this MUST point at +# the mounted podman.img loopback (or equivalent cache-pool/disk path), never +# at a path under /mnt/user (FUSE) or RAM-root /var/lib/containers directly. +# See docs/ARCHITECTURE.md section 4.3 and section 10 (Images). +# +# Full reference: https://github.com/containers/storage/blob/main/docs/containers-storage.conf.5.md + +[storage] +driver = "overlay" +# runroot and graphroot are set at runtime by rc.podman based on +# /boot/config/plugins/podman/podman.cfg (configurable storage location), +# not hardcoded here. TODO: document the exact substitution mechanism once +# rc.podman is implemented. +# graphroot = "/var/lib/containers/storage" +# runroot = "/var/run/containers/storage" + +[storage.options] +# TODO: overlay-specific mount options once the loopback filesystem +# (XFS with ftype=1, or BTRFS) is finalized. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md new file mode 100644 index 0000000..33cf77b --- /dev/null +++ b/docs/ARCHITECTURE.md @@ -0,0 +1,656 @@ +# unraid-podman — Architekturplanung + +Natives Unraid-Plugin zur vollständigen Integration von Podman (rootful, Phase 1) als +gleichberechtigte Alternative zu Docker, mit dem langfristigen Ziel, Docker optional +abschaltbar zu machen. + +Status: Planungsdokument, kein Code. Dient als Grundlage für Implementierungs-Tickets. + +--- + +## 0. Ziele & Nicht-Ziele + +**Ziele (Phase 1 / MVP):** +- Podman rootful lauffähig unter Unraid, überlebt Reboots (Config, Images, Container, Volumes, Netzwerke). +- Koexistenz mit Docker ohne Konflikte (Storage, Netzwerk, Ports, iptables-Ketten). +- Start/Stop/Autostart analog zum bestehenden `rc.docker`-Muster, ohne systemd. +- Sauberer Update-/Rollback-Pfad für das Plugin selbst. +- Grundlegendes Logging & Fehlerdiagnose, das GUI-Meldungen von Unraid nutzt. +- Vorbereitung (nicht Umsetzung) einer WebUI-Anbindung. + +**Nicht-Ziele (bewusst verschoben):** +- Rootless Podman (Phase 2+, andere Storage-/UID-Mapping-Anforderungen). +- Podman Pods / Kubernetes-YAML-Support (Phase 3, siehe Roadmap). +- 1:1-Ersatz der Community-Applications-Vorlagen (erfordert eigenen Übersetzungslayer, separat geplant). +- Abschalten von Docker (erst wenn Podman-Pfad stabil ist, siehe Abschnitt 17). + +--- + +## 1. Rahmenbedingungen von Unraid, die die Architektur erzwingen + +| Eigenschaft | Konsequenz für das Plugin | +|---|---| +| Unraid läuft komplett im RAM (SquashFS + tmpfs-Overlay via `unionfs`) | Alles, was `/`, `/etc`, `/usr`, `/var` betrifft, ist bei jedem Reboot verloren. Nichts darf implizit dort persistiert werden. | +| Persistenter Speicher nur unter `/boot` (USB-Stick, FAT32, klein, langsam, schreibintensiv riskant) und `/mnt/*` (Array/Cache/Pools) | Konfiguration klein & textbasiert auf `/boot/config/plugins/...`; große/volatile Daten (Images, Container-Storage, Logs) auf Array/Cache. | +| Kein systemd, sondern BSD-artige `/etc/rc.d/rc.*`-Skripte, angestoßen über `/boot/config/go` | Eigenes `rc.podman`-Init-Skript im klassischen `start|stop|restart|status`-Stil, keine `.service`-Units, keine `systemctl`-Aufrufe irgendwo im Code. | +| Slackware-Basis, Pakete als `.txz` (pkgtools: `installpkg`/`upgradepkg`/`removepkg`) | Podman + Abhängigkeiten müssen als Slackware-kompatible `.txz` gebaut werden, nicht als RPM/DEB. | +| Plugins werden über `.plg`-XML installiert (lädt Pakete, führt ``-Blöcke mit Pre-/Post-Scripts aus) | Das `.plg` ist der zentrale Installations-/Update-/Deinstallations-Mechanismus, vergleichbar mit einem deklarativen Installer. | +| `/mnt/user` (shfs/FUSE) hat Einschränkungen für Overlay-Filesysteme (fehlendes `d_type` in manchen Konstellationen, FUSE-Overhead) | Podman-Storage (`overlay`-Driver) nicht direkt auf `/mnt/user/...` legen, sondern auf ein dediziertes Loopback-Image oder einen direkt gemounteten Pool/Disk-Pfad (Analogie zu `docker.img` bzw. Docker-„Directory“-Modus seit 6.12). | +| Array kann offline sein / erst spät im Boot verfügbar (`emhttpd`, Array-Start ist ein User-Trigger, kein automatischer Boot-Schritt) | `rc.podman` darf nicht blind beim Boot starten, sondern muss auf Array-/Pool-Verfügbarkeit warten bzw. vom `dynamix`-Event „array started“ getriggert werden, wie es `rc.docker` heute schon tut. | +| Unraid pflegt eigene Firewall-/NAT-Logik (`iptables`) für Docker-Netzwerke (`docker0`, custom bridges, macvlan) | Podman-Netzwerke (netavark) müssen eigene, klar abgegrenzte iptables-Ketten/Chains verwenden, um nicht mit Dockers Regeln zu kollidieren. | + +--- + +## 2. Gesamtarchitektur (Überblick) + +``` + ┌───────────────────────────────────────────┐ + │ /boot (USB, FAT32) │ + │ config/plugins/podman/ │ + │ ├─ podman.cfg (Settings) │ + │ ├─ autostart (Flat-File) │ + │ ├─ network.json │ + │ ├─ containers.conf / storage.conf (Vorlagen) + │ └─ backup/ (vorherige Paketversionen) │ + └───────────────────┬─────────────────────────┘ + │ beim Boot kopiert nach /etc, /var/lib + ▼ + ┌────────────────────────────────────────────────────────────────────┐ + │ RAM-Root (tmpfs/unionfs) │ + │ /etc/rc.d/rc.podman (Init-Skript) │ + │ /etc/containers/* (aus /boot kopiert) │ + │ /usr/bin/podman, conmon, crun, netavark, aardvark-dns, ... │ + │ /usr/local/emhttp/plugins/podman/ (WebUI-Stub, PHP) │ + └───────────────────────────┬───────────────────────────────────────┘ + │ bind-mount / Storage-Root zeigt auf + ▼ + ┌────────────────────────────────────────────────────────────────────┐ + │ Persistenter Bereich (Cache-Pool empfohlen) │ + │ /mnt/cache/system/podman/ │ + │ ├─ podman.img (Loopback, XFS/BTRFS, für overlay-Storage) │ + │ │ → gemountet auf /var/lib/containers/storage │ + │ ├─ logs/ │ + │ └─ networks/ (netavark state, falls nicht im Image) │ + └────────────────────────────────────────────────────────────────────┘ +``` + +Kernprinzip: **Trennung von „Konfiguration“ (klein, Text, `/boot`) und „Nutzdaten“ +(groß, binär, Array/Cache)** — exakt das bestehende Unraid-Muster, das auch +`dynamix.docker.manager` verwendet. + +--- + +## 3. Pluginstruktur + +### 3.1 Repository-Layout (Build-/Source-Repo, nicht das, was auf Unraid landet) + +``` +unraid-podman/ +├── podman.plg # Haupt-Plugin-Manifest (XML) +├── packages/ # Slackware .txz Build-Rezepte +│ ├── podman/ +│ ├── conmon/ +│ ├── crun/ +│ ├── netavark/ +│ ├── aardvark-dns/ +│ ├── containers-common/ +│ ├── fuse-overlayfs/ +│ └── passt/ +├── source/ +│ ├── rc.podman # Init-Skript +│ ├── podman-preflight.sh # Startup-Checks +│ ├── podman-autostart.sh +│ ├── podman-backup.sh +│ └── config-templates/ +│ ├── containers.conf +│ ├── storage.conf +│ └── registries.conf +├── emhttp/ +│ └── plugins/podman/ # WebUI (PHP/JS), Phase 2+ +├── scripts/ +│ ├── build-packages.sh +│ └── release.sh +└── CHANGELOG.md +``` + +### 3.2 `.plg`-Manifest — Verantwortlichkeiten + +Das `.plg` ist eine deklarative XML-Datei, die von Unraids `plugin`-Kommando +interpretiert wird. Verantwortlich für: + +- **Metadaten**: `` inkl. + Mindest-Unraid-Version (Kernel-/glibc-Kompatibilität der Podman-Binaries). +- **Entity-Variablen** (XML-Entities) für Basis-URL, Version, MD5-Summen — ermöglicht + Update-Checks über den Plugin-Manager der GUI. +- **``-Blöcke**: + 1. Download & Installation aller acht `.txz`-Pakete (die sieben Komponenten + plus das plugin-eigene `unraid-podman`-Scaffolding-Paket) einheitlich per + `upgradepkg --install-new --reinstall` — funktioniert unverändert für + Erstinstallation und Update (bestätigtes Muster echter Unraid-Plugins, + siehe unten). + 2. ``-Postinstall-Skript: Setzen von Berechtigungen, Anlegen der + Verzeichnisstruktur unter `/boot/config/plugins/podman/` (nur beim ersten + Install, idempotent; Configs nur kopiert, wenn nicht vorhanden — kein + Überschreiben bestehender Nutzerkonfiguration bei Updates), Schreiben der + Versions-Manifest-Datei, erster `rc.podman start`. +- **Array-Start/Stop-Hook**: **kein** Eintrag in `/boot/config/go`. Stattdessen + der offizielle Unraid-Plugin-Event-Mechanismus: + `/usr/local/emhttp/plugins/podman/event/disks_mounted` (startet `rc.podman`, + sobald Cache-Pools/Disks gemountet sind) und `.../event/stopping` (stoppt + `rc.podman` synchron als allererster Schritt der Shutdown-Sequenz, bevor + Unraid die zugrundeliegende Disk unmountet). Dieses Verzeichnis-Konvention + wurde gegen den echten Quellcode etablierter Plugins verifiziert (u. a. + `unassigned.devices`, das exakt dieselben `event/`-Hooks für + `disks_mounted`/`stopping_svcs` verwendet) — kein `/boot/config/go`-Edit + nötig, dadurch auch beim Deinstallieren nichts manuell rückgängig zu machen. +- **``-Block für Deinstallation**: ruft + zuerst `podman-uninstall-cleanup.sh` auf (stoppt `rc.podman`, räumt + Laufzeitzustand auf), dann `removepkg` für alle acht Pakete. Lässt Nutzdaten + unter `/mnt/*` und `/boot/config/plugins/podman/` standardmäßig **stehen** + (explizite Option „Restlose Deinstallation inkl. Daten“ via + `--purge-data`-Flag, kein automatisches Löschen von Nutzdaten). + +### 3.3 Namens- und Versionskonventionen + +- Plugin-Name: `podman` (Namespace-Kollisionen mit CA-Plugins prüfen). +- Paket-Versionierung getrennt von Plugin-Versionierung: `podman.plg` trägt eine eigene + SemVer (`1.2.0`), referenziert aber exakte Upstream-Versionen der Binärpakete + (`podman-5.x.x`, `netavark-1.x.x`, ...) — erlaubt Plugin-Patches (z. B. am + Init-Skript) ohne Podman-Versionssprung. + +--- + +## 4. Verzeichnislayout (vollständig) + +### 4.1 Auf dem Flash-Device (`/boot`, persistent, klein) + +``` +/boot/config/plugins/podman/ +├── podman.cfg # Key=Value, analog docker.cfg (Storage-Pfad, Enable, Optionen) +├── autostart # eine Container-ID/-Name pro Zeile, Reihenfolge = Startreihenfolge +├── autostart-delay # optionale Wartezeiten pro Container (Key=Sekunden) +├── containers.conf # Vorlage, wird nach /etc/containers/ kopiert +├── storage.conf # Vorlage, referenziert podman.img-Pfad +├── registries.conf +├── policy.json +├── networks/ # persistierte netavark-Netzwerkdefinitionen (JSON) +├── backup/ +│ ├── packages//*.txz # vorherige Paketversionen für Rollback +│ └── config// # Config-Snapshots vor Updates +└── plugin.log # Install-/Update-Log des Plugins selbst +``` + +`/boot/config/plugins/podman/` ist bewusst analog zu +`/boot/config/plugins/dynamix.docker.manager/` gehalten (Wiedererkennung für +erfahrene Unraid-Nutzer, Support-Vergleichbarkeit). + +### 4.2 Laufzeit (RAM-Root, bei jedem Boot neu aufgebaut) + +``` +/etc/rc.d/rc.podman # Init-Skript (Symlink-Ziel oder direkt kopiert) +/etc/containers/{containers,storage,registries}.conf # aus /boot kopiert +/etc/containers/policy.json +/etc/cni/ (falls CNI-Fallback statt netavark benötigt) +/usr/bin/{podman,conmon,crun,fuse-overlayfs,passt,pasta} +/usr/libexec/podman/{netavark,aardvark-dns} # matches upstream's own layout +/usr/local/emhttp/plugins/podman/ # WebUI-Stub (statisch aus Paket) +/var/run/podman/podman.sock # API-Socket (rootful) +/var/log/podman -> /mnt/cache/system/podman/logs # Symlink, siehe Abschnitt 13 +``` + +### 4.3 Persistente Nutzdaten (Cache-Pool bevorzugt, Array als Fallback) + +``` +/mnt/cache/system/podman/ # analog /mnt/user/system/docker/ +├── podman.img # Loopback-Datei, XFS (ftype=1) oder BTRFS +│ # → gemountet auf /var/lib/containers/storage +├── logs/ +│ ├── containers/.log +│ └── podman-service.log +└── networks/ # falls netavark-State nicht im Image liegt +``` + +**Warum Cache-Pool statt `/mnt/user`:** Der `overlay`-Storage-Driver benötigt native +Dateisystemsemantik (d_type, xattrs, Hardlinks) ohne den FUSE-Layer von `shfs`. Das +Docker-Vorbild löst das identisch mit `docker.img`/Directory-Modus auf einem echten +Mountpoint. Nutzer ohne Cache-Pool müssen einen Array-Disk-Pfad (`/mnt/diskX/system/podman`) +wählen können — mit deutlicher GUI-Warnung bzgl. Performance und Spin-up-Verhalten. + +--- + +## 5. Paketmanagement + +### 5.1 Zu paketierende Komponenten + +| Paket | Zweck | Bemerkung | +|---|---|---| +| `podman` | Kern-Binary | Statisch gegen möglichst wenige glibc-Versionen bauen oder gegen Unraid-Slackware-Base kompilieren | +| `conmon` | Container-Monitor-Prozess | Pflicht | +| `crun` (empfohlen) / `runc` | OCI-Runtime | `crun` bevorzugt (leichter, cgroup v2-freundlich, kompatibel zu Unraids Kernel-Config) | +| `netavark` + `aardvark-dns` | Netzwerk-Backend + DNS | Ersetzt CNI-Plugins als Default seit Podman 4.x | +| `containers-common` | Default-Configs (`containers.conf`, `seccomp.json`, `registries.conf`) | Wird als Vorlage übernommen, nicht als aktive Config | +| `fuse-overlayfs` | Fallback-Storage-Driver | Für Rootless-Phase 2 vorbereitet, in Phase 1 optional | +| `passt`/`pasta` | Rootless-Networking | Nachfolger von slirp4netns, Phase 2, aber Paket schon mitbauen (geringe Kosten) | +| `catatonit` oder `tini` | Init-Prozess in Containern (optional, falls von Templates genutzt) | | + +### 5.2 Build-Strategie + +- Eigene Build-Pipeline (containerisiert, z. B. in einem Slackware-kompatiblen + Build-Container) statt manuellem Cross-Compile auf laufenden Unraid-Systemen. +- Zielarchitektur: `x86_64` (Unraid unterstützt aktuell keine anderen Architekturen + produktiv) — vereinfacht Matrix erheblich. +- Statisch oder minimal dynamisch gelinkt (Go-Binaries von Podman/netavark/aardvark-dns + sind ohnehin größtenteils statisch), um Abhängigkeits-Drift gegenüber der jeweils + aktuellen Unraid-Slackware-Basis zu minimieren. +- Jedes Paket als eigenständiges `.txz` mit Slackware-Standard-Metadaten + (`slack-desc`, `doinst.sh`), damit `removepkg`/`upgradepkg` korrekt funktionieren. +- Versions-Pinning: Das `.plg` referenziert exakte Paketversionen + MD5, kein + „latest“-Pull zur Laufzeit — reproduzierbare Installationen, Voraussetzung für Rollback. + +### 5.3 Abhängigkeitsprüfung + +- Preflight-Check im `.plg`-Postinstall: Kernel-Version, `cgroup v2` aktiv, + vorhandene `iptables`/`nftables`-Binaries, freier Platz auf Zielpfad für + `podman.img`. Bei Nichterfüllung: Installation abbrechen mit klarer GUI-Meldung + statt eines halb-funktionsfähigen Zustands. + +--- + +## 6. Start-/Stop-Skripte + +### 6.1 `rc.podman` — Design analog zu `rc.docker` + +Klassisches Slackware-BSD-Init-Skript mit Case-Dispatch: + +``` +rc.podman start # Preflight → Storage mounten → API-Service starten → Autostart-Liste abarbeiten +rc.podman stop # Container geordnet stoppen (Timeout) → API-Service stoppen → Storage unmounten +rc.podman restart +rc.podman status # Health-Check-Ausgabe für GUI/CLI +``` + +Kernschritte von `start` (konzeptionell, kein Code): + +1. **Preflight** (`podman-preflight.sh`): prüft, ob konfigurierter Storage-Pfad + (Cache-Pool/Disk) gemountet ist; falls `podman.img` fehlt → anlegen (Erstinstallation) + mit konfigurierter Größe; falls vorhanden → per Loopback mounten. +2. **Config-Sync**: Kopiert `/boot/config/plugins/podman/*.conf` nach + `/etc/containers/` (Boot-Config ist „Source of Truth“, Laufzeit-Kopie ist Cache). +3. **Socket-Service starten**: `podman system service` im Hintergrund (rootful Unix-Socket + unter `/var/run/podman/podman.sock`), damit spätere WebUI/API-Konsumenten und CLI + dieselbe Instanz sehen (statt pro Aufruf neuer Podman-Fork-Prozesse ohne gemeinsamen State + — State liegt zwar in `/var/lib/containers`, ein Service vereinfacht aber Health-Checks, + Event-Streaming und spätere WebUI-Anbindung). +4. **Netzwerke wiederherstellen**: netavark-Netzwerkdefinitionen aus + `/boot/config/plugins/podman/networks/` nach `/etc/containers/networks/` synchronisieren. +5. **Autostart** (`podman-autostart.sh`, siehe Abschnitt 10). +6. **Statusdatei** schreiben (für GUI-Polling), Event ins Unraid-Log (`logger`). + +`stop` in umgekehrter Reihenfolge, mit konfigurierbarem Stop-Timeout pro Container +(analog Dockers „Stop timeout“-Einstellung), danach `podman system service` beenden, +zuletzt Loopback-Storage sauber unmounten (verhindert Dateisystemfehler im `podman.img`). + +### 6.2 Boot-Integration + +- **Offizieller Unraid-Plugin-Event-Mechanismus**, kein `/boot/config/go`-Edit: + Unraids `emhttpd` führt bei jedem Array-/Boot-Event automatisch jedes + ausführbare Skript unter `/usr/local/emhttp/plugins//event/` + aus, sofern vorhanden. Dieses Verhalten wurde gegen den echten Quellcode + etablierter Plugins verifiziert (`unassigned.devices` nutzt exakt dieselbe + Konvention für seine `disks_mounted`/`started`/`stopping_svcs`-Hooks). + unraid-podman nutzt zwei Events: + - `event/disks_mounted` — feuert, sobald Array-Disks **und** Cache-Pools + gemountet sind (Unraids Event-Reihenfolge: `starting` → `array_started` → + `disks_mounted` → `svcs_restarted` → `docker_started` → `libvirt_started` + → `started`). Ruft `rc.podman start` im Hintergrund auf (`& disown`), + damit der Array-Start nicht blockiert. + - `event/stopping` — der **erste** Schritt der Shutdown-Sequenz (vor + `stopping_docker`, `stopping_svcs`, `unmounting_disks`, `stopping_array`). + Ruft `rc.podman stop` **synchron** auf, damit Container gestoppt und + `podman.img` sauber unmountet sind, bevor Unraid die zugrundeliegende + Disk/den Cache-Pool unmountet. + + Dadurch kein Start vor Verfügbarkeit von Cache/Array, und beim Deinstallieren + ist nichts manuell rückgängig zu machen — die Hook-Skripte verschwinden + automatisch mit `removepkg` des `unraid-podman`-Pakets (siehe Abschnitt 3.2). + +--- + +## 7. Persistenz-Strategie (Zusammenfassung der Prinzipien) + +| Datenkategorie | Ablageort | Synchronisationsrichtung | +|---|---|---| +| Plugin-Settings (Storage-Pfad, Feature-Flags) | `/boot/config/plugins/podman/podman.cfg` | Boot → Laufzeit (read-only Kopie) | +| Container-/Netzwerk-/Registry-Config | `/boot/config/plugins/podman/*.conf` | Boot → Laufzeit bei jedem Start | +| Autostart-Liste & Reihenfolge | `/boot/config/plugins/podman/autostart` | Boot → Laufzeit; GUI schreibt zurück nach `/boot` | +| Images, Container, named Volumes, Layer-Cache | `podman.img` auf Cache-Pool | Laufzeit-only, Backup optional via Snapshot | +| Logs | Cache-Pool `logs/` | Laufzeit-only, Rotation | +| Netzwerk-Definitionen (netavark JSON) | Boot-Kopie + Laufzeit-Kopie | bidirektional bei Änderung über GUI/CLI (Watcher oder expliziter „Apply“-Schritt) | + +Grundregel: **Jede Änderung, die ein Nutzer über CLI/GUI an persistenzrelevanten +Objekten vornimmt (neues Netzwerk, geänderte Autostart-Reihenfolge), muss explizit +oder per Watcher nach `/boot/config/plugins/podman/` zurückgeschrieben werden** — +sonst geht sie beim nächsten Reboot verloren. Das ist der Kernunterschied zu +Standard-Linux-Distributionen und der häufigste Fehlerquell-Kandidat bei Docker-artigen +Unraid-Plugins. + +--- + +## 8. Netzwerke + +- **Backend**: `netavark` + `aardvark-dns` (Podman-Default seit 4.x), kein CNI in Phase 1. +- **Default-Bridge**: eigene Bridge `podman0` (nicht `docker0`), eigener privater + Adressraum (konfigurierbar, Default-Vorschlag außerhalb von Dockers Default-Range, + um Kollisionen bei Parallelbetrieb zu vermeiden). +- **Custom Networks**: analog Docker-für-Unraid „Custom Networks“-Feature — GUI-Verwaltung + in Phase 2, Config-Persistenz-Mechanismus aber bereits in Phase 1 vorsehen (Abschnitt 7). +- **macvlan/ipvlan**: gleiche Kernel-Voraussetzungen wie bei Docker; bekannte Unraid-Falle + „macvlan call trap“ (Kernel-Panic bei bestimmten NIC-Treibern) muss dokumentiert und im + Preflight als Warnung ausgegeben werden, wenn macvlan-Netzwerke angelegt werden. +- **iptables/nftables-Koexistenz mit Docker**: netavark legt eigene Chains an + (`NETAVARK-*` Präfix); explizit verifizieren, dass diese nicht mit Dockers + `DOCKER`-Chains oder Unraids eigenen Firewall-Regeln interferieren. Firewall-Reload + durch Docker (bei dessen Start/Stop) darf Podman-Regeln nicht löschen und umgekehrt — + ggf. eigenes `iptables-restore`-Hook nach jedem `rc.podman start`. +- **DNS**: `aardvark-dns` für Container-zu-Container-Namensauflösung innerhalb + benutzerdefinierter Netzwerke, analog Dockers eingebettetem DNS-Server. +- **Port-Publishing**: Kollisionsprüfung mit bereits von Docker/anderen Diensten + belegten Ports als GUI-seitige Validierung (spätere Phase), technisch bereits + durch Kernel/`iptables` ohnehin erzwungen. + +--- + +## 9. Volumes + +- **Named Volumes**: liegen innerhalb von `podman.img` unter + `.../storage/volumes/`, damit sie vom selben Backup-/Rollback-Mechanismus wie Images + erfasst werden. +- **Bind-Mounts** auf Unraid-Shares (`/mnt/user/appdata/...`): empfohlener Standardweg + für Nutzerdaten (analog Dockers „Path“-Mappings in Community-Applications-Templates), + da `/mnt/user/appdata` bereits vom Nutzer im Backup-/Mover-/Cache-Konzept berücksichtigt + wird. Bind-Mounts direkt gegen `/mnt/user` (FUSE) sind für Applikationsdaten unkritisch + (kein Overlay-Storage-Anspruch), nur der Storage-Graph selbst braucht den echten Mount. +- **Permissions/UID-Mapping**: Phase 1 rootful → Container laufen mit denselben + UID/GID-Semantiken wie Docker rootful heute; keine zusätzliche User-Namespace-Remapping- + Komplexität. Wird in Phase 2 (rootless) relevant. +- **Migration-Hilfe (spätere Phase, nicht MVP)**: Werkzeug zum Referenzieren bestehender + Docker-Bind-Mount-Pfade beim Anlegen äquivalenter Podman-Container, um Parallelbetrieb + mit denselben Appdata-Verzeichnissen zu erleichtern (mit klarer Warnung vor + gleichzeitigem Schreibzugriff durch zwei Runtimes). + +--- + +## 10. Images + +- **Storage-Driver**: `overlay` (native, kein `fuse-overlayfs`) auf dem gemounteten + `podman.img` — Performance-Parität mit Dockers Standard-Setup. +- **Registries**: Default `registries.conf` mit Docker Hub + ggf. `ghcr.io`, `quay.io` + vorkonfiguriert, kurz-Namen-Auflösung (`unqualified-search-registries`) explizit + gesetzt statt interaktivem Prompt (der in einer daemonless/Skript-Umgebung nicht + funktioniert). +- **Pull/Push**: Standard-Podman-CLI-Semantik, keine Besonderheiten; Fortschrittsanzeige + später über API-Service-Events für die WebUI nutzbar. +- **Image-Größenmanagement**: `podman.img` ist eine feste/dynamisch wachsende + Loopback-Datei — GUI muss Füllstand anzeigen und Vergrößern anbieten (analog Dockers + „docker.img full“-Problem, das in der Community bekannt und schmerzhaft ist; hier von + Anfang an mit klarer Fehlermeldung statt stillem Fehlschlag lösen, siehe Abschnitt 16). +- **Community-Applications-Kompatibilität**: kein automatischer Import von + Docker-Templates in Phase 1 (eigenes, separates Vorhaben); Podman-Images werden + zunächst über CLI/eigene, minimale Template-Definition verwaltet. + +--- + +## 11. Container (Lifecycle-Management) + +- Lifecycle-Operationen (`create`, `start`, `stop`, `restart`, `remove`, `exec`, + `logs`, `inspect`) laufen über den in Abschnitt 6.1 gestarteten Podman-API-Service, + nicht über Ad-hoc-CLI-Aufrufe aus der GUI heraus — ein gemeinsamer Service reduziert + Race Conditions und ermöglicht Event-Streaming. +- **Labels für GUI-Integration**: eigenes Label-Schema (z. B. `net.unraid.podman.icon`, + `net.unraid.podman.webui`), angelehnt an, aber getrennt von Dockers + `net.unraid.docker.*`-Konventionen, um spätere GUI-Icons/WebUI-Links analog zur + Docker-Tab-Darstellung zu ermöglichen, ohne Namensraum-Kollisionen. +- **Update-Strategie für Container-Images**: „Check for Updates“-Mechanismus + (Digest-Vergleich gegen Registry) als spätere GUI-Funktion, technisch schon in + Phase 1 per `podman image inspect`/`skopeo`-Vergleich vorbereitbar. + +--- + +## 12. Autostart + +Da kein systemd existiert, wird Autostart **explizit vom Plugin verwaltet**, nicht von +Podman selbst (Podman hat kein natives „restart on boot“ ohne generierte Unit-Files): + +- Flache Datei `/boot/config/plugins/podman/autostart`, eine Zeile pro Container + (Name oder ID), Reihenfolge = Zeilen-Reihenfolge = Startreihenfolge — direktes + Äquivalent zu Dockers `/boot/config/plugins/dynamix.docker.manager/docker-autostart` + bzw. der DB-gestützten Nachfolgelösung. +- Optionale, separate Delay-Datei für Wartezeiten zwischen Starts (Abhängigkeiten + zwischen Containern, z. B. DB vor App). +- `rc.podman start` liest die Liste sequenziell, startet jeden Container über den + API-Service, protokolliert Erfolg/Fehler pro Eintrag, bricht bei Einzelfehlern + **nicht** die gesamte Kette ab (ein defekter Container darf nicht alle anderen + blockieren). +- GUI-Checkbox „Autostart“ pro Container (spätere Phase) schreibt direkt in diese + Datei zurück — kein separates DB-Layer in Phase 1, um Komplexität niedrig zu halten + (kann bei Bedarf später durch strukturierteres Format, z. B. JSON, ersetzt werden). + +--- + +## 13. Updates + +### 13.1 Plugin-Update (Podman-Version, Init-Skripte) + +- Über den regulären Unraid-Plugin-Update-Mechanismus: `.plg` mit neuer Version + + neuen Paket-URLs/MD5s wird erneut ausgeführt. +- Vor jedem Update: automatisches Backup (siehe Abschnitt 14) der aktuellen + `.txz`-Pakete und Config-Dateien. +- **Container laufen während des Plugin-Updates weiter**, sofern nur Binaries + ausgetauscht werden (laufende `conmon`/Container-Prozesse sind vom + Dateisystem-Austausch unter `/usr/bin` isoliert, solange der Prozess nicht neu + exec't wird) — erst ein nachfolgender `rc.podman restart` übernimmt die neue + Podman-Version für den API-Service. Klar kommunizieren: Update ≠ sofortiger + Container-Neustart. +- Config-Migrationen (z. B. neues Feld in `containers.conf`) über ein + Versions-gestempeltes Migrationsskript, das beim Postinstall geprüft wird + (`podman.cfg` enthält `CONFIG_SCHEMA_VERSION`). + +### 13.2 Container-Image-Updates + +- Getrennt vom Plugin-Update zu betrachten — reine Podman-Funktionalität + (`podman pull` + Recreate), GUI-Komfortfunktion für Phase 2. + +--- + +## 14. Rollback + +- **Paket-Rollback**: Vor jedem Update werden die aktuell installierten `.txz` nach + `/boot/config/plugins/podman/backup/packages//` kopiert. Ein + „Rollback“-Kommando (Postinstall-Skript, später GUI-Button) installiert diese + Pakete erneut per `upgradepkg --reinstall`. +- **Config-Rollback**: Vor jeder strukturellen Config-Änderung (Plugin-Update mit + Schema-Migration) wird ein Snapshot nach + `/boot/config/plugins/podman/backup/config//` geschrieben; Rollback + kopiert diesen Snapshot zurück und setzt `CONFIG_SCHEMA_VERSION` entsprechend zurück. +- **Storage-Rollback (Images/Container) ist explizit außerhalb des automatischen + Rollbacks**: `podman.img` wird nicht bei jedem Plugin-Update gesichert (zu groß, + zu I/O-intensiv). Stattdessen: dokumentierte, manuell auslösbare Snapshot-Funktion + (z. B. `cp --reflink` auf BTRFS/ZFS-Cache-Pools, wo Reflinks verfügbar sind) als + optionales Feature, kein Zwang. +- Rollback-Historie begrenzen (z. B. letzte 3 Versionen), um den knappen + Flash-Speicher nicht zu erschöpfen. + +--- + +## 15. Logging + +- **Problem**: `/var/log` liegt im RAM und ist bei Reboot weg; `journald` existiert + nicht, daher kann Podmans `journald`-Log-Driver nicht Default sein. +- **Lösung**: Default-Log-Driver `k8s-file` (JSON-per-Zeile, Podman-nativ) mit + Zielverzeichnis unter `/mnt/cache/system/podman/logs/containers/`, per + Symlink von `/var/log/podman` dorthin erreichbar (einheitlicher Pfad für Tools, + die `/var/log` erwarten, ohne RAM-Verlust). +- **Log-Rotation**: eigener Cron-Eintrag (Unraid nutzt `cron` bereits für andere + Plugins über `/boot/config/plugins/dynamix/`-Muster bzw. `/etc/cron.d`) mit + `logrotate`-Konfiguration oder einfachem Größen-/Alter-basiertem Skript, da + Flash/Cache-Platz begrenzt ist. +- **Service-Log** (`podman system service`, `rc.podman`-Ausführung) getrennt von + Container-Logs, ebenfalls auf Cache-Pool, zusätzlich Kurzfassung wichtiger + Ereignisse (Start/Stop/Fehler) über `logger` in Unraids zentrales Syslog, damit + sie in der Standard-„System Log“-GUI sichtbar sind. +- **GUI-Zugriff (spätere Phase)**: Log-Viewer analog Dockers Container-Log-Fenster, + liest direkt aus den `k8s-file`-Logs bzw. streamt über den API-Service. + +--- + +## 16. Fehlerbehandlung + +### 16.1 Startup-Robustheit + +- **Preflight-Checks** vor jedem `rc.podman start` (siehe 6.1): Storage-Mount, + Speicherplatz auf `podman.img`, Kernel-Voraussetzungen, Socket-Verfügbarkeit + (kein verwaister Socket von vorherigem Absturz). +- **Storage-Korruption**: Falls `podman.img` beim Mount-Versuch Dateisystemfehler + zeigt (`xfs_repair`/`btrfs check` schlägt fehl oder wird nicht automatisch + ausgeführt) → Start abbrechen, klare GUI-Fehlermeldung mit Handlungsempfehlung + (Recovery-Tool ausführen, Backup einspielen), **kein** automatisches + Neuanlegen/Formatieren ohne Nutzerbestätigung (Datenverlustrisiko). +- **Autostart-Fehler pro Container**: einzelner fehlgeschlagener Container wird + geloggt und übersprungen, blockiert nicht die restliche Kette (siehe Abschnitt 12); + nach N gescheiterten Autostart-Versuchen in Folge wird der Container automatisch + aus der Autostart-Kette pausiert („Safe-Mode“ pro Container) mit GUI-Hinweis, + um Boot-Loops/Ressourcenverschwendung zu vermeiden. + +### 16.2 Laufzeitfehler + +- **Health-Check des API-Service**: `rc.podman status` prüft Socket-Erreichbarkeit + und meldet Diskrepanzen (Prozess läuft, Socket tot o. ä.) verständlich. +- **Firewall-/Netzwerk-Konflikte**: bei bekannten Fehlerbildern (z. B. Chain-Konflikt + mit Docker, Port bereits belegt) definierte, für Menschen lesbare Fehlermeldungen + statt roher `iptables`/Go-Stacktraces — Fehlerkatalog mit Klartext-Ursache + + nächstem Schritt. +- **Ressourcenerschöpfung** (`podman.img` voll): aktive Prüfung im Preflight und + periodisch (Cron), GUI-Warnung bei > 85 % Füllstand, klare Anleitung zum + Vergrößern des Loopback-Images (bekannte Docker-für-Unraid-Schwachstelle, hier + von Anfang an proaktiv statt reaktiv lösen). +- **Unraid-Benachrichtigungssystem**: kritische Fehler (Storage-Mount fehlgeschlagen, + Autostart-Kette größtenteils fehlgeschlagen, `podman.img` > Schwellwert) werden + über Unraids Standard-Notify-Mechanismus (`/usr/local/emhttp/webGui/scripts/notify`) + ausgelöst, damit sie in GUI-Toasts, optional E-Mail/Pushover (falls vom Nutzer + konfiguriert) erscheinen — kein Parallel-Notify-System erfinden. + +--- + +## 17. Docker-Parallelbetrieb & spätere Deaktivierung + +### Phase 1 — Parallelbetrieb (Pflicht) + +- Getrennte Storage-Roots (`docker.img` vs. `podman.img`), getrennte Default-Bridges, + getrennte iptables-Chain-Präfixe, getrennte Log-Verzeichnisse — keine gemeinsam + genutzten mutable Ressourcen außer read-only Kernel-Features (cgroups, netfilter). +- Ressourcen-Konkurrenz (CPU/RAM/Disk-IO) ist erwartet und wird nicht technisch + verhindert, aber in der GUI/Doku transparent gemacht. +- Beide Runtimes dürfen gleichzeitig laufen, ohne dass Start/Stop des einen den + anderen beeinflusst (insbesondere Firewall-Reloads, siehe Abschnitt 8). + +### Phase 2+ — Docker optional deaktivierbar + +- Neuer Schalter (z. B. in den bestehenden Docker-Settings oder einem neuen + „Container Engine“-Auswahlbereich): „Docker aktiviert / Podman aktiviert / beide“. +- Deaktivierung von Docker bedeutet: `rc.docker stop` + Entfernen des + Autostart-Hooks aus `/boot/config/go` für Docker, **kein** Deinstallieren des + Docker-Plugins selbst (reversibel, Datenerhalt). +- Voraussetzung für diese Phase: Podman-Pfad muss funktional äquivalent zu den von + Nutzern tatsächlich genutzten Docker-Features sein (mind. Custom Networks, Autostart, + Log-Viewer, Update-Check) — technisch als Gate, nicht nur als Empfehlung, im + Freigabeprozess verankern. +- CA-Kompatibilität (Community Applications erwartet aktuell Docker) ist der + wahrscheinlich größte Blocker für „Docker komplett aus“ und sollte als eigenes + Arbeitspaket (Template-Übersetzung Docker→Podman) vor Freigabe dieser Option + behandelt werden. + +--- + +## 18. Zukünftige WebUI + +### 18.1 Architektur (Zielbild, Umsetzung nach MVP) + +- Eigene GUI-Seite unter `/usr/local/emhttp/plugins/podman/`, PHP + JS im + bestehenden Unraid-„Dynamix“-Stil (Konsistenz mit restlicher GUI, wiederverwendbare + CSS-/JS-Assets), analog zur Docker-Tab-Struktur (`dynamix.docker.manager`). +- Backend-Kommunikation **nicht** über Shell-Exec von `podman`-CLI-Aufrufen aus PHP + (fragil, schwer zu parallelisieren), sondern über den in Abschnitt 6.1 laufenden + Podman-API-Service (Unix-Socket, REST, Docker-kompatible Teilmenge der API) — + ermöglicht später auch Wiederverwendung bestehender Docker-API-kompatibler + Frontend-Bibliotheken. +- Echtzeit-Updates (Container-Status, Logs) über Server-Sent Events oder Polling + gegen den API-Service, kein WebSocket-Zwang in Phase 1 der WebUI (geringerer + Implementierungsaufwand, ausreichend für Status-Refresh im Sekundenbereich). + +### 18.2 Funktionsumfang, gestaffelt + +1. **Stufe 1**: Read-only Übersicht (laufende Container, Images, Netzwerke), + Start/Stop/Restart einzelner Container. +2. **Stufe 2**: Container-Erstellung über einfache Formulare (Image, Ports, Volumes, + Env, Labels), Autostart-Verwaltung direkt in GUI (schreibt in Abschnitt-12-Datei). +3. **Stufe 3**: Log-Viewer, Netzwerk-Verwaltung (Custom Networks/macvlan-Anlage), + Update-Check pro Container. +4. **Stufe 4**: Community-Applications-Anbindung/Template-Import (separates Vorhaben). + +### 18.3 Sicherheitsaspekt der WebUI + +- API-Service-Socket nur lokal (`/var/run/podman/podman.sock`), keine TCP-Exposition + nach außen ohne explizite, abgesicherte Opt-in-Konfiguration (TLS + Auth), da + rootful Podman-API faktisch Root-Äquivalent ist — gleiche Vorsicht wie beim + Docker-Socket. + +--- + +## 19. Sicherheitsbetrachtungen (Phase 1, rootful) + +- Rootful Podman-API-Socket ist funktional gleichwertig zu `docker.sock` bzgl. + Angriffsfläche (Zugriff = Root auf dem Host) — Berechtigungen auf den Socket + (`0660`, Gruppe analog `docker`-Gruppe, z. B. neue Gruppe `podman`) restriktiv setzen. + Achtung Docker Sock: root Äquivalenz, daher keine ungeprüfte Gruppenmitgliedschaft + für WebUI-Prozesse vergeben, die nicht ohnehin schon rootäquivalent laufen (`emhttpd` + läuft unter Unraid ohnehin als root, insofern kein zusätzliches Risiko gegenüber + Docker heute — aber explizit dokumentieren, nicht stillschweigend voraussetzen). +- Keine automatische, ungefragte Netzwerk-Exposition von Container-Ports über die + Standard-Bridge hinaus. +- Rollback-/Backup-Mechanismus darf keine Zugangsdaten (Registry-Logins in + `auth.json`) ungesichert auf dem FAT32-Flash ablegen, ohne dass sich der Nutzer + dessen bewusst ist (Flash ist physisch leicht auslesbar) — ggf. Hinweis, sensible + Registry-Credentials bevorzugt nicht dauerhaft dort zu speichern, oder Ablage + ausschließlich auf dem Cache-Pool statt `/boot`. + +--- + +## 20. Phasenplan + +| Phase | Inhalt | +|---|---| +| **MVP (Phase 1)** | Plugin-Grundgerüst, Pakete, `rc.podman`, Storage auf Cache-Pool, Autostart-Datei, Basis-Logging, Preflight/Fehlerbehandlung, Rollback für Pakete/Config, **keine WebUI** (CLI-only, `podman` direkt nutzbar) | +| **Phase 2** | WebUI Stufe 1–2, Custom-Networks-Verwaltung, Log-Viewer, Storage-Snapshot-Feature | +| **Phase 3** | Rootless-Option, Docker-Deaktivierungs-Schalter, CA-Template-Kompatibilitätsschicht | +| **Phase 4** | Pods/Quadlet-artige Konzepte (ohne systemd nur eingeschränkt sinnvoll — ggf. eigenes Skript-Äquivalent statt echter Quadlet/systemd-Generatoren) | + +--- + +## 21. Offene Fragen / Risiken (zur Klärung vor Implementierungsstart) + +1. Ziel-Podman-Version und Update-Kadenz (Tracking von Upstream-CVEs erfordert + Paket-Update-Prozess, nicht nur Erstrelease). +2. Cache-Pool-Pflicht vs. Array-Fallback für `podman.img` — Nutzer ohne Cache-Pool + sind bei Docker heute ebenfalls im Nachteil; gleiche Community-Erwartungshaltung + übernehmen oder verbessern? +2a. Verhalten bei mehreren Cache-Pools (Unraid 6.9+ unterstützt mehrere benannte + Pools) — welchen Pool per Default vorschlagen, wie in GUI wählbar machen. +3. `podman system service` als Dauerprozess vs. Socket-Activation-artiges + On-Demand-Start (ohne systemd nur eingeschränkt nachbaubar) — Dauerprozess ist + der pragmatischere Start, sollte aber gegen Ressourcenverbrauch im Idle + evaluiert werden. +4. Genaues Verhalten bei gleichzeitigem Boot von Docker und Podman bzgl. + iptables-Reihenfolge/Locking (`iptables`-Aufrufe sind nicht atomar über + mehrere Prozesse hinweg) — ggf. Locking-Strategie (`xtables_lock`) explizit prüfen. +5. Lizenz-/Signatur-Anforderungen für Community-Verbreitung des Plugins + (Unraid-Plugin-Repository-Richtlinien, Signierung der `.plg`/Pakete). diff --git a/docs/FAQ.md b/docs/FAQ.md new file mode 100644 index 0000000..4c0a3a9 --- /dev/null +++ b/docs/FAQ.md @@ -0,0 +1,30 @@ +# FAQ + +**Q: Does this replace Docker on Unraid?** +Not initially. Phase 1 runs Podman alongside Docker with fully separate storage, +networking, and firewall rules. A later phase adds an option to disable Docker +once feature parity is reached — see [ROADMAP.md](ROADMAP.md). + +**Q: Is this rootless or rootful Podman?** +Phase 1 is rootful only, matching Docker's current trust model on Unraid. +Rootless is planned for a later phase — see +[ARCHITECTURE.md](ARCHITECTURE.md#0-ziele--nicht-ziele). + +**Q: Will my existing Docker containers/appdata work with Podman?** +Bind-mounted appdata under `/mnt/user/appdata/...` can generally be reused, but +there is no automatic Docker→Podman template migration in Phase 1. See +[ARCHITECTURE.md, section 9 (Volumes)](ARCHITECTURE.md#9-volumes). + +**Q: Why isn't there a WebUI yet?** +The MVP intentionally ships CLI-only to validate the persistence, packaging, and +init model first, before investing in GUI work. See +[ARCHITECTURE.md, section 18](ARCHITECTURE.md#18-zukünftige-webui). + +**Q: Why does this need a cache pool / dedicated disk instead of just using +`/mnt/user`?** +Podman's `overlay` storage driver needs real filesystem semantics that the +`/mnt/user` FUSE layer (`shfs`) doesn't reliably provide. See +[ARCHITECTURE.md, section 4.3](ARCHITECTURE.md#43-persistente-nutzdaten-cache-pool-bevorzugt-array-als-fallback). + +**Q: Is this affiliated with Unraid, Inc. or the Podman project?** +No. This is an independent, community-driven plugin project. diff --git a/docs/INSTALL.md b/docs/INSTALL.md new file mode 100644 index 0000000..02b7c4a --- /dev/null +++ b/docs/INSTALL.md @@ -0,0 +1,39 @@ +# Installation Guide (draft) + +> This guide is a placeholder. Nothing described here is functional yet — the +> project is at the architecture/scaffolding stage. See +> [ARCHITECTURE.md](ARCHITECTURE.md) and [ROADMAP.md](ROADMAP.md). + +## Planned installation flow + +Once a release exists, installation will follow standard Unraid conventions: + +1. In the Unraid WebUI, go to **Plugins → Install Plugin**. +2. Paste the `.plg` URL (or install via Community Applications, once listed). +3. Unraid downloads and runs `podman.plg`, which: + - installs the Slackware `.txz` packages under `packages/`, + - stages default configuration under `/boot/config/plugins/podman/`, + - registers the boot hook in `/boot/config/go`, + - starts the Podman service for the first time. + +## Planned requirements + +- Unraid version: TBD minimum (depends on kernel/cgroup v2 requirements — see + [ARCHITECTURE.md, section 5.3](ARCHITECTURE.md#53-abhängigkeitsprüfung)). +- A cache pool (recommended) or a dedicated array disk path for + `podman.img` — see [ARCHITECTURE.md, section 4.3](ARCHITECTURE.md#43-persistente-nutzdaten-cache-pool-bevorzugt-array-als-fallback). +- Sufficient free space on that pool/disk for the container storage image. + +## Uninstallation (planned) + +Removing the plugin removes the installed packages and init hooks but +**preserves** data under `/mnt/*/system/podman/` and +`/boot/config/plugins/podman/` by default. A separate, explicit option will be +provided for full data removal. + +## Verifying the installation (planned) + +```sh +rc.podman status +podman info +``` diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md new file mode 100644 index 0000000..5c09ff3 --- /dev/null +++ b/docs/ROADMAP.md @@ -0,0 +1,100 @@ +# Roadmap + +This roadmap mirrors the phase plan in +[ARCHITECTURE.md](ARCHITECTURE.md#20-phasenplan). It will be kept in sync as +scope shifts; treat ARCHITECTURE.md as the source of truth for *why*, and this +file as the tracking view for *what/when*. + +## Phase 1 — MVP (CLI core) + +- [x] Repository scaffolding +- [x] Reproducible build system: `versions.env` pins, per-package + `SlackBuild` scripts, `scripts/build-packages.sh` orchestrator, + `scripts/checksums.sh`, `scripts/release.sh`, + `scripts/update-versions.sh`, GitHub Actions + (`build-packages.yml`, `release.yml`, `lint.yml`) — builds podman, + conmon, crun, netavark, aardvark-dns, passt, fuse-overlayfs, and the + plugin's own `unraid-podman` scaffolding package. Not yet run + end-to-end in a real Slackware environment (see note below). +- [x] `podman.plg` install/update/remove manifest — validated well-formed + (including full DTD entity expansion inside INLINE script blocks) and + release-round-tripped against `scripts/release.sh` with synthetic + build artifacts; not yet installed on a real Unraid system. +- [x] `rc.podman` init script (start/stop/restart/status) +- [x] Storage on cache pool (`podman.img`, XFS ftype=1, overlay driver) — + `podman-storage.sh` +- [x] Config sync (`/boot/config/plugins/podman/` ↔ `/etc/containers/`) — + `podman-config.sh` (seed/sync logic sandbox-tested) +- [x] Autostart flat-file mechanism — `podman-autostart.sh`, including + per-container Safe-Mode after repeated failures (parsing logic + unit-tested) +- [x] Preflight checks & error handling (storage mount, disk space, kernel + reqs) — `podman-preflight.sh` +- [x] Basic logging — `podman_log`/`podman_log_error` in `podman-common.sh`; + log rotation still open (cron-based, see ARCHITECTURE.md section 15) +- [x] Package + config rollback (backup previous `.txz` / config snapshots) + — `podman-backup.sh`; plus `podman-verify-packages.sh` and + `podman-update-packages.sh` for integrity checking and reconciliation +- [x] Official Unraid array-event hooks (`event/disks_mounted`, + `event/stopping`) instead of editing `/boot/config/go` — verified + against real-world plugin source (`unassigned.devices`) +- [ ] Real-world install/uninstall/update test on an actual (or virtualized) + Unraid system — everything above has been syntax-checked, unit-tested + in isolation, and XML-validated, but not run against real Unraid/ + Podman/Slackware yet + +## Phase 2 — WebUI & network management + +Built ahead of the original phase order (see [webui/README.md](../webui/README.md) +and the mockup at `webui/mockups/prototype.html`, which was reviewed before +implementation started) — PHP/JS/AJAX, talking to `podman system service` +exclusively via `PodmanClient` (two documented, deliberate exceptions: the +Terminal panel's one-shot exec model, and the Compose panel's use of the +`podman compose` CLI, since libpod has no REST endpoint for either true PTY +sessions or Compose — see those ajax/*.php files' header comments). + +- [x] WebUI stage 1: read-only overview (Dashboard), start/stop/restart/remove + for Containers/Pods, backend logic unit- and integration-tested against + a fake podman.sock server (real Unix socket, real cURL transport) +- [x] Images, Volumes, Networks panels (list + create/remove/pull) — not part + of the original "stage 2/3" split, built alongside stage 1 since the + backend pattern (PodmanClient + one ajax/*.php file) is identical +- [x] Autostart management in GUI (Settings panel — reorder/remove, writes + the same `/boot/config/plugins/podman/autostart` file + `podman-autostart.sh` reads) +- [x] Log viewer (Logs panel, polling-based "Follow") +- [x] Terminal panel — one-shot exec, not a true interactive PTY (see + `ajax/exec.php`); a real PTY would need a WebSocket bridge, tracked + as a follow-up, not implemented as a shell-out workaround +- [x] Compose panel — project list + YAML view + up/down/pull via the + `podman compose` CLI (the one deliberate CLI exception, see + `ajax/compose.php`) +- [ ] Container **creation** forms — the Containers panel manages the + lifecycle of existing containers only; there is no "new container" + form yet (the mockup shows one; the real implementation doesn't wire + it up) +- [ ] Custom network / macvlan creation UI beyond the basic name+subnet form + already in the Networks panel +- [ ] Real interactive PTY terminal (WebSocket bridge) +- [ ] Storage snapshot feature (reflink-based, BTRFS/ZFS cache pools) +- [ ] Run against a real Unraid + Podman install — the backend has been + tested against a hand-written fake podman.sock server (verifying the + transport layer, JSON shaping, and error handling genuinely work), + not against real libpod + +## Phase 3 — Rootless & Docker-optional + +- [ ] Rootless Podman option +- [ ] Docker enable/disable switch +- [ ] Community Applications template compatibility layer + +## Phase 4 — Pods + +- [ ] Pod support (Quadlet-equivalent without systemd — approach TBD, see + ARCHITECTURE.md open question) + +## Open questions blocking later phases + +See [ARCHITECTURE.md, section 21](ARCHITECTURE.md#21-offene-fragen--risiken) for +the current list (Podman version/update cadence, cache-pool requirement, service +vs. on-demand start, iptables locking with Docker, release signing). diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md new file mode 100644 index 0000000..e1b95af --- /dev/null +++ b/docs/TROUBLESHOOTING.md @@ -0,0 +1,41 @@ +# Troubleshooting (draft) + +> Placeholder. Will be filled in as real failure modes are observed post-MVP. +> Cross-reference [ARCHITECTURE.md, section 16 (Fehlerbehandlung)](ARCHITECTURE.md#16-fehlerbehandlung) +> for the error-handling design these entries should map to. + +## How to gather diagnostics + +```sh +rc.podman status +podman info +podman system df +``` + +Relevant log locations: + +- Container logs: `/mnt/*/system/podman/logs/containers/` +- Service log: `/mnt/*/system/podman/logs/podman-service.log` +- Unraid System Log: `Tools → System Log` in the WebUI + +Please redact registry credentials/tokens before sharing logs in an issue. + +## Known problem categories (to be populated) + +### Storage + +- `podman.img` full / near-full +- Loopback mount failures after unclean shutdown + +### Networking + +- Custom network / macvlan issues +- Conflicts with Docker's `iptables` chains + +### Autostart + +- Container stuck in a failed autostart loop + +### Plugin update / rollback + +- Config migration failures after an update diff --git a/packages/README.md b/packages/README.md new file mode 100644 index 0000000..78742ac --- /dev/null +++ b/packages/README.md @@ -0,0 +1,59 @@ +# packages/ + +One subdirectory per Slackware `.txz` package required by the plugin, built +against Unraid's Slackware base. See +[docs/ARCHITECTURE.md, section 5](../docs/ARCHITECTURE.md#5-paketmanagement) +for the full rationale (why these components, static linking preference, +version pinning, build strategy). + +## Packages + +| Directory | Upstream project | Purpose | +|---|---|---| +| `podman/` | [containers/podman](https://github.com/containers/podman) | Core container engine binary | +| `conmon/` | [containers/conmon](https://github.com/containers/conmon) | Container monitor process | +| `crun/` | [containers/crun](https://github.com/containers/crun) | OCI runtime (preferred over runc) | +| `netavark/` | [containers/netavark](https://github.com/containers/netavark) | Network backend | +| `aardvark-dns/` | [containers/aardvark-dns](https://github.com/containers/aardvark-dns) | DNS for container-to-container name resolution | +| `containers-common/` | [containers/common](https://github.com/containers/common) | Default config/seccomp/registries templates | +| `fuse-overlayfs/` | [containers/fuse-overlayfs](https://github.com/containers/fuse-overlayfs) | Fallback storage driver (rootless, Phase 2) | +| `passt/` | [passt.top](https://passt.top/passt/about/) (no GitHub mirror) | Rootless networking (Phase 2), successor to slirp4netns | +| `unraid-podman/` | this repository (not an upstream project) | Plugin's own scaffolding — rc.podman, sbin/ scripts, event/ hooks, config templates (see [its README](unraid-podman/README.md)) | + +`podman`, `conmon`, `crun`, `netavark`, `aardvark-dns`, `passt`, +`fuse-overlayfs`, and `unraid-podman` are built and released automatically by +`.github/workflows/build-packages.yml`. `containers-common` currently only +supplies reference config (see [config/](../config)) and is not yet wired +into the automated build. + +## Standard layout per package + +Each package directory follows the same skeleton: + +``` +packages// +├── .SlackBuild # Slackware build script (source download, build, packaging) +├── slack-desc # Slackware package description (max 70 cols / 11 lines) +├── patches/ # Optional patches applied during the build +└── README.md # Upstream version pinned, build notes, patch rationale +``` + +## Build pipeline + +Packages are built via `scripts/build-packages.sh`, which runs each +package's `.SlackBuild` in turn (see `scripts/lib/slackbuild-common.sh` +for the shared fetch/verify/package helpers they all use). This must run +inside a Slackware-compatible build environment — never on an arbitrary host +distro, to avoid glibc/ABI drift against Unraid's base. +`.github/workflows/build-packages.yml` is the CI entry point: it runs the +same script inside a pinned Slackware container +(`scripts/ci/setup-slackware-buildenv.sh` bootstraps any build-time +dependency the base image doesn't already ship) and uploads the results as a +workflow artifact — nothing built is ever committed to this repository. + +Exact upstream versions and source checksums are pinned centrally in +[versions.env](../versions.env) (update via `scripts/update-versions.sh`, +never by hand) and, at release time, mirrored into the top-level +`plugin/podman.plg` manifest together with MD5 checksums by +`scripts/release.sh`, so installs are reproducible and rollback-able — see +[docs/ARCHITECTURE.md, section 14 (Rollback)](../docs/ARCHITECTURE.md#14-rollback). diff --git a/packages/aardvark-dns/README.md b/packages/aardvark-dns/README.md new file mode 100644 index 0000000..b14c8ae --- /dev/null +++ b/packages/aardvark-dns/README.md @@ -0,0 +1,15 @@ +# packages/aardvark-dns + +Slackware package build recipe for **aardvark-dns**. + +- Upstream: https://github.com/containers/aardvark-dns +- Pinned version: TODO +- Patches: see `patches/` (currently none) +- Build entry point: `aardvark-dns.SlackBuild` (placeholder, not yet implemented) + +## Notes + +TODO: document any Unraid/Slackware-specific build considerations (static +linking, cgroup v2 assumptions, kernel feature requirements) once the build +script is implemented. See +[docs/ARCHITECTURE.md, section 5.2 (Build-Strategie)](../../docs/ARCHITECTURE.md#52-build-strategie). diff --git a/packages/aardvark-dns/aardvark-dns.SlackBuild b/packages/aardvark-dns/aardvark-dns.SlackBuild new file mode 100755 index 0000000..c105328 --- /dev/null +++ b/packages/aardvark-dns/aardvark-dns.SlackBuild @@ -0,0 +1,46 @@ +#!/bin/bash +# ============================================================================= +# packages/aardvark-dns/aardvark-dns.SlackBuild +# +# Builds the aardvark-dns .txz package from upstream source (Rust). +# aardvark-dns provides container-to-container DNS name resolution within +# netavark-managed networks (see docs/ARCHITECTURE.md section 8). Installed +# as a libexec helper binary, same rationale as netavark. +# +# Requires (in the build environment): rustc, cargo. +# ============================================================================= + +set -eu + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +# shellcheck source=/dev/null +. "$REPO_ROOT/scripts/lib/slackbuild-common.sh" +# shellcheck source=/dev/null +. "$REPO_ROOT/versions.env" + +VERSION="$AARDVARK_DNS_VERSION" +ARCH="$PKG_ARCH" +BUILD="$PKG_BUILD" +TAG="$PKG_TAG" + +sb_init "aardvark-dns" + +tarball=$(sb_fetch_and_verify "$AARDVARK_DNS_SRC_URL" "$AARDVARK_DNS_SRC_SHA256" "aardvark-dns-$VERSION.tar.gz") +srcdir=$(sb_extract "$tarball") + +for patch in "$CWD"/patches/*.patch; do + [ -e "$patch" ] || continue + echo "==> [aardvark-dns] applying $(basename "$patch")" + patch -d "$srcdir" -p1 < "$patch" +done + +cd "$srcdir" + +echo "==> [aardvark-dns] cargo build --release" +cargo build --release --locked + +echo "==> [aardvark-dns] install into \$PKG" +install -D -m 0755 target/release/aardvark-dns "$PKG/usr/libexec/podman/aardvark-dns" + +sb_install_docs "$srcdir/LICENSE" "$srcdir/README.md" +sb_make_package "$VERSION" "$ARCH" "$BUILD" "$TAG" diff --git a/packages/aardvark-dns/patches/.gitkeep b/packages/aardvark-dns/patches/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/packages/aardvark-dns/slack-desc b/packages/aardvark-dns/slack-desc new file mode 100644 index 0000000..109addc --- /dev/null +++ b/packages/aardvark-dns/slack-desc @@ -0,0 +1,19 @@ +# HOW TO EDIT THIS FILE: +# The "handy ruler" below makes it easier to edit a package description. +# Line up the first '|' above the ':' following the base package name, and +# the '|' on the right side marks the last column you can put a character in. +# You must make exactly 11 lines for the formatting to be correct. It's also +# customary to leave one space after the ':' except on otherwise blank lines. + + |-----handy-ruler------------------------------------------------| +aardvark-dns: aardvark-dns (TODO: one-line summary) +aardvark-dns: +aardvark-dns: TODO: two-to-three sentence description of aardvark-dns, its role in the +aardvark-dns: unraid-podman plugin, and a link to the upstream project. +aardvark-dns: +aardvark-dns: Homepage: https://github.com/containers/aardvark-dns +aardvark-dns: +aardvark-dns: +aardvark-dns: +aardvark-dns: +aardvark-dns: diff --git a/packages/conmon/README.md b/packages/conmon/README.md new file mode 100644 index 0000000..fdc77ee --- /dev/null +++ b/packages/conmon/README.md @@ -0,0 +1,15 @@ +# packages/conmon + +Slackware package build recipe for **conmon**. + +- Upstream: https://github.com/containers/conmon +- Pinned version: TODO +- Patches: see `patches/` (currently none) +- Build entry point: `conmon.SlackBuild` (placeholder, not yet implemented) + +## Notes + +TODO: document any Unraid/Slackware-specific build considerations (static +linking, cgroup v2 assumptions, kernel feature requirements) once the build +script is implemented. See +[docs/ARCHITECTURE.md, section 5.2 (Build-Strategie)](../../docs/ARCHITECTURE.md#52-build-strategie). diff --git a/packages/conmon/conmon.SlackBuild b/packages/conmon/conmon.SlackBuild new file mode 100755 index 0000000..94d3639 --- /dev/null +++ b/packages/conmon/conmon.SlackBuild @@ -0,0 +1,46 @@ +#!/bin/bash +# ============================================================================= +# packages/conmon/conmon.SlackBuild +# +# Builds the conmon .txz package from upstream source (C, glib-based). +# conmon is the per-container monitor process podman/crun rely on to keep a +# container's stdio and exit status attached even if podman itself restarts. +# +# Requires (in the build environment): gcc, make, pkg-config, glib2-dev, +# libseccomp-dev. +# ============================================================================= + +set -eu + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +# shellcheck source=/dev/null +. "$REPO_ROOT/scripts/lib/slackbuild-common.sh" +# shellcheck source=/dev/null +. "$REPO_ROOT/versions.env" + +VERSION="$CONMON_VERSION" +ARCH="$PKG_ARCH" +BUILD="$PKG_BUILD" +TAG="$PKG_TAG" + +sb_init "conmon" + +tarball=$(sb_fetch_and_verify "$CONMON_SRC_URL" "$CONMON_SRC_SHA256" "conmon-$VERSION.tar.gz") +srcdir=$(sb_extract "$tarball") + +for patch in "$CWD"/patches/*.patch; do + [ -e "$patch" ] || continue + echo "==> [conmon] applying $(basename "$patch")" + patch -d "$srcdir" -p1 < "$patch" +done + +cd "$srcdir" + +echo "==> [conmon] make" +make PREFIX=/usr + +echo "==> [conmon] make install into \$PKG" +make install PREFIX=/usr DESTDIR="$PKG" + +sb_install_docs "$srcdir/LICENSE" "$srcdir/README.md" +sb_make_package "$VERSION" "$ARCH" "$BUILD" "$TAG" diff --git a/packages/conmon/patches/.gitkeep b/packages/conmon/patches/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/packages/conmon/slack-desc b/packages/conmon/slack-desc new file mode 100644 index 0000000..87450ec --- /dev/null +++ b/packages/conmon/slack-desc @@ -0,0 +1,19 @@ +# HOW TO EDIT THIS FILE: +# The "handy ruler" below makes it easier to edit a package description. +# Line up the first '|' above the ':' following the base package name, and +# the '|' on the right side marks the last column you can put a character in. +# You must make exactly 11 lines for the formatting to be correct. It's also +# customary to leave one space after the ':' except on otherwise blank lines. + + |-----handy-ruler------------------------------------------------| +conmon: conmon (TODO: one-line summary) +conmon: +conmon: TODO: two-to-three sentence description of conmon, its role in the +conmon: unraid-podman plugin, and a link to the upstream project. +conmon: +conmon: Homepage: https://github.com/containers/conmon +conmon: +conmon: +conmon: +conmon: +conmon: diff --git a/packages/containers-common/README.md b/packages/containers-common/README.md new file mode 100644 index 0000000..ab80100 --- /dev/null +++ b/packages/containers-common/README.md @@ -0,0 +1,15 @@ +# packages/containers-common + +Slackware package build recipe for **containers-common**. + +- Upstream: https://github.com/containers/common +- Pinned version: TODO +- Patches: see `patches/` (currently none) +- Build entry point: `containers-common.SlackBuild` (placeholder, not yet implemented) + +## Notes + +TODO: document any Unraid/Slackware-specific build considerations (static +linking, cgroup v2 assumptions, kernel feature requirements) once the build +script is implemented. See +[docs/ARCHITECTURE.md, section 5.2 (Build-Strategie)](../../docs/ARCHITECTURE.md#52-build-strategie). diff --git a/packages/containers-common/containers-common.SlackBuild b/packages/containers-common/containers-common.SlackBuild new file mode 100755 index 0000000..6571909 --- /dev/null +++ b/packages/containers-common/containers-common.SlackBuild @@ -0,0 +1,33 @@ +#!/bin/sh +# SlackBuild for containers-common — unraid-podman +# +# Builds a Slackware-compatible .txz package for containers-common, pinned to a specific +# upstream release from https://github.com/containers/common. +# See docs/ARCHITECTURE.md section 5 (Paketmanagement) and packages/README.md. +# +# STATUS: placeholder — not yet implemented. + +set -eu + +PRGNAM=containers-common +VERSION=${VERSION:-0.0.0} # TODO: pin exact upstream version +ARCH=${ARCH:-x86_64} +BUILD=${BUILD:-1} +TAG=${TAG:-_unraidpodman} + +CWD=$(pwd) +TMP=${TMP:-/tmp/SBo} +PKG=$TMP/package-$PRGNAM +OUTPUT=${OUTPUT:-/tmp} + +# TODO: +# 1. Fetch source tarball for $PRGNAM $VERSION from +# https://github.com/containers/common +# 2. Apply patches from packages/containers-common/patches/, if any +# 3. Build (Go build for Go-based components, make for C-based components) +# 4. Install into $PKG following Slackware package filesystem layout +# 5. Copy packages/containers-common/slack-desc into $PKG/install/slack-desc +# 6. makepkg to produce $OUTPUT/$PRGNAM-$VERSION-$ARCH-$BUILD$TAG.txz + +echo "TODO: implement containers-common.SlackBuild" +exit 1 diff --git a/packages/containers-common/patches/.gitkeep b/packages/containers-common/patches/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/packages/containers-common/slack-desc b/packages/containers-common/slack-desc new file mode 100644 index 0000000..def1388 --- /dev/null +++ b/packages/containers-common/slack-desc @@ -0,0 +1,19 @@ +# HOW TO EDIT THIS FILE: +# The "handy ruler" below makes it easier to edit a package description. +# Line up the first '|' above the ':' following the base package name, and +# the '|' on the right side marks the last column you can put a character in. +# You must make exactly 11 lines for the formatting to be correct. It's also +# customary to leave one space after the ':' except on otherwise blank lines. + + |-----handy-ruler------------------------------------------------| +containers-common: containers-common (TODO: one-line summary) +containers-common: +containers-common: TODO: two-to-three sentence description of containers-common, its role in the +containers-common: unraid-podman plugin, and a link to the upstream project. +containers-common: +containers-common: Homepage: https://github.com/containers/common +containers-common: +containers-common: +containers-common: +containers-common: +containers-common: diff --git a/packages/crun/README.md b/packages/crun/README.md new file mode 100644 index 0000000..4db9696 --- /dev/null +++ b/packages/crun/README.md @@ -0,0 +1,15 @@ +# packages/crun + +Slackware package build recipe for **crun**. + +- Upstream: https://github.com/containers/crun +- Pinned version: TODO +- Patches: see `patches/` (currently none) +- Build entry point: `crun.SlackBuild` (placeholder, not yet implemented) + +## Notes + +TODO: document any Unraid/Slackware-specific build considerations (static +linking, cgroup v2 assumptions, kernel feature requirements) once the build +script is implemented. See +[docs/ARCHITECTURE.md, section 5.2 (Build-Strategie)](../../docs/ARCHITECTURE.md#52-build-strategie). diff --git a/packages/crun/crun.SlackBuild b/packages/crun/crun.SlackBuild new file mode 100755 index 0000000..879f845 --- /dev/null +++ b/packages/crun/crun.SlackBuild @@ -0,0 +1,59 @@ +#!/bin/bash +# ============================================================================= +# packages/crun/crun.SlackBuild +# +# Builds the crun .txz package from upstream source (C, autotools). crun is +# the OCI runtime this project uses (preferred over runc — lighter, cgroup +# v2-friendly, see docs/ARCHITECTURE.md section 5.1). +# +# Requires (in the build environment): gcc, make, autoconf, automake, +# libtool, pkg-config, libcap-dev, libseccomp-dev, libyajl-dev, python3 +# (used by crun's build-time code generator). +# ============================================================================= + +set -eu + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +# shellcheck source=/dev/null +. "$REPO_ROOT/scripts/lib/slackbuild-common.sh" +# shellcheck source=/dev/null +. "$REPO_ROOT/versions.env" + +VERSION="$CRUN_VERSION" +ARCH="$PKG_ARCH" +BUILD="$PKG_BUILD" +TAG="$PKG_TAG" + +sb_init "crun" + +tarball=$(sb_fetch_and_verify "$CRUN_SRC_URL" "$CRUN_SRC_SHA256" "crun-$VERSION.tar.gz") +srcdir=$(sb_extract "$tarball") + +for patch in "$CWD"/patches/*.patch; do + [ -e "$patch" ] || continue + echo "==> [crun] applying $(basename "$patch")" + patch -d "$srcdir" -p1 < "$patch" +done + +cd "$srcdir" + +# GitHub's source archive does not include vendored git submodules +# (libocispec) that crun's release tarballs normally bundle; init/update +# them explicitly. +if [ -f .gitmodules ]; then + echo "==> [crun] fetching submodules" + git -c protocol.file.allow=always submodule update --init --recursive || true +fi + +echo "==> [crun] autogen + configure" +./autogen.sh +./configure --prefix=/usr --disable-systemd + +echo "==> [crun] make" +make + +echo "==> [crun] make install into \$PKG" +make install DESTDIR="$PKG" + +sb_install_docs "$srcdir/COPYING" "$srcdir/README.md" +sb_make_package "$VERSION" "$ARCH" "$BUILD" "$TAG" diff --git a/packages/crun/patches/.gitkeep b/packages/crun/patches/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/packages/crun/slack-desc b/packages/crun/slack-desc new file mode 100644 index 0000000..119f435 --- /dev/null +++ b/packages/crun/slack-desc @@ -0,0 +1,19 @@ +# HOW TO EDIT THIS FILE: +# The "handy ruler" below makes it easier to edit a package description. +# Line up the first '|' above the ':' following the base package name, and +# the '|' on the right side marks the last column you can put a character in. +# You must make exactly 11 lines for the formatting to be correct. It's also +# customary to leave one space after the ':' except on otherwise blank lines. + + |-----handy-ruler------------------------------------------------| +crun: crun (TODO: one-line summary) +crun: +crun: TODO: two-to-three sentence description of crun, its role in the +crun: unraid-podman plugin, and a link to the upstream project. +crun: +crun: Homepage: https://github.com/containers/crun +crun: +crun: +crun: +crun: +crun: diff --git a/packages/fuse-overlayfs/README.md b/packages/fuse-overlayfs/README.md new file mode 100644 index 0000000..42ec299 --- /dev/null +++ b/packages/fuse-overlayfs/README.md @@ -0,0 +1,15 @@ +# packages/fuse-overlayfs + +Slackware package build recipe for **fuse-overlayfs**. + +- Upstream: https://github.com/containers/fuse-overlayfs +- Pinned version: TODO +- Patches: see `patches/` (currently none) +- Build entry point: `fuse-overlayfs.SlackBuild` (placeholder, not yet implemented) + +## Notes + +TODO: document any Unraid/Slackware-specific build considerations (static +linking, cgroup v2 assumptions, kernel feature requirements) once the build +script is implemented. See +[docs/ARCHITECTURE.md, section 5.2 (Build-Strategie)](../../docs/ARCHITECTURE.md#52-build-strategie). diff --git a/packages/fuse-overlayfs/fuse-overlayfs.SlackBuild b/packages/fuse-overlayfs/fuse-overlayfs.SlackBuild new file mode 100755 index 0000000..e864ed4 --- /dev/null +++ b/packages/fuse-overlayfs/fuse-overlayfs.SlackBuild @@ -0,0 +1,54 @@ +#!/bin/bash +# ============================================================================= +# packages/fuse-overlayfs/fuse-overlayfs.SlackBuild +# +# Builds the fuse-overlayfs .txz package from upstream source (C, autotools). +# Not used by the rootful storage path in Phase 1 (which uses the native +# overlay driver on the podman.img loopback filesystem — see +# docs/ARCHITECTURE.md section 10), but required ahead of time for the +# rootless Podman phase, where unprivileged overlay mounts generally aren't +# available (see docs/ARCHITECTURE.md section 0, "Nicht-Ziele" / ROADMAP.md +# Phase 3). +# +# Requires (in the build environment): gcc, make, autoconf, automake, +# libtool, pkg-config, libfuse3-dev. +# ============================================================================= + +set -eu + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +# shellcheck source=/dev/null +. "$REPO_ROOT/scripts/lib/slackbuild-common.sh" +# shellcheck source=/dev/null +. "$REPO_ROOT/versions.env" + +VERSION="$FUSE_OVERLAYFS_VERSION" +ARCH="$PKG_ARCH" +BUILD="$PKG_BUILD" +TAG="$PKG_TAG" + +sb_init "fuse-overlayfs" + +tarball=$(sb_fetch_and_verify "$FUSE_OVERLAYFS_SRC_URL" "$FUSE_OVERLAYFS_SRC_SHA256" "fuse-overlayfs-$VERSION.tar.gz") +srcdir=$(sb_extract "$tarball") + +for patch in "$CWD"/patches/*.patch; do + [ -e "$patch" ] || continue + echo "==> [fuse-overlayfs] applying $(basename "$patch")" + patch -d "$srcdir" -p1 < "$patch" +done + +cd "$srcdir" + +echo "==> [fuse-overlayfs] autogen + configure" +./autogen.sh +./configure --prefix=/usr + +echo "==> [fuse-overlayfs] make" +make + +echo "==> [fuse-overlayfs] make install into \$PKG" +make install DESTDIR="$PKG" + +sb_install_docs "$srcdir/COPYING" "$srcdir/README.md" +sb_make_package "$VERSION" "$ARCH" "$BUILD" "$TAG" diff --git a/packages/fuse-overlayfs/patches/.gitkeep b/packages/fuse-overlayfs/patches/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/packages/fuse-overlayfs/slack-desc b/packages/fuse-overlayfs/slack-desc new file mode 100644 index 0000000..0fb8fae --- /dev/null +++ b/packages/fuse-overlayfs/slack-desc @@ -0,0 +1,19 @@ +# HOW TO EDIT THIS FILE: +# The "handy ruler" below makes it easier to edit a package description. +# Line up the first '|' above the ':' following the base package name, and +# the '|' on the right side marks the last column you can put a character in. +# You must make exactly 11 lines for the formatting to be correct. It's also +# customary to leave one space after the ':' except on otherwise blank lines. + + |-----handy-ruler------------------------------------------------| +fuse-overlayfs: fuse-overlayfs (TODO: one-line summary) +fuse-overlayfs: +fuse-overlayfs: TODO: two-to-three sentence description of fuse-overlayfs, its role in the +fuse-overlayfs: unraid-podman plugin, and a link to the upstream project. +fuse-overlayfs: +fuse-overlayfs: Homepage: https://github.com/containers/fuse-overlayfs +fuse-overlayfs: +fuse-overlayfs: +fuse-overlayfs: +fuse-overlayfs: +fuse-overlayfs: diff --git a/packages/netavark/README.md b/packages/netavark/README.md new file mode 100644 index 0000000..57615f6 --- /dev/null +++ b/packages/netavark/README.md @@ -0,0 +1,15 @@ +# packages/netavark + +Slackware package build recipe for **netavark**. + +- Upstream: https://github.com/containers/netavark +- Pinned version: TODO +- Patches: see `patches/` (currently none) +- Build entry point: `netavark.SlackBuild` (placeholder, not yet implemented) + +## Notes + +TODO: document any Unraid/Slackware-specific build considerations (static +linking, cgroup v2 assumptions, kernel feature requirements) once the build +script is implemented. See +[docs/ARCHITECTURE.md, section 5.2 (Build-Strategie)](../../docs/ARCHITECTURE.md#52-build-strategie). diff --git a/packages/netavark/netavark.SlackBuild b/packages/netavark/netavark.SlackBuild new file mode 100755 index 0000000..aacef08 --- /dev/null +++ b/packages/netavark/netavark.SlackBuild @@ -0,0 +1,49 @@ +#!/bin/bash +# ============================================================================= +# packages/netavark/netavark.SlackBuild +# +# Builds the netavark .txz package from upstream source (Rust). netavark is +# this project's container networking backend (see docs/ARCHITECTURE.md +# section 8) — it is installed as a libexec helper binary, not a $PATH tool, +# matching upstream's own layout so podman finds it via +# `netavark_binary_dir` in containers.conf. +# +# Requires (in the build environment): rustc, cargo (a recent stable +# toolchain — check upstream's Cargo.toml `rust-version` for the current +# minimum). +# ============================================================================= + +set -eu + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +# shellcheck source=/dev/null +. "$REPO_ROOT/scripts/lib/slackbuild-common.sh" +# shellcheck source=/dev/null +. "$REPO_ROOT/versions.env" + +VERSION="$NETAVARK_VERSION" +ARCH="$PKG_ARCH" +BUILD="$PKG_BUILD" +TAG="$PKG_TAG" + +sb_init "netavark" + +tarball=$(sb_fetch_and_verify "$NETAVARK_SRC_URL" "$NETAVARK_SRC_SHA256" "netavark-$VERSION.tar.gz") +srcdir=$(sb_extract "$tarball") + +for patch in "$CWD"/patches/*.patch; do + [ -e "$patch" ] || continue + echo "==> [netavark] applying $(basename "$patch")" + patch -d "$srcdir" -p1 < "$patch" +done + +cd "$srcdir" + +echo "==> [netavark] cargo build --release" +cargo build --release --locked + +echo "==> [netavark] install into \$PKG" +install -D -m 0755 target/release/netavark "$PKG/usr/libexec/podman/netavark" + +sb_install_docs "$srcdir/LICENSE" "$srcdir/README.md" +sb_make_package "$VERSION" "$ARCH" "$BUILD" "$TAG" diff --git a/packages/netavark/patches/.gitkeep b/packages/netavark/patches/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/packages/netavark/slack-desc b/packages/netavark/slack-desc new file mode 100644 index 0000000..adfde8a --- /dev/null +++ b/packages/netavark/slack-desc @@ -0,0 +1,19 @@ +# HOW TO EDIT THIS FILE: +# The "handy ruler" below makes it easier to edit a package description. +# Line up the first '|' above the ':' following the base package name, and +# the '|' on the right side marks the last column you can put a character in. +# You must make exactly 11 lines for the formatting to be correct. It's also +# customary to leave one space after the ':' except on otherwise blank lines. + + |-----handy-ruler------------------------------------------------| +netavark: netavark (TODO: one-line summary) +netavark: +netavark: TODO: two-to-three sentence description of netavark, its role in the +netavark: unraid-podman plugin, and a link to the upstream project. +netavark: +netavark: Homepage: https://github.com/containers/netavark +netavark: +netavark: +netavark: +netavark: +netavark: diff --git a/packages/passt/README.md b/packages/passt/README.md new file mode 100644 index 0000000..7b8ef52 --- /dev/null +++ b/packages/passt/README.md @@ -0,0 +1,21 @@ +# packages/passt + +Slackware package build recipe for **passt** (and its `pasta` counterpart). + +- Upstream: https://passt.top/passt/about/ (cgit, no GitHub mirror) +- Pinned commit: see `PASST_COMMIT` in [versions.env](../../versions.env) +- Patches: see `patches/` (currently none) +- Build entry point: `passt.SlackBuild` + +## Notes + +Unlike the other six packages, passt has no tagged releases or semver +versioning upstream — distributions package it straight from a pinned git +commit (this is also how Fedora/Debian build it). `scripts/update-versions.sh` +handles re-pinning to a newer commit and recomputing the snapshot checksum; +see [versions.env](../../versions.env) for the exact snapshot URL pattern. + +Not required for the Phase 1 (rootful) MVP — passt/pasta become relevant once +rootless Podman support is added (see +[docs/ROADMAP.md](../../docs/ROADMAP.md), Phase 3). It is built now so the +package pipeline covers the full dependency set from day one. diff --git a/packages/passt/passt.SlackBuild b/packages/passt/passt.SlackBuild new file mode 100755 index 0000000..1f48152 --- /dev/null +++ b/packages/passt/passt.SlackBuild @@ -0,0 +1,49 @@ +#!/bin/bash +# ============================================================================= +# packages/passt/passt.SlackBuild +# +# Builds the passt/pasta .txz package from upstream source (C, plain +# Makefile — no autotools). passt is Podman's modern unprivileged +# user-mode network transport for rootless containers, superseding +# slirp4netns (see versions.env for why this project pins it via a git +# commit snapshot rather than a tagged release: upstream has no GitHub +# mirror and does not use semver tags). +# +# Requires (in the build environment): gcc, make. No external library +# dependencies beyond libc — passt deliberately avoids them. +# ============================================================================= + +set -eu + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +# shellcheck source=/dev/null +. "$REPO_ROOT/scripts/lib/slackbuild-common.sh" +# shellcheck source=/dev/null +. "$REPO_ROOT/versions.env" + +VERSION="$PASST_VERSION" +ARCH="$PKG_ARCH" +BUILD="$PKG_BUILD" +TAG="$PKG_TAG" + +sb_init "passt" + +tarball=$(sb_fetch_and_verify "$PASST_SRC_URL" "$PASST_SRC_SHA256" "passt-$PASST_COMMIT.tar.gz") +srcdir=$(sb_extract "$tarball") + +for patch in "$CWD"/patches/*.patch; do + [ -e "$patch" ] || continue + echo "==> [passt] applying $(basename "$patch")" + patch -d "$srcdir" -p1 < "$patch" +done + +cd "$srcdir" + +echo "==> [passt] make" +make + +echo "==> [passt] make install into \$PKG" +make install prefix=/usr DESTDIR="$PKG" + +sb_install_docs "$srcdir/LICENSES/GPL-2.0-or-later.txt" "$srcdir/README.md" +sb_make_package "$VERSION" "$ARCH" "$BUILD" "$TAG" diff --git a/packages/passt/patches/.gitkeep b/packages/passt/patches/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/packages/passt/slack-desc b/packages/passt/slack-desc new file mode 100644 index 0000000..d60980e --- /dev/null +++ b/packages/passt/slack-desc @@ -0,0 +1,19 @@ +# HOW TO EDIT THIS FILE: +# The "handy ruler" below makes it easier to edit a package description. +# Line up the first '|' above the ':' following the base package name, and +# the '|' on the right side marks the last column you can put a character in. +# You must make exactly 11 lines for the formatting to be correct. It's also +# customary to leave one space after the ':' except on otherwise blank lines. + + |-----handy-ruler------------------------------------------------| +passt: passt (unprivileged user-mode networking for VMs/containers) +passt: +passt: passt and pasta provide unprivileged (rootless) user-mode network +passt: connectivity for containers and VMs, without requiring a tap device +passt: or elevated capabilities. Podman uses it as the rootless network +passt: transport, superseding slirp4netns. +passt: +passt: Homepage: https://passt.top/passt/about/ +passt: +passt: +passt: diff --git a/packages/podman/README.md b/packages/podman/README.md new file mode 100644 index 0000000..e6a4fa4 --- /dev/null +++ b/packages/podman/README.md @@ -0,0 +1,15 @@ +# packages/podman + +Slackware package build recipe for **podman**. + +- Upstream: https://github.com/containers/podman +- Pinned version: TODO +- Patches: see `patches/` (currently none) +- Build entry point: `podman.SlackBuild` (placeholder, not yet implemented) + +## Notes + +TODO: document any Unraid/Slackware-specific build considerations (static +linking, cgroup v2 assumptions, kernel feature requirements) once the build +script is implemented. See +[docs/ARCHITECTURE.md, section 5.2 (Build-Strategie)](../../docs/ARCHITECTURE.md#52-build-strategie). diff --git a/packages/podman/patches/.gitkeep b/packages/podman/patches/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/packages/podman/podman.SlackBuild b/packages/podman/podman.SlackBuild new file mode 100755 index 0000000..8d8f388 --- /dev/null +++ b/packages/podman/podman.SlackBuild @@ -0,0 +1,74 @@ +#!/bin/bash +# ============================================================================= +# packages/podman/podman.SlackBuild +# +# Builds the podman .txz package from upstream source (Go). Version and +# checksum are pinned centrally in versions.env — see +# scripts/lib/slackbuild-common.sh for the shared fetch/verify/package +# helpers used below, and docs/ARCHITECTURE.md section 5 for why the +# graphdriver build tags below are what they are (this project only ever +# uses the overlay storage driver, see docs/ARCHITECTURE.md section 10). +# +# Requires (in the build environment): go >= 1.22, make, gcc, pkg-config, +# libseccomp-dev. GPG signature verification support is intentionally left +# out to keep the dependency footprint minimal for a from-scratch Slackware +# build. +# ============================================================================= + +set -eu + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +# shellcheck source=/dev/null +. "$REPO_ROOT/scripts/lib/slackbuild-common.sh" +# shellcheck source=/dev/null +. "$REPO_ROOT/versions.env" + +VERSION="$PODMAN_VERSION" +ARCH="$PKG_ARCH" +BUILD="$PKG_BUILD" +TAG="$PKG_TAG" + +sb_init "podman" + +tarball=$(sb_fetch_and_verify "$PODMAN_SRC_URL" "$PODMAN_SRC_SHA256" "podman-$VERSION.tar.gz") +srcdir=$(sb_extract "$tarball") + +# Apply any local patches (none currently — see packages/podman/patches/). +for patch in "$CWD"/patches/*.patch; do + [ -e "$patch" ] || continue + echo "==> [podman] applying $(basename "$patch")" + patch -d "$srcdir" -p1 < "$patch" +done + +cd "$srcdir" + +# Build tags: exclude storage backends this project doesn't use (btrfs, +# devicemapper — see docs/ARCHITECTURE.md section 10, overlay-only), exclude +# systemd integration (Unraid has no systemd — see docs/ARCHITECTURE.md +# "Rahmenbedingungen"), keep seccomp support. +export BUILDTAGS="seccomp exclude_graphdriver_btrfs exclude_graphdriver_devicemapper containers_image_openpgp" +export CGO_ENABLED=1 +export GOFLAGS="${GOFLAGS:--mod=mod}" + +echo "==> [podman] make (BUILDTAGS=$BUILDTAGS)" +make BUILDTAGS="$BUILDTAGS" GO_BUILD_FLAGS="-ldflags -s" + +echo "==> [podman] make install into \$PKG" +make install \ + DESTDIR="$PKG" \ + PREFIX=/usr \ + ETCDIR="$PKG/etc" \ + BUILDTAGS="$BUILDTAGS" + +# We ship our own containers.conf/storage.conf/registries.conf/policy.json +# templates (see config/) staged via plugin/podman.plg instead of upstream's +# defaults, so drop the ones `make install` places under $PKG/etc to avoid +# ambiguity about which file is authoritative on a running system. +rm -rf "$PKG/etc/containers" + +# No systemd units — Unraid has no systemd (see docs/ARCHITECTURE.md, +# "Rahmenbedingungen"); process lifecycle is entirely owned by rc.podman. +rm -rf "$PKG/usr/lib/systemd" "$PKG/lib/systemd" + +sb_install_docs "$srcdir/LICENSE" "$srcdir/README.md" +sb_make_package "$VERSION" "$ARCH" "$BUILD" "$TAG" diff --git a/packages/podman/slack-desc b/packages/podman/slack-desc new file mode 100644 index 0000000..98a660d --- /dev/null +++ b/packages/podman/slack-desc @@ -0,0 +1,19 @@ +# HOW TO EDIT THIS FILE: +# The "handy ruler" below makes it easier to edit a package description. +# Line up the first '|' above the ':' following the base package name, and +# the '|' on the right side marks the last column you can put a character in. +# You must make exactly 11 lines for the formatting to be correct. It's also +# customary to leave one space after the ':' except on otherwise blank lines. + + |-----handy-ruler------------------------------------------------| +podman: podman (TODO: one-line summary) +podman: +podman: TODO: two-to-three sentence description of podman, its role in the +podman: unraid-podman plugin, and a link to the upstream project. +podman: +podman: Homepage: https://github.com/containers/podman +podman: +podman: +podman: +podman: +podman: diff --git a/packages/unraid-podman/README.md b/packages/unraid-podman/README.md new file mode 100644 index 0000000..e4647dd --- /dev/null +++ b/packages/unraid-podman/README.md @@ -0,0 +1,16 @@ +# packages/unraid-podman + +Packaging recipe for the plugin's own scaffolding — **not** an upstream +component like the other seven package directories. See +`unraid-podman.SlackBuild`'s header comment for the full rationale. + +Bundles: +- `plugin/rc.d/rc.podman` → `/etc/rc.d/rc.podman` +- `plugin/sbin/*.sh` → `/usr/local/sbin/` +- `plugin/event/*` → `/usr/local/emhttp/plugins/podman/event/` (official + Unraid array-event hooks — see those files' own header comments) +- `config/*.conf`, `config/podman.cfg.example` → `/usr/local/share/unraid-podman/templates/` +- `webui/plugins/podman/` → `/usr/local/emhttp/plugins/podman/` (once populated, see [docs/ROADMAP.md](../../docs/ROADMAP.md)) + +Version is read directly from `plugin/podman.plg`'s `` — +there's no separate version to keep in sync. diff --git a/packages/unraid-podman/patches/.gitkeep b/packages/unraid-podman/patches/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/packages/unraid-podman/slack-desc b/packages/unraid-podman/slack-desc new file mode 100644 index 0000000..aa3c2c8 --- /dev/null +++ b/packages/unraid-podman/slack-desc @@ -0,0 +1,19 @@ +# HOW TO EDIT THIS FILE: +# The "handy ruler" below makes it easier to edit a package description. +# Line up the first '|' above the ':' following the base package name, and +# the '|' on the right side marks the last column you can put a character in. +# You must make exactly 11 lines for the formatting to be correct. It's also +# customary to leave one space after the ':' except on otherwise blank lines. + + |-----handy-ruler------------------------------------------------| +unraid-podman: unraid-podman (plugin scaffolding: rc.podman, hooks, config) +unraid-podman: +unraid-podman: This package contains the unraid-podman plugin's own files — +unraid-podman: the rc.podman init script, helper scripts under +unraid-podman: /usr/local/sbin, the official Unraid array event hooks, and +unraid-podman: default configuration templates. It does not contain Podman +unraid-podman: itself (see the separate podman/conmon/crun/... packages). +unraid-podman: +unraid-podman: Homepage: https://github.com/OWNER/unraid-podman +unraid-podman: +unraid-podman: diff --git a/packages/unraid-podman/unraid-podman.SlackBuild b/packages/unraid-podman/unraid-podman.SlackBuild new file mode 100755 index 0000000..071bd12 --- /dev/null +++ b/packages/unraid-podman/unraid-podman.SlackBuild @@ -0,0 +1,84 @@ +#!/bin/bash +# ============================================================================= +# packages/unraid-podman/unraid-podman.SlackBuild +# +# Unlike the other seven packages, this one does not compile anything from +# an external upstream source — it packages THIS repository's own plugin +# scaffolding (rc.podman, the sbin/ helper scripts, the official Unraid +# event/ hooks, and the default config templates) into a single .txz, +# exactly matching how real-world Unraid plugins bundle their own files +# (verified against the actual unassigned.devices.plg / package layout — +# see docs/ARCHITECTURE.md section 3.2 for the reference check that led to +# this design). plugin/podman.plg installs this alongside the seven +# compiled component packages, all via the same +# `upgradepkg --install-new --reinstall` mechanism. +# +# Version: taken directly from plugin/podman.plg's own , +# so there is exactly one place a release's plugin version is defined — +# scripts/release.sh already updates that entity, and this script just +# reads it back rather than duplicating it in versions.env (which is +# reserved for pinning EXTERNAL upstream sources, see that file's header). +# ============================================================================= + +set -eu + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +# shellcheck source=/dev/null +. "$REPO_ROOT/scripts/lib/slackbuild-common.sh" +# shellcheck source=/dev/null +. "$REPO_ROOT/versions.env" + +VERSION=$(grep -oP '(?<= from $REPO_ROOT/plugin/podman.plg" >&2 + exit 1 +fi + +ARCH="$PKG_ARCH" +BUILD="$PKG_BUILD" +TAG="$PKG_TAG" + +sb_init "unraid-podman" + +# --- Stage rc.podman ----------------------------------------------------- +install -D -m 0755 "$REPO_ROOT/plugin/rc.d/rc.podman" "$PKG/etc/rc.d/rc.podman" + +# --- Stage sbin helper scripts --------------------------------------------- +for f in "$REPO_ROOT"/plugin/sbin/*.sh; do + install -D -m 0755 "$f" "$PKG/usr/local/sbin/$(basename "$f")" +done + +# --- Stage official Unraid plugin event hooks ------------------------------- +# See plugin/event/*/'s own header comments: this is the officially +# documented mechanism (verified against unassigned.devices), NOT a +# /boot/config/go edit. +for f in "$REPO_ROOT"/plugin/event/*; do + install -D -m 0755 "$f" "$PKG/usr/local/emhttp/plugins/podman/event/$(basename "$f")" +done + +# --- Stage default config templates ----------------------------------------- +# Consumed by podman-config.sh's `seed` command at install/first-boot time — +# see that script's header comment for the seed-only-if-missing contract. +for f in containers.conf storage.conf registries.conf policy.json; do + install -D -m 0644 "$REPO_ROOT/config/$f" "$PKG/usr/local/share/unraid-podman/templates/$f" +done +install -D -m 0644 "$REPO_ROOT/config/podman.cfg.example" \ + "$PKG/usr/local/share/unraid-podman/templates/podman.cfg.example" + +# --- Stage WebUI files, if present ------------------------------------------ +# webui/plugins/podman/ is populated starting in a later development phase +# (see docs/ROADMAP.md, Phase 2) — copy it in wholesale when it exists so +# this SlackBuild doesn't need to change again once it does. +if [ -d "$REPO_ROOT/webui/plugins/podman" ]; then + mkdir -p "$PKG/usr/local/emhttp/plugins/podman" + cp -a "$REPO_ROOT/webui/plugins/podman/." "$PKG/usr/local/emhttp/plugins/podman/" + # event/ was already staged above with correct permissions — avoid + # clobbering it with a possibly-non-executable copy from webui/ (which + # has no reason to contain its own event/ directory, but be defensive). + for f in "$REPO_ROOT"/plugin/event/*; do + install -D -m 0755 "$f" "$PKG/usr/local/emhttp/plugins/podman/event/$(basename "$f")" + done +fi + +sb_install_docs "$REPO_ROOT/LICENSE" "$REPO_ROOT/README.md" +sb_make_package "$VERSION" "$ARCH" "$BUILD" "$TAG" diff --git a/plugin/boot-config/plugins/podman/README.md b/plugin/boot-config/plugins/podman/README.md new file mode 100644 index 0000000..228be5f --- /dev/null +++ b/plugin/boot-config/plugins/podman/README.md @@ -0,0 +1,27 @@ +# plugin/boot-config/plugins/podman/ + +This directory is a staged mirror of what `/boot/config/plugins/podman/` should +look like immediately after a first install — see +[docs/ARCHITECTURE.md, section 4.1](../../../../docs/ARCHITECTURE.md#41-auf-dem-flash-device-boot-persistent-klein). + +The `.plg` postinstall step (see `plugin/podman.plg`) copies these files to +`/boot/config/plugins/podman/` **only if they don't already exist**, so +re-running an install/update never clobbers a user's existing configuration. + +Config file *content* templates (`containers.conf`, `storage.conf`, +`registries.conf`, `policy.json`) are sourced from the top-level [config/](../../../../config) +directory rather than duplicated here — this directory only defines the +directory skeleton and the plugin-specific settings files (`podman.cfg`, +`autostart`, ...) that have no equivalent elsewhere. + +``` +plugins/podman/ +├── podman.cfg # copied from config/podman.cfg.example +├── autostart # empty by default, see docs/ARCHITECTURE.md section 12 +├── autostart-delay # empty by default +├── networks/ # empty, populated as custom networks are created +├── backup/ +│ ├── packages/ # empty, populated on first update +│ └── config/ # empty, populated on first update +└── plugin.log # empty, install/update log +``` diff --git a/plugin/boot-config/plugins/podman/autostart b/plugin/boot-config/plugins/podman/autostart new file mode 100644 index 0000000..e69de29 diff --git a/plugin/boot-config/plugins/podman/autostart-delay b/plugin/boot-config/plugins/podman/autostart-delay new file mode 100644 index 0000000..e69de29 diff --git a/plugin/boot-config/plugins/podman/backup/config/.gitkeep b/plugin/boot-config/plugins/podman/backup/config/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/plugin/boot-config/plugins/podman/backup/packages/.gitkeep b/plugin/boot-config/plugins/podman/backup/packages/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/plugin/boot-config/plugins/podman/networks/.gitkeep b/plugin/boot-config/plugins/podman/networks/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/plugin/event/disks_mounted b/plugin/event/disks_mounted new file mode 100755 index 0000000..d04025e --- /dev/null +++ b/plugin/event/disks_mounted @@ -0,0 +1,25 @@ +#!/bin/bash +# ============================================================================= +# plugin/event/disks_mounted +# +# Official Unraid plugin event hook — NOT a custom mechanism. Unraid's +# emhttpd fires every executable found at +# /usr/local/emhttp/plugins//event/ as each boot/array +# event happens (see e.g. the real-world unassigned.devices plugin, which +# uses this same convention for its own disks_mounted/started/stopping_svcs +# hooks). This project relies exclusively on this official mechanism — +# specifically NOT on hand-editing /boot/config/go, which is unnecessary +# and easy to get wrong across plugin updates/uninstalls. +# +# "disks_mounted" fires once array disks AND cache pools are mounted — +# exactly the precondition unraid-podman needs before it can create/mount +# podman.img on the configured cache pool/disk (see +# docs/ARCHITECTURE.md section 4.3 and section 6.2). +# +# Backgrounded (& disown) so array startup / the Main GUI page isn't +# blocked on podman's full startup sequence (preflight, storage mount, +# service start, autostart chain) — mirrors how unassigned.devices +# backgrounds its own longer-running "started" hook. +# ============================================================================= + +/etc/rc.d/rc.podman start > /dev/null 2>&1 & disown diff --git a/plugin/event/stopping b/plugin/event/stopping new file mode 100755 index 0000000..2a014c0 --- /dev/null +++ b/plugin/event/stopping @@ -0,0 +1,19 @@ +#!/bin/bash +# ============================================================================= +# plugin/event/stopping +# +# Official Unraid plugin event hook (see plugin/event/disks_mounted for the +# full explanation of this mechanism). "stopping" is the FIRST event fired +# in Unraid's shutdown sequence — before stopping_docker, stopping_svcs, +# unmounting_disks, stopping_array — which gives unraid-podman the largest +# possible window to stop containers and unmount podman.img BEFORE Unraid +# unmounts the underlying cache pool/disk out from under it. +# +# Run synchronously (no backgrounding, unlike disks_mounted) — shutdown +# must actually wait for containers to stop and storage to unmount +# cleanly; racing the array-stop sequence here risks filesystem corruption +# on podman.img (see docs/ARCHITECTURE.md section 16.1 on why this project +# treats storage integrity conservatively). +# ============================================================================= + +/etc/rc.d/rc.podman stop diff --git a/plugin/podman.plg b/plugin/podman.plg new file mode 100644 index 0000000..7224094 --- /dev/null +++ b/plugin/podman.plg @@ -0,0 +1,365 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +]> + + + + + +##podman + +###0.0.0 +- Initial scaffolding. Not a functional release — see CHANGELOG.md for the + authoritative, up-to-date history; this block is kept in sync with it by + hand at release time (scripts/release.sh does not touch this section). + + + + + +if [ "$(uname -m)" != "x86_64" ]; then + echo "unraid-podman only supports x86_64 (detected: $(uname -m)) - aborting install." + exit 1 +fi + + + + + + + +&baseURL;/&podman_txz_file; + + +&podman_txz_md5; + + + + + +&baseURL;/&conmon_txz_file; + + +&conmon_txz_md5; + + + + + +&baseURL;/&crun_txz_file; + + +&crun_txz_md5; + + + + + +&baseURL;/&netavark_txz_file; + + +&netavark_txz_md5; + + + + + +&baseURL;/&aardvark_dns_txz_file; + + +&aardvark_dns_txz_md5; + + + + + +&baseURL;/&passt_txz_file; + + +&passt_txz_md5; + + + + + +&baseURL;/&fuse_overlayfs_txz_file; + + +&fuse_overlayfs_txz_md5; + + + + + + +&baseURL;/&unraid_podman_txz_file; + + +&unraid_podman_txz_md5; + + + + + + + +set -u + +echo "Setting permissions..." +chmod 0755 /etc/rc.d/rc.podman +chmod 0755 /usr/local/sbin/podman-*.sh +chmod 0755 /usr/local/emhttp/plugins/podman/event/disks_mounted +chmod 0755 /usr/local/emhttp/plugins/podman/event/stopping + +echo "Writing installed-package manifest..." +mkdir -p /usr/local/share/unraid-podman +MANIFEST=/usr/local/share/unraid-podman/installed-versions.env +echo "# Generated by podman.plg postinstall - do not edit by hand." > "$MANIFEST" +echo "# Read by podman-verify-packages.sh and podman-update-packages.sh." >> "$MANIFEST" +echo "PLUGIN_VERSION=\"&version;\"" >> "$MANIFEST" +echo "PODMAN_INSTALLED_VERSION=\"&podman_txz_version;\"" >> "$MANIFEST" +echo "CONMON_INSTALLED_VERSION=\"&conmon_txz_version;\"" >> "$MANIFEST" +echo "CRUN_INSTALLED_VERSION=\"&crun_txz_version;\"" >> "$MANIFEST" +echo "NETAVARK_INSTALLED_VERSION=\"&netavark_txz_version;\"" >> "$MANIFEST" +echo "AARDVARK_DNS_INSTALLED_VERSION=\"&aardvark_dns_txz_version;\"" >> "$MANIFEST" +echo "PASST_INSTALLED_VERSION=\"&passt_txz_version;\"" >> "$MANIFEST" +echo "FUSE_OVERLAYFS_INSTALLED_VERSION=\"&fuse_overlayfs_txz_version;\"" >> "$MANIFEST" +echo "UNRAID_PODMAN_INSTALLED_VERSION=\"&unraid_podman_txz_version;\"" >> "$MANIFEST" + +echo "Seeding /boot/config/plugins/podman/ configuration (existing files left untouched)..." +/usr/local/sbin/podman-config.sh seed + +echo "Verifying package installation..." +if /usr/local/sbin/podman-verify-packages.sh --quiet; then + echo "Package verification OK." +else + echo "WARNING: package verification reported problems - see /boot/config/plugins/podman/plugin.log" +fi + +echo "Starting podman..." +if /etc/rc.d/rc.podman start; then + echo "" + echo "----------------------------------------------------" + echo " unraid-podman &version; has been installed and started." + echo " CLI: podman --url unix:///var/run/podman/podman.sock ..." + echo " Docs: https://github.com/&github;" + echo "----------------------------------------------------" + echo "" +else + echo "" + echo "----------------------------------------------------" + echo " unraid-podman &version; was installed but did not start cleanly." + echo " It will be retried automatically the next time the array starts" + echo " (see /usr/local/emhttp/plugins/podman/event/disks_mounted)." + echo " Check /boot/config/plugins/podman/plugin.log for details." + echo "----------------------------------------------------" + echo "" +fi + + + + + + + +echo "Running unraid-podman cleanup..." +/usr/local/sbin/podman-uninstall-cleanup.sh + +echo "Removing packages..." +removepkg &podman_txz_file; +removepkg &conmon_txz_file; +removepkg &crun_txz_file; +removepkg &netavark_txz_file; +removepkg &aardvark_dns_txz_file; +removepkg &passt_txz_file; +removepkg &fuse_overlayfs_txz_file; +removepkg &unraid_podman_txz_file; + +echo "" +echo "unraid-podman removed. Your configuration, backups, and container" +echo "storage under /boot/config/plugins/podman/ and the configured" +echo "STORAGE_PATH were left in place - see docs/INSTALL.md if you want" +echo "to remove them too." +echo "" + + + + diff --git a/plugin/rc.d/rc.podman b/plugin/rc.d/rc.podman new file mode 100755 index 0000000..8d0c89a --- /dev/null +++ b/plugin/rc.d/rc.podman @@ -0,0 +1,244 @@ +#!/bin/bash +# ============================================================================= +# plugin/rc.d/rc.podman +# +# Init script for unraid-podman, in the classic Slackware BSD +# start|stop|restart|status style — Unraid has no systemd, so this script +# alone owns the entire process lifecycle (see docs/ARCHITECTURE.md +# section 6, "Start-/Stop-Skripte", and the top-level "Rahmenbedingungen" +# table on why that's the case). +# +# Staged by plugin/podman.plg to /etc/rc.d/rc.podman. Its boot-time +# invocation is registered (also by podman.plg) in /boot/config/go, gated on +# Unraid's "array started" event rather than running unconditionally at +# boot — the configured storage path (cache pool/disk) is not guaranteed to +# be available before that point. See ARCHITECTURE.md section 6.2. +# +# The individual responsibilities below are deliberately split into small, +# single-purpose scripts under /usr/local/sbin/ (staged from plugin/sbin/) +# rather than inlined here — see each script's own header comment for why +# it's separate: +# podman-preflight.sh startup validation +# podman-config.sh config seeding + sync +# podman-storage.sh podman.img create/mount/unmount +# podman-autostart.sh autostart chain +# podman-backup.sh config/package snapshots for rollback +# podman-verify-packages.sh package integrity checks +# podman-update-packages.sh package reconciliation/update +# podman-uninstall-cleanup.sh called from podman.plg's removepkg block +# ============================================================================= + +set -u + +SBIN_DIR="/usr/local/sbin" +# shellcheck source=../sbin/podman-common.sh +. "$SBIN_DIR/podman-common.sh" + +podman_load_cfg + +# ----------------------------------------------------------------------------- +# podman_start +# +# See file header for the full sequence. Every step is expected to be +# idempotent (safe to re-run `rc.podman start` against an already-running +# instance without breaking anything) — preflight and podman-storage.sh +# both already implement this on their own, but we also short-circuit here +# if the service is clearly already up, to avoid doing redundant work. +# ----------------------------------------------------------------------------- +podman_start() { + if [ "${PODMAN_ENABLED:-yes}" != "yes" ]; then + podman_log "start: PODMAN_ENABLED is not 'yes' in podman.cfg, not starting" + return 0 + fi + + if [ -S "$PODMAN_SOCKET" ] && [ -f "$PODMAN_SERVICE_PID_FILE" ] \ + && kill -0 "$(cat "$PODMAN_SERVICE_PID_FILE" 2> /dev/null)" 2> /dev/null; then + podman_log "start: already running (pid $(cat "$PODMAN_SERVICE_PID_FILE"))" + return 0 + fi + + podman_log "start: beginning startup sequence" + + # 1. Preflight — aborts loudly (and already notified) on failure. + if ! "$SBIN_DIR/podman-preflight.sh"; then + podman_log_error "start: preflight checks failed, aborting" + return 1 + fi + + # 2. Ensure config exists (no-op if already seeded) and sync it to the + # RAM-root /etc/containers/ — see podman-config.sh. + "$SBIN_DIR/podman-config.sh" seed + if ! "$SBIN_DIR/podman-config.sh" sync; then + podman_log_error "start: config sync failed, aborting" + return 1 + fi + + # 3. Storage: create the image if this is a first start, then mount it. + if ! "$SBIN_DIR/podman-storage.sh" create; then + podman_log_error "start: storage creation failed, aborting" + return 1 + fi + if ! "$SBIN_DIR/podman-storage.sh" mount; then + podman_log_error "start: storage mount failed, aborting" + return 1 + fi + + # 4. Restore persisted netavark network definitions (see + # docs/ARCHITECTURE.md section 8, Netzwerke) from the boot-persistent + # copy into the RAM-root config directory netavark reads from. + mkdir -p "$PODMAN_ETC_DIR/networks" + if [ -d "$PODMAN_NETWORKS_BOOT_DIR" ] && [ -n "$(ls -A "$PODMAN_NETWORKS_BOOT_DIR" 2> /dev/null)" ]; then + cp -a "$PODMAN_NETWORKS_BOOT_DIR"/. "$PODMAN_ETC_DIR/networks/" + podman_log "start: restored custom network definitions" + fi + + # 5. Start the Podman API service, rootful, on a unix socket — see + # docs/ARCHITECTURE.md section 6.1 for why this runs as a persistent + # service rather than being started fresh per CLI/WebUI call: + # a shared process gives consistent state and event streaming. + # --time=0 disables the idle-shutdown timeout (this is a long-running + # daemon under our process management, not an on-demand activation). + mkdir -p "$PODMAN_RUN_DIR" + podman_log "start: starting podman system service on unix://$PODMAN_SOCKET" + nohup podman system service --time=0 "unix://$PODMAN_SOCKET" \ + > "$PODMAN_LOG_DIR/podman-service.log" 2>&1 & + local service_pid=$! + echo "$service_pid" > "$PODMAN_SERVICE_PID_FILE" + + # Wait for the socket to actually appear rather than assuming the fork + # succeeded instantly — up to 15s, polled every 200ms. + local waited=0 + while [ ! -S "$PODMAN_SOCKET" ] && [ "$waited" -lt 15000 ]; do + sleep 0.2 + waited=$((waited + 200)) + if ! kill -0 "$service_pid" 2> /dev/null; then + podman_log_error "start: podman system service exited immediately — see $PODMAN_LOG_DIR/podman-service.log" + "$SBIN_DIR/podman-storage.sh" unmount || true + podman_notify "Podman failed to start" \ + "podman system service exited immediately. See $PODMAN_LOG_DIR/podman-service.log." \ + "alert" + return 1 + fi + done + + if [ ! -S "$PODMAN_SOCKET" ]; then + podman_log_error "start: timed out waiting for $PODMAN_SOCKET to appear" + return 1 + fi + podman_log "start: podman system service is up (pid $service_pid)" + + # 6. Autostart. A failure here is logged/notified by the script itself + # and does not abort rc.podman start — the service is already usable. + "$SBIN_DIR/podman-autostart.sh" || podman_log_error "start: autostart chain reported errors (see above)" + + echo "running" > "$PODMAN_STATUS_FILE" + podman_log "start: startup sequence complete" + return 0 +} + +# ----------------------------------------------------------------------------- +# podman_stop +# +# Stops containers first (each with its own STOP_TIMEOUT grace period), +# then the API service, then unmounts storage — the reverse of start, so +# nothing is torn down out from under something still using it. +# ----------------------------------------------------------------------------- +podman_stop() { + if [ ! -S "$PODMAN_SOCKET" ]; then + podman_log "stop: not running (no socket at $PODMAN_SOCKET)" + # Still attempt an unmount in case a prior stop was interrupted after + # the service went down but before the unmount completed. + "$SBIN_DIR/podman-storage.sh" unmount || true + rm -f "$PODMAN_STATUS_FILE" + return 0 + fi + + podman_log "stop: stopping running containers (timeout ${STOP_TIMEOUT:-10}s each)" + local running_ids + running_ids=$(podman --url "unix://$PODMAN_SOCKET" ps -q 2> /dev/null || true) + if [ -n "$running_ids" ]; then + local id + for id in $running_ids; do + podman --url "unix://$PODMAN_SOCKET" stop -t "${STOP_TIMEOUT:-10}" "$id" \ + > /dev/null 2>&1 \ + || podman_log_error "stop: failed to stop container $id within timeout" + done + fi + + podman_log "stop: stopping podman system service" + if [ -f "$PODMAN_SERVICE_PID_FILE" ]; then + local pid + pid=$(cat "$PODMAN_SERVICE_PID_FILE") + if kill -0 "$pid" 2> /dev/null; then + kill "$pid" 2> /dev/null || true + local waited=0 + while kill -0 "$pid" 2> /dev/null && [ "$waited" -lt 10 ]; do + sleep 1 + waited=$((waited + 1)) + done + kill -0 "$pid" 2> /dev/null && kill -9 "$pid" 2> /dev/null || true + fi + rm -f "$PODMAN_SERVICE_PID_FILE" + fi + rm -f "$PODMAN_SOCKET" + + "$SBIN_DIR/podman-storage.sh" unmount || podman_log_error "stop: storage unmount failed" + + rm -f "$PODMAN_STATUS_FILE" + podman_log "stop: stopped" + return 0 +} + +# ----------------------------------------------------------------------------- +# podman_status +# +# Human-readable health summary — see docs/ARCHITECTURE.md section 16.2. +# ----------------------------------------------------------------------------- +podman_status() { + echo "PODMAN_ENABLED: ${PODMAN_ENABLED:-yes}" + + if [ -S "$PODMAN_SOCKET" ] && [ -f "$PODMAN_SERVICE_PID_FILE" ] \ + && kill -0 "$(cat "$PODMAN_SERVICE_PID_FILE" 2> /dev/null)" 2> /dev/null; then + echo "service: running (pid $(cat "$PODMAN_SERVICE_PID_FILE"), socket $PODMAN_SOCKET)" + else + echo "service: stopped" + fi + + echo + "$SBIN_DIR/podman-storage.sh" status + + echo + if [ -f "$PODMAN_AUTOSTART_FILE" ]; then + local count + count=$(grep -vcE '^\s*(#|$)' "$PODMAN_AUTOSTART_FILE" 2> /dev/null || echo 0) + echo "autostart entries: $count (see $PODMAN_AUTOSTART_FILE)" + fi + + if [ -S "$PODMAN_SOCKET" ]; then + echo + echo "podman info:" + podman --url "unix://$PODMAN_SOCKET" info --format \ + ' containers: {{.Store.ContainerStore.Number}} images: {{.Store.ImageStore.Number}}' \ + 2> /dev/null || echo " (failed to query — service may still be initializing)" + fi +} + +case "${1:-}" in + start) + podman_start + ;; + stop) + podman_stop + ;; + restart) + podman_stop + podman_start + ;; + status) + podman_status + ;; + *) + echo "usage: $0 {start|stop|restart|status}" + exit 1 + ;; +esac diff --git a/plugin/sbin/podman-autostart.sh b/plugin/sbin/podman-autostart.sh new file mode 100755 index 0000000..99389f4 --- /dev/null +++ b/plugin/sbin/podman-autostart.sh @@ -0,0 +1,172 @@ +#!/bin/bash +# ============================================================================= +# plugin/sbin/podman-autostart.sh +# +# "Autostart einrichten" — starts containers listed in +# /boot/config/plugins/podman/autostart, in file order, via the running +# Podman API service. Run by `rc.podman start` after the service is up. +# See docs/ARCHITECTURE.md section 12 (Autostart). +# +# Design constraints from the architecture doc, both implemented below: +# - A single container failing to start must NOT abort the rest of the +# chain (one broken container shouldn't take down everything else). +# - After repeated consecutive failures, a container is automatically +# paused out of the autostart chain ("Safe-Mode pro Container") so a +# persistently crash-looping container doesn't waste boot time or +# resources forever — with a GUI notification explaining why. +# +# File formats: +# autostart one container name per line; '#' starts a comment; +# blank lines ignored. Order = start order. +# autostart-delay optional "=" lines — sleep +# that many seconds AFTER starting that container +# before moving to the next (for startup dependencies, +# e.g. a database before the app that needs it). +# +# Failure tracking: a small per-container counter file under +# $PODMAN_BOOT_DIR/autostart-failures/ holds the consecutive-failure +# count. Reset to 0 on any successful start. +# ============================================================================= + +set -u + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=./podman-common.sh +. "$SCRIPT_DIR/podman-common.sh" + +podman_load_cfg + +# After this many consecutive failed autostart attempts, a container is +# skipped and flagged rather than retried again on the next boot, until a +# human clears it (see clear_failure_flag below). +MAX_CONSECUTIVE_FAILURES=3 + +FAILURES_DIR="$PODMAN_BOOT_DIR/autostart-failures" +mkdir -p "$FAILURES_DIR" + +# ----------------------------------------------------------------------------- +# get_delay +# +# Looks up an optional post-start delay for a container from +# autostart-delay ("name=seconds" lines). Returns 0 if not configured. +# ----------------------------------------------------------------------------- +get_delay() { + local name="$1" + if [ -f "$PODMAN_AUTOSTART_DELAY_FILE" ]; then + awk -F= -v n="$name" '$1 == n { print $2; found=1 } END { if (!found) print 0 }' \ + "$PODMAN_AUTOSTART_DELAY_FILE" + else + echo 0 + fi +} + +failure_count() { + local name="$1" + local f="$FAILURES_DIR/$name" + [ -f "$f" ] && cat "$f" || echo 0 +} + +record_failure() { + local name="$1" + local count + count=$(($(failure_count "$name") + 1)) + echo "$count" > "$FAILURES_DIR/$name" + echo "$count" +} + +clear_failure_flag() { + local name="$1" + rm -f "$FAILURES_DIR/$name" +} + +# ----------------------------------------------------------------------------- +# start_one +# +# Starts a single container via `podman start`, which talks to the already +# running API service over $PODMAN_SOCKET rather than spawning an unrelated +# podman process tree — see docs/ARCHITECTURE.md section 11 (Container). +# ----------------------------------------------------------------------------- +start_one() { + local name="$1" + + local prior_failures + prior_failures=$(failure_count "$name") + if [ "$prior_failures" -ge "$MAX_CONSECUTIVE_FAILURES" ]; then + podman_log_error "autostart: skipping '$name' — $prior_failures consecutive prior failures (Safe-Mode). Remove $FAILURES_DIR/$name to re-enable." + return 1 + fi + + podman_log "autostart: starting '$name'" + if podman --url "unix://$PODMAN_SOCKET" start "$name" > /tmp/podman-autostart-"$name".log 2>&1; then + clear_failure_flag "$name" + podman_log "autostart: '$name' started successfully" + return 0 + fi + + local new_count + new_count=$(record_failure "$name") + podman_log_error "autostart: '$name' failed to start (attempt $new_count/$MAX_CONSECUTIVE_FAILURES) — see /tmp/podman-autostart-$name.log" + + if [ "$new_count" -ge "$MAX_CONSECUTIVE_FAILURES" ]; then + podman_notify "Podman container disabled from autostart" \ + "'$name' failed to start $new_count times in a row and has been paused from autostart. Fix the underlying issue, then remove $FAILURES_DIR/$name to re-enable." \ + "warning" + fi + + return 1 +} + +# ----------------------------------------------------------------------------- +# main +# ----------------------------------------------------------------------------- +if [ ! -f "$PODMAN_AUTOSTART_FILE" ]; then + podman_log "autostart: no autostart file at $PODMAN_AUTOSTART_FILE, nothing to do" + exit 0 +fi + +if [ ! -S "$PODMAN_SOCKET" ]; then + podman_log_error "autostart: podman API socket ($PODMAN_SOCKET) not present — is the service running?" + exit 1 +fi + +started=0 +skipped=0 +failed=0 + +while IFS= read -r raw_line || [ -n "$raw_line" ]; do + # Strip comments and surrounding whitespace; skip blank lines. + line="${raw_line%%#*}" + line="$(echo "$line" | xargs || true)" + [ -z "$line" ] && continue + + if start_one "$line"; then + started=$((started + 1)) + else + prior=$(failure_count "$line") + if [ "$prior" -ge "$MAX_CONSECUTIVE_FAILURES" ]; then + skipped=$((skipped + 1)) + else + failed=$((failed + 1)) + fi + # A single container's failure does not abort the loop — see file + # header. Continue to the next entry. + continue + fi + + delay=$(get_delay "$line") + if [ "$delay" -gt 0 ] 2> /dev/null; then + podman_log "autostart: waiting ${delay}s after '$line' (configured dependency delay)" + sleep "$delay" + fi +done < "$PODMAN_AUTOSTART_FILE" + +podman_log "autostart: complete (started=$started failed=$failed skipped-safe-mode=$skipped)" + +# Exit non-zero only if EVERY entry failed outright (as opposed to a mix, +# or entries already in Safe-Mode) — rc.podman treats that as worth +# flagging loudly, whereas a partial failure is already individually +# notified above. +if [ "$started" -eq 0 ] && [ "$failed" -gt 0 ]; then + exit 1 +fi +exit 0 diff --git a/plugin/sbin/podman-backup.sh b/plugin/sbin/podman-backup.sh new file mode 100755 index 0000000..e5c4eea --- /dev/null +++ b/plugin/sbin/podman-backup.sh @@ -0,0 +1,168 @@ +#!/bin/bash +# ============================================================================= +# plugin/sbin/podman-backup.sh +# +# Rollback support — see docs/ARCHITECTURE.md section 14 (Rollback). Two +# independent kinds of backup, matching the two kinds of thing an update can +# break: +# +# PACKAGES plugin/podman.plg downloads each release's .txz files +# straight into $PODMAN_BOOT_DIR/backup/packages// +# — all 7 component packages of one plugin release share a +# single directory named after the PLUGIN version (not each +# component's own version), since a rollback means "go back to +# plugin release X" as one unit — rather than into a scratch +# dir that gets discarded, so the previously installed +# version's packages are automatically still on disk after an +# update, with no separate copy step. `restore-packages +# ` reinstalls every package from one of those +# directories. +# +# CONFIG Before a config schema migration (see podman.cfg's +# CONFIG_SCHEMA_VERSION), the plg's postinstall calls +# `snapshot-config` to copy the current *.conf + podman.cfg +# into $PODMAN_BOOT_DIR/backup/config//. +# `restore-config ` copies them back. +# +# `prune` bounds how many old snapshots of each kind are kept, since flash +# space is limited (see ARCHITECTURE.md section 14, "Rollback-Historie +# begrenzen"). +# +# Usage: +# podman-backup.sh snapshot-config +# podman-backup.sh restore-config +# podman-backup.sh restore-packages +# podman-backup.sh list +# podman-backup.sh prune [--keep N] +# ============================================================================= + +set -eu + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=./podman-common.sh +. "$SCRIPT_DIR/podman-common.sh" + +DEFAULT_KEEP=3 + +cmd_snapshot_config() { + local ts + ts=$(date -u +%Y%m%dT%H%M%SZ) + local dest="$PODMAN_BACKUP_DIR/config/$ts" + mkdir -p "$dest" + + local f + for f in podman.cfg containers.conf storage.conf registries.conf policy.json; do + [ -f "$PODMAN_BOOT_DIR/$f" ] && cp "$PODMAN_BOOT_DIR/$f" "$dest/" + done + + podman_log "backup: config snapshot saved to $dest" + echo "$ts" +} + +cmd_restore_config() { + local ts="${1:?usage: podman-backup.sh restore-config }" + local src="$PODMAN_BACKUP_DIR/config/$ts" + + if [ ! -d "$src" ]; then + podman_log_error "backup: no config snapshot found at $src" + exit 1 + fi + + local f + for f in "$src"/*; do + [ -f "$f" ] || continue + cp "$f" "$PODMAN_BOOT_DIR/$(basename "$f")" + done + + podman_log "backup: config restored from snapshot $ts — restart podman (rc.podman restart) to apply" +} + +cmd_restore_packages() { + local version="${1:?usage: podman-backup.sh restore-packages }" + local src="$PODMAN_BACKUP_DIR/packages/$version" + + if [ ! -d "$src" ]; then + podman_log_error "backup: no package backup found for version $version at $src" + exit 1 + fi + + podman_require_command upgradepkg + + local txz + local restored=0 + for txz in "$src"/*.txz; do + [ -f "$txz" ] || continue + podman_log "backup: reinstalling $(basename "$txz") from backup (version $version)" + upgradepkg --reinstall --install-new "$txz" + restored=$((restored + 1)) + done + + if [ "$restored" -eq 0 ]; then + podman_log_error "backup: no .txz files found in $src" + exit 1 + fi + + podman_notify "Podman packages rolled back" \ + "Reinstalled $restored package(s) from the version $version backup. Run 'rc.podman restart' to apply." \ + "warning" + podman_log "backup: package rollback to version $version complete ($restored package(s))" +} + +cmd_list() { + echo "Config snapshots ($PODMAN_BACKUP_DIR/config/):" + if [ -d "$PODMAN_BACKUP_DIR/config" ]; then + find "$PODMAN_BACKUP_DIR/config" -mindepth 1 -maxdepth 1 -type d -printf ' %f\n' 2> /dev/null | sort -r + fi + echo + echo "Package backups ($PODMAN_BACKUP_DIR/packages/):" + if [ -d "$PODMAN_BACKUP_DIR/packages" ]; then + find "$PODMAN_BACKUP_DIR/packages" -mindepth 1 -maxdepth 1 -type d -printf ' %f\n' 2> /dev/null | sort -r + fi +} + +cmd_prune() { + local keep="$DEFAULT_KEEP" + if [ "${1:-}" = "--keep" ] && [ -n "${2:-}" ]; then + keep="$2" + fi + + local kind + for kind in config packages; do + local dir="$PODMAN_BACKUP_DIR/$kind" + [ -d "$dir" ] || continue + + # Sort newest-first by directory name (timestamps and semver both sort + # correctly lexicographically here — timestamps are zero-padded ISO8601, + # versions are compared via `sort -V`), then remove everything beyond + # $keep. + local sorted + if [ "$kind" = "config" ]; then + sorted=$(find "$dir" -mindepth 1 -maxdepth 1 -type d -printf '%f\n' | sort -r) + else + sorted=$(find "$dir" -mindepth 1 -maxdepth 1 -type d -printf '%f\n' | sort -rV) + fi + + local i=0 + local name + while IFS= read -r name; do + [ -z "$name" ] && continue + i=$((i + 1)) + if [ "$i" -gt "$keep" ]; then + podman_log "backup: pruning old $kind backup: $name" + rm -rf "${dir:?}/$name" + fi + done <<< "$sorted" + done +} + +case "${1:-}" in + snapshot-config) cmd_snapshot_config ;; + restore-config) shift; cmd_restore_config "$@" ;; + restore-packages) shift; cmd_restore_packages "$@" ;; + list) cmd_list ;; + prune) shift; cmd_prune "$@" ;; + *) + echo "usage: $0 {snapshot-config|restore-config |restore-packages |list|prune [--keep N]}" >&2 + exit 1 + ;; +esac diff --git a/plugin/sbin/podman-common.sh b/plugin/sbin/podman-common.sh new file mode 100755 index 0000000..fb4dc55 --- /dev/null +++ b/plugin/sbin/podman-common.sh @@ -0,0 +1,173 @@ +#!/bin/bash +# ============================================================================= +# plugin/sbin/podman-common.sh +# +# Shared constants and helper functions sourced by every other script under +# plugin/sbin/ and by plugin/rc.d/rc.podman. Centralizing these here means: +# - every script agrees on the same paths (no risk of one script writing +# config to a path another script reads from a slightly different one), +# - logging/notification behavior is consistent everywhere, +# - each individual script stays focused on its one job instead of +# re-implementing "how do I log this" or "where is podman.cfg". +# +# This file is NOT meant to be executed directly — it only defines +# functions/variables for other scripts to source: +# . "$(dirname "${BASH_SOURCE[0]}")/podman-common.sh" +# +# See docs/ARCHITECTURE.md sections 4 (Verzeichnislayout), 7 (Persistenz), +# 15 (Logging), 16 (Fehlerbehandlung) for the design this implements. +# ============================================================================= + +# We deliberately do NOT `set -e` in this file: it is sourced by scripts +# that set their own error-handling mode, and a library changing its +# caller's shell options would be a surprising action at a distance. + +# ----------------------------------------------------------------------------- +# Path constants. +# +# PODMAN_BOOT_DIR is the persistent, source-of-truth configuration directory +# on the Unraid flash device (survives reboots — see ARCHITECTURE.md 4.1). +# Everything under /etc, /var, /usr is RAM-root and rebuilt from here (or +# from the array/cache-backed storage dir) on every boot. +# ----------------------------------------------------------------------------- +PODMAN_BOOT_DIR="/boot/config/plugins/podman" +PODMAN_CFG_FILE="$PODMAN_BOOT_DIR/podman.cfg" +PODMAN_AUTOSTART_FILE="$PODMAN_BOOT_DIR/autostart" +PODMAN_AUTOSTART_DELAY_FILE="$PODMAN_BOOT_DIR/autostart-delay" +PODMAN_NETWORKS_BOOT_DIR="$PODMAN_BOOT_DIR/networks" +PODMAN_BACKUP_DIR="$PODMAN_BOOT_DIR/backup" +PODMAN_PLUGIN_LOG="$PODMAN_BOOT_DIR/plugin.log" + +# Runtime (RAM-root) locations rebuilt/synced on every rc.podman start. +PODMAN_ETC_DIR="/etc/containers" +PODMAN_RUN_DIR="/var/run/podman" +PODMAN_SOCKET="$PODMAN_RUN_DIR/podman.sock" +PODMAN_STATUS_FILE="$PODMAN_RUN_DIR/rc.podman.status" +PODMAN_SERVICE_PID_FILE="$PODMAN_RUN_DIR/podman-service.pid" + +# ----------------------------------------------------------------------------- +# podman_load_cfg +# +# Sources /boot/config/plugins/podman/podman.cfg (the user-editable settings +# file, see config/podman.cfg.example) and applies safe defaults for any +# setting that file doesn't define — so every other script can simply +# reference $STORAGE_PATH, $PODMAN_ENABLED, etc. after calling this, without +# each script having its own copy of the defaults. +# ----------------------------------------------------------------------------- +podman_load_cfg() { + # Defaults, applied BEFORE sourcing podman.cfg so the file only needs to + # override what the user actually wants to change. + STORAGE_PATH="/mnt/cache/system/podman" + STORAGE_IMAGE_SIZE_GB="20" + PODMAN_ENABLED="yes" + STOP_TIMEOUT="10" + CONFIG_SCHEMA_VERSION="1" + + if [ -f "$PODMAN_CFG_FILE" ]; then + # shellcheck source=/dev/null + . "$PODMAN_CFG_FILE" + fi + + # Values derived from STORAGE_PATH, computed after sourcing so a custom + # STORAGE_PATH is honored. + PODMAN_STORAGE_IMAGE="$STORAGE_PATH/podman.img" + PODMAN_LOG_DIR="$STORAGE_PATH/logs" + PODMAN_GRAPHROOT="/var/lib/containers/storage" +} + +# ----------------------------------------------------------------------------- +# podman_log +# +# Writes a timestamped line to both stdout (so it shows up in `rc.podman` +# invocations and CI/manual runs) and to the persistent plugin log on flash +# (so it survives a reboot — see ARCHITECTURE.md section 15, Logging). Kept +# deliberately terse (no log levels/rotation logic here) — this is for +# install/lifecycle events, not container output, which lives under +# $PODMAN_LOG_DIR instead. +# ----------------------------------------------------------------------------- +podman_log() { + local msg="$1" + local line + line="$(date -u +'%Y-%m-%dT%H:%M:%SZ') [podman] $msg" + echo "$line" + # Best-effort: the boot directory should always exist post-install, but + # never let logging itself fail a caller that has `set -e`. + mkdir -p "$PODMAN_BOOT_DIR" 2> /dev/null || true + echo "$line" >> "$PODMAN_PLUGIN_LOG" 2> /dev/null || true +} + +# ----------------------------------------------------------------------------- +# podman_log_error +# +# Like podman_log, but also mirrors the message to the system log via +# `logger`, so it is visible in Unraid's Tools -> System Log GUI without the +# user having to know where the plugin's own log lives. Reserved for +# conditions the user should actually notice (see ARCHITECTURE.md 16.2). +# ----------------------------------------------------------------------------- +podman_log_error() { + local msg="$1" + podman_log "ERROR: $msg" + if command -v logger > /dev/null 2>&1; then + logger -t podman-plugin "$msg" + fi +} + +# ----------------------------------------------------------------------------- +# podman_notify [importance] +# +# Surfaces a message via Unraid's own GUI notification system +# (/usr/local/emhttp/webGui/scripts/notify) instead of inventing a parallel +# notification mechanism — see ARCHITECTURE.md section 16.2. Silently +# no-ops if that script isn't present (e.g. when running outside a real +# Unraid system, such as in CI/lint contexts). +# +# importance: "normal" (default), "warning", or "alert". +# ----------------------------------------------------------------------------- +podman_notify() { + local subject="$1" + local description="$2" + local importance="${3:-normal}" + local notify_bin="/usr/local/emhttp/webGui/scripts/notify" + + podman_log "notify [$importance] $subject: $description" + + if [ -x "$notify_bin" ]; then + "$notify_bin" -e "unraid-podman" -s "$subject" -d "$description" -i "$importance" \ + > /dev/null 2>&1 || true + fi +} + +# ----------------------------------------------------------------------------- +# podman_storage_path_is_safe +# +# Refuses storage paths under /mnt/user (FUSE/shfs) — see +# ARCHITECTURE.md section 4.3 for why the overlay storage driver must live +# on a real mounted filesystem (cache pool or a specific disk), not shfs. +# Returns 0 (safe) or 1 (unsafe) and prints a reason on failure. +# ----------------------------------------------------------------------------- +podman_storage_path_is_safe() { + local path="$1" + case "$path" in + /mnt/user/*|/mnt/user) + echo "STORAGE_PATH ($path) is under /mnt/user (FUSE/shfs)." >&2 + echo "The overlay storage driver needs a real mounted filesystem —" >&2 + echo "use a cache pool or a specific disk path instead, e.g. /mnt/cache/system/podman." >&2 + return 1 + ;; + esac + return 0 +} + +# ----------------------------------------------------------------------------- +# podman_require_command +# +# Fails loudly (rather than letting a script continue and fail confusingly +# three steps later) if a required binary isn't on PATH. +# ----------------------------------------------------------------------------- +podman_require_command() { + local bin="$1" + if ! command -v "$bin" > /dev/null 2>&1; then + podman_log_error "required command not found: $bin" + return 1 + fi +} diff --git a/plugin/sbin/podman-config.sh b/plugin/sbin/podman-config.sh new file mode 100755 index 0000000..375e431 --- /dev/null +++ b/plugin/sbin/podman-config.sh @@ -0,0 +1,141 @@ +#!/bin/bash +# ============================================================================= +# plugin/sbin/podman-config.sh +# +# "Konfiguration anlegen" — creates and synchronizes unraid-podman's +# configuration. Has two responsibilities, run as two subcommands: +# +# podman-config.sh seed +# First-install (or "file went missing") step: ensures +# /boot/config/plugins/podman/ exists with its full directory skeleton, +# and copies default config templates into it — but ONLY for files that +# don't already exist. This is what makes plugin updates safe: an +# existing user configuration is never overwritten (see +# docs/ARCHITECTURE.md section 3.2 and section 7). +# +# podman-config.sh sync +# Boot-time step: copies the current, authoritative config from +# /boot/config/plugins/podman/*.conf into /etc/containers/ (which lives +# on Unraid's RAM-root and is empty again after every reboot). Run by +# rc.podman on every `start`. +# +# Template source: the plugin ships its default config templates (from this +# repo's config/) to /usr/local/share/unraid-podman/templates/ at install +# time (see plugin/podman.plg) — that is what `seed` copies FROM, and +# /boot/config/plugins/podman/ is what it copies TO. +# +# Usage: +# podman-config.sh seed +# podman-config.sh sync +# ============================================================================= + +set -eu + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=./podman-common.sh +. "$SCRIPT_DIR/podman-common.sh" + +TEMPLATES_DIR="/usr/local/share/unraid-podman/templates" + +# ----------------------------------------------------------------------------- +# seed_file +# +# Copies a template into place only if the destination doesn't already +# exist. This single rule is what protects user customizations across +# plugin updates — see docs/ARCHITECTURE.md section 7, "Persistenz-Strategie". +# ----------------------------------------------------------------------------- +seed_file() { + local template_name="$1" + local dest_path="$2" + local src_path="$TEMPLATES_DIR/$template_name" + + if [ -f "$dest_path" ]; then + podman_log "config: $dest_path already exists, leaving untouched" + return 0 + fi + + if [ ! -f "$src_path" ]; then + podman_log_error "config: template missing: $src_path (plugin install incomplete?)" + return 1 + fi + + mkdir -p "$(dirname "$dest_path")" + cp "$src_path" "$dest_path" + podman_log "config: seeded $dest_path from template $template_name" +} + +cmd_seed() { + podman_log "config: seeding /boot/config/plugins/podman/ (first install or repair)" + + # Full directory skeleton — see docs/ARCHITECTURE.md section 4.1 and + # plugin/boot-config/plugins/podman/README.md for the reference layout. + mkdir -p \ + "$PODMAN_BOOT_DIR" \ + "$PODMAN_NETWORKS_BOOT_DIR" \ + "$PODMAN_BACKUP_DIR/packages" \ + "$PODMAN_BACKUP_DIR/config" + + seed_file "podman.cfg.example" "$PODMAN_CFG_FILE" + seed_file "containers.conf" "$PODMAN_BOOT_DIR/containers.conf" + seed_file "storage.conf" "$PODMAN_BOOT_DIR/storage.conf" + seed_file "registries.conf" "$PODMAN_BOOT_DIR/registries.conf" + seed_file "policy.json" "$PODMAN_BOOT_DIR/policy.json" + + # Autostart files: empty by default, `touch` is the "seed" here (no + # template content to copy — see docs/ARCHITECTURE.md section 12). + [ -f "$PODMAN_AUTOSTART_FILE" ] || { touch "$PODMAN_AUTOSTART_FILE"; podman_log "config: created empty $PODMAN_AUTOSTART_FILE"; } + [ -f "$PODMAN_AUTOSTART_DELAY_FILE" ] || { touch "$PODMAN_AUTOSTART_DELAY_FILE"; podman_log "config: created empty $PODMAN_AUTOSTART_DELAY_FILE"; } + [ -f "$PODMAN_PLUGIN_LOG" ] || touch "$PODMAN_PLUGIN_LOG" + + podman_log "config: seeding complete" +} + +cmd_sync() { + podman_load_cfg + + if [ ! -d "$PODMAN_BOOT_DIR" ]; then + podman_log_error "config: $PODMAN_BOOT_DIR does not exist — run 'podman-config.sh seed' first" + return 1 + fi + + podman_log "config: syncing $PODMAN_BOOT_DIR/*.conf -> $PODMAN_ETC_DIR/" + mkdir -p "$PODMAN_ETC_DIR" + + local any_missing=0 + for f in containers.conf storage.conf registries.conf policy.json; do + if [ ! -f "$PODMAN_BOOT_DIR/$f" ]; then + podman_log_error "config: missing $PODMAN_BOOT_DIR/$f (run 'podman-config.sh seed')" + any_missing=1 + continue + fi + cp "$PODMAN_BOOT_DIR/$f" "$PODMAN_ETC_DIR/$f" + done + + if [ "$any_missing" -ne 0 ]; then + return 1 + fi + + # storage.conf's graphroot/runroot are environment-specific (depend on + # STORAGE_PATH from podman.cfg), so they are appended here at sync time + # rather than hardcoded in the template — see config/storage.conf's own + # comment on this split. + { + echo "" + echo "# --- appended at boot by podman-config.sh sync, from podman.cfg ---" + echo "[storage]" + echo "driver = \"overlay\"" + echo "graphroot = \"$PODMAN_GRAPHROOT\"" + echo "runroot = \"/var/run/containers/storage\"" + } >> "$PODMAN_ETC_DIR/storage.conf" + + podman_log "config: sync complete" +} + +case "${1:-}" in + seed) cmd_seed ;; + sync) cmd_sync ;; + *) + echo "usage: $0 {seed|sync}" >&2 + exit 1 + ;; +esac diff --git a/plugin/sbin/podman-preflight.sh b/plugin/sbin/podman-preflight.sh new file mode 100755 index 0000000..9111b6e --- /dev/null +++ b/plugin/sbin/podman-preflight.sh @@ -0,0 +1,150 @@ +#!/bin/bash +# ============================================================================= +# plugin/sbin/podman-preflight.sh +# +# Startup validation, run by `rc.podman start` BEFORE anything is mounted or +# started. The goal is to fail fast with one clear message, instead of +# letting `podman system service` fail three steps later with a cryptic +# error. See docs/ARCHITECTURE.md section 16.1 (Startup-Robustheit). +# +# Every check below is independent and all of them run even if an earlier +# one fails, so a single invocation reports every problem at once rather +# than forcing the user through a fix-one-rerun-find-the-next loop. +# +# Exit code: 0 if every check passed, 1 if any failed (with all failures +# already logged/notified by that point). +# ============================================================================= + +set -u # deliberately not -e: see the "run every check" note above + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=./podman-common.sh +. "$SCRIPT_DIR/podman-common.sh" + +podman_load_cfg + +FAILURES=0 + +fail() { + podman_log_error "preflight: $1" + FAILURES=$((FAILURES + 1)) +} + +ok() { + podman_log "preflight: OK - $1" +} + +# ----------------------------------------------------------------------------- +# 1. Required binaries are actually installed. +# ----------------------------------------------------------------------------- +for bin in podman conmon crun mount umount xfs_repair mkfs.xfs; do + if command -v "$bin" > /dev/null 2>&1; then + ok "$bin found" + else + fail "required binary '$bin' not found on PATH — is the plugin fully installed?" + fi +done + +for helper in /usr/libexec/podman/netavark /usr/libexec/podman/aardvark-dns; do + if [ -x "$helper" ]; then + ok "$helper found" + else + fail "required helper '$helper' not found — is the plugin fully installed?" + fi +done + +# ----------------------------------------------------------------------------- +# 2. Storage path configured, safe, and mounted (cache pool / disk, not +# /mnt/user — see docs/ARCHITECTURE.md section 4.3). +# ----------------------------------------------------------------------------- +if podman_storage_path_is_safe "$STORAGE_PATH" 2> /tmp/podman-preflight-storage-safety.log; then + ok "STORAGE_PATH ($STORAGE_PATH) is not under /mnt/user" +else + fail "$(cat /tmp/podman-preflight-storage-safety.log)" +fi +rm -f /tmp/podman-preflight-storage-safety.log + +if [ -d "$STORAGE_PATH" ] && mountpoint -q "$STORAGE_PATH" 2> /dev/null; then + ok "STORAGE_PATH ($STORAGE_PATH) is a mounted filesystem" +elif [ -d "$STORAGE_PATH" ]; then + # Not every valid target is a mountpoint itself (e.g. a subdirectory of a + # cache pool root) — warn rather than fail, but only if the parent chain + # is mounted somewhere. + if findmnt -T "$STORAGE_PATH" > /dev/null 2>&1; then + ok "STORAGE_PATH ($STORAGE_PATH) resolves onto a mounted filesystem" + else + fail "STORAGE_PATH ($STORAGE_PATH) does not appear to be on a mounted filesystem" + fi +else + fail "STORAGE_PATH ($STORAGE_PATH) does not exist — is the configured cache pool/disk present and started?" +fi + +# ----------------------------------------------------------------------------- +# 3. Free space check (only meaningful once STORAGE_PATH exists). +# ----------------------------------------------------------------------------- +if [ -d "$STORAGE_PATH" ]; then + available_kb=$(df --output=avail -k "$STORAGE_PATH" 2> /dev/null | tail -n1 | tr -d '[:space:]') + if [ -n "${available_kb:-}" ]; then + available_gb=$((available_kb / 1024 / 1024)) + if [ -f "$PODMAN_STORAGE_IMAGE" ]; then + # Image already exists — just warn if the pool itself is nearly full, + # since podman.img growth or new image pulls need headroom too. + if [ "$available_gb" -lt 2 ]; then + fail "less than 2G free on $STORAGE_PATH (${available_gb}G) — image pulls will likely fail" + else + ok "${available_gb}G free on $STORAGE_PATH" + fi + elif [ "$available_gb" -lt "$STORAGE_IMAGE_SIZE_GB" ]; then + fail "not enough free space on $STORAGE_PATH to create a ${STORAGE_IMAGE_SIZE_GB}G podman.img (only ${available_gb}G free)" + else + ok "${available_gb}G free on $STORAGE_PATH (enough for a fresh ${STORAGE_IMAGE_SIZE_GB}G podman.img)" + fi + fi +fi + +# ----------------------------------------------------------------------------- +# 4. Kernel supports cgroup v2 (required by crun/netavark's expectations). +# ----------------------------------------------------------------------------- +if [ -f /sys/fs/cgroup/cgroup.controllers ]; then + ok "cgroup v2 unified hierarchy is active" +else + fail "cgroup v2 unified hierarchy not detected (/sys/fs/cgroup/cgroup.controllers missing) — check Unraid's syslinux cgroup boot parameters" +fi + +# ----------------------------------------------------------------------------- +# 5. No orphaned socket/pidfile from a previous unclean shutdown. +# ----------------------------------------------------------------------------- +if [ -S "$PODMAN_SOCKET" ]; then + if [ -f "$PODMAN_SERVICE_PID_FILE" ] && kill -0 "$(cat "$PODMAN_SERVICE_PID_FILE")" 2> /dev/null; then + fail "podman system service already appears to be running (pid $(cat "$PODMAN_SERVICE_PID_FILE")) — is rc.podman already started?" + else + podman_log "preflight: removing orphaned socket $PODMAN_SOCKET from a previous unclean shutdown" + rm -f "$PODMAN_SOCKET" + ok "cleared orphaned socket" + fi +else + ok "no orphaned podman.sock" +fi + +# ----------------------------------------------------------------------------- +# 6. Boot-config directory exists (i.e. 'seed' has run at least once). +# ----------------------------------------------------------------------------- +if [ -f "$PODMAN_CFG_FILE" ]; then + ok "$PODMAN_CFG_FILE present" +else + fail "$PODMAN_CFG_FILE missing — run 'podman-config.sh seed' (should happen automatically on install)" +fi + +# ----------------------------------------------------------------------------- +# Summary +# ----------------------------------------------------------------------------- +if [ "$FAILURES" -gt 0 ]; then + podman_notify "Podman preflight checks failed" \ + "$FAILURES check(s) failed — podman was not started. See $PODMAN_PLUGIN_LOG for details." \ + "alert" + podman_log_error "preflight: $FAILURES check(s) failed, aborting start" + exit 1 +fi + +podman_log "preflight: all checks passed" +exit 0 diff --git a/plugin/sbin/podman-storage.sh b/plugin/sbin/podman-storage.sh new file mode 100755 index 0000000..239df5c --- /dev/null +++ b/plugin/sbin/podman-storage.sh @@ -0,0 +1,180 @@ +#!/bin/bash +# ============================================================================= +# plugin/sbin/podman-storage.sh +# +# "Container Storage erstellen" — creates, mounts, and unmounts the +# podman.img loopback filesystem that backs Podman's overlay storage graph +# (images, containers, named volumes). See docs/ARCHITECTURE.md section 4.3 +# for why this needs to be a real mounted filesystem (XFS with ftype=1, on a +# cache pool or dedicated disk) rather than a directory under /mnt/user — +# the FUSE (shfs) layer behind /mnt/user does not reliably support the +# overlay storage driver's filesystem requirements (d_type, etc). +# +# Usage: +# podman-storage.sh create # create podman.img if it doesn't exist yet +# podman-storage.sh mount # mount it at $PODMAN_GRAPHROOT (idempotent) +# podman-storage.sh unmount # cleanly unmount +# podman-storage.sh status # report existence/mount/usage +# ============================================================================= + +set -eu + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=./podman-common.sh +. "$SCRIPT_DIR/podman-common.sh" + +podman_load_cfg + +# ----------------------------------------------------------------------------- +# cmd_create +# +# Creates podman.img at the configured size if it doesn't already exist, +# and formats it XFS with ftype=1 (required for the overlay storage driver +# to see correct directory entry types — without it, podman's overlay +# backend silently falls back to a slower/less-capable mode or fails +# outright, depending on version). Does nothing (and exits 0) if the image +# already exists — this script is meant to be safe to call on every boot. +# ----------------------------------------------------------------------------- +cmd_create() { + if ! podman_storage_path_is_safe "$STORAGE_PATH"; then + return 1 + fi + + if [ -f "$PODMAN_STORAGE_IMAGE" ]; then + podman_log "storage: $PODMAN_STORAGE_IMAGE already exists, not recreating" + return 0 + fi + + if [ ! -d "$STORAGE_PATH" ]; then + podman_log_error "storage: $STORAGE_PATH does not exist or is not mounted." + podman_log_error "storage: check that the configured cache pool/disk is present before starting podman." + return 1 + fi + + # Free space check: refuse to create an image bigger than what's actually + # available, with a small safety margin, rather than letting truncate + # silently create a sparse file that will fail unpredictably later once + # it's actually written to (see docs/ARCHITECTURE.md section 16.2). + local available_kb required_kb + available_kb=$(df --output=avail -k "$STORAGE_PATH" | tail -n1 | tr -d '[:space:]') + required_kb=$((STORAGE_IMAGE_SIZE_GB * 1024 * 1024)) + if [ "$available_kb" -lt "$required_kb" ]; then + podman_log_error "storage: not enough free space on $STORAGE_PATH (need ${STORAGE_IMAGE_SIZE_GB}G, have $((available_kb / 1024 / 1024))G)" + podman_notify "Podman storage creation failed" \ + "Not enough free space on $STORAGE_PATH for a ${STORAGE_IMAGE_SIZE_GB}G podman.img." \ + "alert" + return 1 + fi + + podman_log "storage: creating ${STORAGE_IMAGE_SIZE_GB}G image at $PODMAN_STORAGE_IMAGE" + mkdir -p "$STORAGE_PATH" + + # Sparse file: only actually consumes disk space as data is written, + # matching Docker-for-Unraid's docker.img behavior that users already + # understand. + truncate -s "${STORAGE_IMAGE_SIZE_GB}G" "$PODMAN_STORAGE_IMAGE" + + podman_require_command mkfs.xfs + # -n ftype=1 is the whole reason this has to be a purpose-made image + # rather than a plain directory — see file header comment. + mkfs.xfs -n ftype=1 -q "$PODMAN_STORAGE_IMAGE" + + podman_log "storage: created and formatted $PODMAN_STORAGE_IMAGE" +} + +# ----------------------------------------------------------------------------- +# cmd_mount +# +# Idempotent: does nothing if $PODMAN_GRAPHROOT is already mounted from +# podman.img. Runs a read-only integrity check (xfs_repair -n) before +# mounting and refuses to proceed if it reports corruption — per +# docs/ARCHITECTURE.md section 16.1, this project does not auto-repair +# storage without the user's explicit action, to avoid silent data loss. +# ----------------------------------------------------------------------------- +cmd_mount() { + if mountpoint -q "$PODMAN_GRAPHROOT" 2> /dev/null; then + podman_log "storage: $PODMAN_GRAPHROOT already mounted" + return 0 + fi + + if [ ! -f "$PODMAN_STORAGE_IMAGE" ]; then + podman_log_error "storage: $PODMAN_STORAGE_IMAGE does not exist — run 'podman-storage.sh create' first" + return 1 + fi + + podman_require_command xfs_repair + podman_log "storage: checking filesystem integrity of $PODMAN_STORAGE_IMAGE" + if ! xfs_repair -n "$PODMAN_STORAGE_IMAGE" > /tmp/podman-xfs-repair.log 2>&1; then + podman_log_error "storage: filesystem check failed for $PODMAN_STORAGE_IMAGE — refusing to mount." + podman_log_error "storage: see /tmp/podman-xfs-repair.log. Run 'xfs_repair $PODMAN_STORAGE_IMAGE' manually to attempt repair, or restore from backup." + podman_notify "Podman storage corruption detected" \ + "$PODMAN_STORAGE_IMAGE failed an integrity check and was not mounted. Manual recovery required — see plugin.log." \ + "alert" + return 1 + fi + + mkdir -p "$PODMAN_GRAPHROOT" + podman_log "storage: mounting $PODMAN_STORAGE_IMAGE at $PODMAN_GRAPHROOT" + mount -o loop "$PODMAN_STORAGE_IMAGE" "$PODMAN_GRAPHROOT" + + # Runtime state (runroot) is tmpfs-backed and fine to live on RAM-root — + # only the persistent graphroot needs the loopback filesystem. + mkdir -p /var/run/containers/storage + + podman_log "storage: mounted" +} + +# ----------------------------------------------------------------------------- +# cmd_unmount +# +# Unmounts cleanly. Retries briefly if the mount is momentarily busy (a +# container process exiting can hold a reference for a few hundred ms), +# rather than immediately failing rc.podman's stop sequence. +# ----------------------------------------------------------------------------- +cmd_unmount() { + if ! mountpoint -q "$PODMAN_GRAPHROOT" 2> /dev/null; then + podman_log "storage: $PODMAN_GRAPHROOT not mounted, nothing to do" + return 0 + fi + + podman_log "storage: unmounting $PODMAN_GRAPHROOT" + local attempt + for attempt in 1 2 3 4 5; do + if umount "$PODMAN_GRAPHROOT" 2> /dev/null; then + podman_log "storage: unmounted" + return 0 + fi + sleep 1 + done + + podman_log_error "storage: failed to unmount $PODMAN_GRAPHROOT after 5 attempts (still busy?)" + return 1 +} + +cmd_status() { + echo "storage image: $PODMAN_STORAGE_IMAGE" + if [ -f "$PODMAN_STORAGE_IMAGE" ]; then + echo " exists: yes ($(du -h "$PODMAN_STORAGE_IMAGE" | cut -f1) allocated / ${STORAGE_IMAGE_SIZE_GB}G nominal)" + else + echo " exists: no" + fi + + echo "graphroot: $PODMAN_GRAPHROOT" + if mountpoint -q "$PODMAN_GRAPHROOT" 2> /dev/null; then + echo " mounted: yes" + df -h "$PODMAN_GRAPHROOT" | tail -n1 | awk '{print " usage: " $3 " used / " $2 " total (" $5 " full)"}' + else + echo " mounted: no" + fi +} + +case "${1:-}" in + create) cmd_create ;; + mount) cmd_mount ;; + unmount) cmd_unmount ;; + status) cmd_status ;; + *) + echo "usage: $0 {create|mount|unmount|status}" >&2 + exit 1 + ;; +esac diff --git a/plugin/sbin/podman-uninstall-cleanup.sh b/plugin/sbin/podman-uninstall-cleanup.sh new file mode 100755 index 0000000..eaa9e54 --- /dev/null +++ b/plugin/sbin/podman-uninstall-cleanup.sh @@ -0,0 +1,94 @@ +#!/bin/bash +# ============================================================================= +# plugin/sbin/podman-uninstall-cleanup.sh +# +# "Beim Entfernen sauber aufräumen" — run by plugin/podman.plg's block when the plugin is uninstalled via Unraid's Plugins +# page. Two-tier cleanup, matching docs/ARCHITECTURE.md section 3.2 / +# section 17: +# +# ALWAYS removed (installed software, safe to regenerate): +# - running containers/service stopped cleanly +# - runtime config under /etc/containers, /var/run/podman +# - staged helper scripts, templates, WebUI files, event hooks +# +# NOT applicable: this project has no /boot/config/go entry to clean up +# in the first place — it hooks the official Unraid plugin event +# mechanism (/usr/local/emhttp/plugins/podman/event/*, see +# plugin/event/disks_mounted's header comment) instead of editing go, +# so removepkg-ing the unraid-podman package (done by podman.plg right +# after this script runs) already removes those hooks along with +# everything else under /usr/local/emhttp/plugins/podman/. +# +# PRESERVED BY DEFAULT (user data — see docs/INSTALL.md "Uninstallation"): +# - /boot/config/plugins/podman/ (settings, autostart list, backups) +# - $STORAGE_PATH (podman.img — every image/container/volume the user +# has) +# +# Full data removal is opt-in only, via: +# podman-uninstall-cleanup.sh --purge-data +# never the default, and never triggered automatically — deleting a user's +# containers/images without an explicit, separate confirmation is exactly +# the kind of destructive-by-surprise behavior this project avoids (see +# docs/ARCHITECTURE.md "Nicht-Ziele" around data safety). +# ============================================================================= + +set -u + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=./podman-common.sh +. "$SCRIPT_DIR/podman-common.sh" + +PURGE_DATA=0 +[ "${1:-}" = "--purge-data" ] && PURGE_DATA=1 + +podman_log "uninstall: starting cleanup (purge-data=$PURGE_DATA)" + +# ----------------------------------------------------------------------------- +# 1. Stop podman cleanly if it's running, so containers shut down properly +# and the storage loopback is unmounted before we remove anything else. +# ----------------------------------------------------------------------------- +if [ -x /etc/rc.d/rc.podman ]; then + podman_log "uninstall: stopping podman" + /etc/rc.d/rc.podman stop || podman_log_error "uninstall: rc.podman stop reported an error (continuing cleanup anyway)" +fi + +# ----------------------------------------------------------------------------- +# 2. Remove runtime state (RAM-root anyway, but tidy up now rather than +# waiting for the next reboot to implicitly clear it). +# ----------------------------------------------------------------------------- +podman_log "uninstall: removing runtime state" +rm -rf "$PODMAN_ETC_DIR" "$PODMAN_RUN_DIR" + +# ----------------------------------------------------------------------------- +# 3. Remove staged files this plugin installed outside of package-managed +# paths. This is technically redundant with podman.plg's removepkg of +# the unraid-podman package that immediately follows this script (see +# that package's manifest — it owns exactly these paths), but doing it +# explicitly here too means this script is also safe to run manually as +# a standalone repair tool without relying on removepkg bookkeeping. +# ----------------------------------------------------------------------------- +podman_log "uninstall: removing plugin scaffolding" +rm -rf /usr/local/share/unraid-podman +rm -rf /usr/local/emhttp/plugins/podman +rm -f /etc/rc.d/rc.podman + +# ----------------------------------------------------------------------------- +# 4. User data — preserved unless --purge-data was explicitly passed. +# ----------------------------------------------------------------------------- +if [ "$PURGE_DATA" -eq 1 ]; then + podman_load_cfg + podman_log "uninstall: --purge-data given, removing user data" + + if [ -n "${STORAGE_PATH:-}" ] && [ -d "$STORAGE_PATH" ]; then + podman_log "uninstall: removing $STORAGE_PATH (images, containers, volumes, logs)" + rm -rf "${STORAGE_PATH:?}" + fi + + podman_log "uninstall: removing $PODMAN_BOOT_DIR (settings, autostart, backups)" + rm -rf "${PODMAN_BOOT_DIR:?}" +else + podman_log "uninstall: preserving $PODMAN_BOOT_DIR and podman's storage path (pass --purge-data to remove them too)" +fi + +podman_log "uninstall: cleanup complete" diff --git a/plugin/sbin/podman-update-packages.sh b/plugin/sbin/podman-update-packages.sh new file mode 100755 index 0000000..11eab51 --- /dev/null +++ b/plugin/sbin/podman-update-packages.sh @@ -0,0 +1,134 @@ +#!/bin/bash +# ============================================================================= +# plugin/sbin/podman-update-packages.sh +# +# "Pakete aktualisieren" — reconciles installed packages with what the +# currently installed plugin version expects, per +# /usr/local/share/unraid-podman/installed-versions.env (written by +# plugin/podman.plg's postinstall — see podman-verify-packages.sh for the +# same manifest used the other direction, to *check* rather than *fix*). +# +# This is intentionally idempotent and safe to run any time, not just +# during a plugin update: if every package already matches, it's a no-op. +# It exists as a separate script (rather than being inline in podman.plg) +# for two reasons: +# 1. plugin/podman.plg's own blocks handle the +# *normal* update path (new .txz already downloaded by the plg, +# installed as part of the same transaction) — this script is the +# *repair* path for when that didn't fully complete (e.g. Unraid was +# rebooted mid-update), and can be re-run safely from the command line +# or from a future WebUI "check for package issues" action. +# 2. It calls podman-backup.sh snapshot-config first, since any package +# swap is exactly the moment a config schema migration might be +# needed — see docs/ARCHITECTURE.md section 13.1. +# +# Usage: +# podman-update-packages.sh # reconcile all 7 packages +# podman-update-packages.sh podman # reconcile a single package +# ============================================================================= + +set -u + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=./podman-common.sh +. "$SCRIPT_DIR/podman-common.sh" + +INSTALLED_VERSIONS_FILE="/usr/local/share/unraid-podman/installed-versions.env" +ALL_PACKAGES="podman conmon crun netavark aardvark-dns passt fuse-overlayfs unraid-podman" + +if [ ! -f "$INSTALLED_VERSIONS_FILE" ]; then + podman_log_error "update-packages: $INSTALLED_VERSIONS_FILE missing — plugin install metadata not found" + exit 1 +fi +# shellcheck source=/dev/null +. "$INSTALLED_VERSIONS_FILE" + +if [ -z "${PLUGIN_VERSION:-}" ]; then + podman_log_error "update-packages: PLUGIN_VERSION not recorded in $INSTALLED_VERSIONS_FILE" + exit 1 +fi + +targets="${*:-$ALL_PACKAGES}" + +podman_require_command upgradepkg +podman_require_command installpkg + +updated=0 +already_current=0 +errors=0 + +for name in $targets; do + entity_prefix=$(echo "$name" | tr '[:lower:]-' '[:upper:]_') + expected_version_var="${entity_prefix}_INSTALLED_VERSION" + expected_version="${!expected_version_var:-}" + + if [ -z "$expected_version" ]; then + podman_log_error "update-packages: no expected version recorded for '$name', skipping" + errors=$((errors + 1)) + continue + fi + + installed_record=$(find /var/log/packages -maxdepth 1 -name "${name}-*" -print 2> /dev/null | head -n1) + installed_basename=$(basename "${installed_record:-__none__}") + + case "$installed_basename" in + "${name}-${expected_version}-"*) + podman_log "update-packages: $name already at expected version $expected_version" + already_current=$((already_current + 1)) + continue + ;; + esac + + # The package for the currently expected version was downloaded straight + # into its backup slot by podman.plg (grouped by PLUGIN_VERSION, since a + # rollback targets "this plugin release" — see podman-backup.sh's header + # comment for why that's also where we install FROM. + txz=$(find "$PODMAN_BACKUP_DIR/packages/$PLUGIN_VERSION" -maxdepth 1 -name "${name}-*.txz" 2> /dev/null | head -n1) + if [ -z "$txz" ]; then + podman_log_error "update-packages: no package file found for $name (expected version $expected_version) in $PODMAN_BACKUP_DIR/packages/$PLUGIN_VERSION/" + errors=$((errors + 1)) + continue + fi + + podman_log "update-packages: installing $name -> $expected_version ($txz)" + + # Snapshot config once, before the first actual package change — a + # version bump is exactly when a schema migration might be needed (see + # file header). Only do this once per run, not once per package. + if [ "$updated" -eq 0 ]; then + "$SCRIPT_DIR/podman-backup.sh" snapshot-config > /dev/null + fi + + if [ -n "$installed_record" ]; then + if upgradepkg --install-new "$txz"; then + updated=$((updated + 1)) + else + podman_log_error "update-packages: upgradepkg failed for $name ($txz)" + errors=$((errors + 1)) + fi + else + if installpkg "$txz"; then + updated=$((updated + 1)) + else + podman_log_error "update-packages: installpkg failed for $name ($txz)" + errors=$((errors + 1)) + fi + fi +done + +podman_log "update-packages: done (updated=$updated already-current=$already_current errors=$errors)" + +if [ "$errors" -gt 0 ]; then + podman_notify "Podman package update had errors" \ + "$errors package(s) failed to update — see $PODMAN_PLUGIN_LOG." \ + "alert" + exit 1 +fi + +if [ "$updated" -gt 0 ]; then + podman_notify "Podman packages updated" \ + "$updated package(s) updated. Run 'rc.podman restart' to apply." \ + "normal" +fi + +exit 0 diff --git a/plugin/sbin/podman-verify-packages.sh b/plugin/sbin/podman-verify-packages.sh new file mode 100755 index 0000000..009b16f --- /dev/null +++ b/plugin/sbin/podman-verify-packages.sh @@ -0,0 +1,127 @@ +#!/bin/bash +# ============================================================================= +# plugin/sbin/podman-verify-packages.sh +# +# "Pakete prüfen" — verifies the seven packages this plugin ships are +# actually installed, at the version the plugin expects, and that the +# backed-up .txz copies (see podman-backup.sh) haven't bit-rotted on disk. +# +# Three checks per package: +# 1. Installed: Slackware records every installed package under +# /var/log/packages/--- — its mere +# existence IS the install record (standard Slackware pkgtools +# convention, nothing custom here). +# 2. Expected version: compared against +# /usr/local/share/unraid-podman/installed-versions.env, written by +# plugin/podman.plg's postinstall step at install/update time. +# 3. Backup integrity: if a backed-up .txz exists for the installed +# version (see podman-backup.sh), its current SHA256 is re-checked +# against the .sha256 sidecar recorded at build time — catches flash +# storage corruption on the backup copy before it's needed for a +# rollback. +# +# Usage: +# podman-verify-packages.sh # human-readable report +# podman-verify-packages.sh --quiet # exit code only, minimal output +# +# Exit code: 0 if everything checks out, 1 if any package has a problem. +# ============================================================================= + +set -u + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=./podman-common.sh +. "$SCRIPT_DIR/podman-common.sh" + +INSTALLED_VERSIONS_FILE="/usr/local/share/unraid-podman/installed-versions.env" +PACKAGES="podman conmon crun netavark aardvark-dns passt fuse-overlayfs unraid-podman" + +QUIET=0 +[ "${1:-}" = "--quiet" ] && QUIET=1 + +report() { + [ "$QUIET" -eq 0 ] && echo "$1" +} + +if [ ! -f "$INSTALLED_VERSIONS_FILE" ]; then + podman_log_error "verify: $INSTALLED_VERSIONS_FILE missing — plugin install metadata not found" + exit 1 +fi +# shellcheck source=/dev/null +. "$INSTALLED_VERSIONS_FILE" + +if [ -z "${PLUGIN_VERSION:-}" ]; then + podman_log_error "verify: PLUGIN_VERSION not recorded in $INSTALLED_VERSIONS_FILE" + exit 1 +fi + +PROBLEMS=0 + +for name in $PACKAGES; do + entity_prefix=$(echo "$name" | tr '[:lower:]-' '[:upper:]_') + expected_version_var="${entity_prefix}_INSTALLED_VERSION" + expected_version="${!expected_version_var:-}" + + report "== $name ==" + + if [ -z "$expected_version" ]; then + report " expected version: UNKNOWN (not recorded in $INSTALLED_VERSIONS_FILE)" + PROBLEMS=$((PROBLEMS + 1)) + continue + fi + report " expected version: $expected_version" + + # --- Check 1: installed ----------------------------------------------- + installed_record=$(find /var/log/packages -maxdepth 1 -name "${name}-*" -print 2> /dev/null | head -n1) + if [ -z "$installed_record" ]; then + report " installed: NO" + podman_log_error "verify: $name is not installed (no /var/log/packages/$name-* record)" + PROBLEMS=$((PROBLEMS + 1)) + continue + fi + installed_basename=$(basename "$installed_record") + report " installed package: $installed_basename" + + # --- Check 2: version matches expectation ------------------------------ + case "$installed_basename" in + "${name}-${expected_version}-"*) + report " version match: OK" + ;; + *) + report " version match: MISMATCH (installed record does not match expected $expected_version)" + podman_log_error "verify: $name installed record '$installed_basename' does not match expected version $expected_version" + PROBLEMS=$((PROBLEMS + 1)) + ;; + esac + + # --- Check 3: backup artifact integrity, if present -------------------- + # Packages of all 7 components released together as one plugin version + # are grouped under a single PLUGIN_VERSION directory (not per-component + # version) — a rollback targets "go back to plugin release X", matching + # podman-backup.sh's restore-packages . + backup_dir="$PODMAN_BACKUP_DIR/packages/$PLUGIN_VERSION" + backup_txz=$(find "$backup_dir" -maxdepth 1 -name "${name}-*.txz" 2> /dev/null | head -n1) + if [ -n "$backup_txz" ] && [ -f "$backup_txz.sha256" ]; then + if ( cd "$(dirname "$backup_txz")" && sha256sum -c "$(basename "$backup_txz").sha256" > /dev/null 2>&1 ); then + report " backup integrity: OK ($backup_txz)" + else + report " backup integrity: CORRUPT ($backup_txz)" + podman_log_error "verify: backup artifact $backup_txz failed checksum verification" + PROBLEMS=$((PROBLEMS + 1)) + fi + else + report " backup integrity: no backup artifact on file (nothing to verify)" + fi +done + +report "" +if [ "$PROBLEMS" -gt 0 ]; then + report "$PROBLEMS problem(s) found." + podman_notify "Podman package verification found problems" \ + "$PROBLEMS issue(s) found — see $PODMAN_PLUGIN_LOG for details." \ + "warning" + exit 1 +fi + +report "All packages verified OK." +exit 0 diff --git a/scripts/build-packages.sh b/scripts/build-packages.sh new file mode 100755 index 0000000..830d56e --- /dev/null +++ b/scripts/build-packages.sh @@ -0,0 +1,103 @@ +#!/bin/bash +# ============================================================================= +# scripts/build-packages.sh +# +# Orchestrates building all seven Slackware .txz packages this plugin ships +# (podman, conmon, crun, netavark, aardvark-dns, passt, fuse-overlayfs), by +# running each package's .SlackBuild in turn. See +# docs/ARCHITECTURE.md section 5.2 (Build-Strategie). +# +# This script itself does not containerize anything — it assumes it is +# already running inside a Slackware-compatible build environment (see +# .github/workflows/build-packages.yml, which runs it inside a pinned +# Slackware Docker image). Running it on a non-Slackware host will likely +# still compile successfully for most components but the resulting .txz +# should not be trusted for an actual Unraid install — see the container +# image note in the CI workflow. +# +# Usage: +# scripts/build-packages.sh # build all packages +# scripts/build-packages.sh podman conmon # build only the named ones +# +# Output: $REPO_ROOT/dist/---.txz +# plus a .sha256 and .md5 sidecar file per package (see +# scripts/lib/slackbuild-common.sh, sb_make_package). +# Nothing under dist/ is committed to git — see .gitignore. +# ============================================================================= + +set -eu + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +PACKAGES_DIR="$REPO_ROOT/packages" +DIST_DIR="$REPO_ROOT/dist" + +# The full, ordered set of packages this project builds automatically. +# containers-common is intentionally excluded for now — see packages/README.md. +# unraid-podman (the plugin's own scaffolding, not an upstream component — +# see packages/unraid-podman/README.md) is built last since it's by far the +# fastest and has nothing useful to report on failure that earlier package +# failures wouldn't already explain. +ALL_PACKAGES=(conmon crun netavark aardvark-dns passt fuse-overlayfs podman unraid-podman) + +# Podman is listed second-to-last on purpose: among the seven upstream +# components it is the slowest build and the one most likely to fail on a +# dependency/tag mistake, so faster packages surface problems first during +# local iteration. unraid-podman is genuinely last since it packages this +# repo's own files and has no compile step at all. + +requested=("$@") +if [ "${#requested[@]}" -eq 0 ]; then + targets=("${ALL_PACKAGES[@]}") +else + targets=("${requested[@]}") +fi + +mkdir -p "$DIST_DIR" + +echo "==> Building packages: ${targets[*]}" +echo "==> Output directory: $DIST_DIR" +echo + +failed=() +built=() + +for name in "${targets[@]}"; do + slackbuild="$PACKAGES_DIR/$name/$name.SlackBuild" + + if [ ! -x "$slackbuild" ]; then + echo "!! No SlackBuild found/executable for '$name' ($slackbuild)" >&2 + failed+=("$name") + continue + fi + + echo "############################################################" + echo "## Building: $name" + echo "############################################################" + + # Run each SlackBuild with OUTPUT pointed at the shared dist/ directory, + # so every package's .txz ends up in one place regardless of the + # per-package TMP/PKG scratch dirs it uses internally. + if OUTPUT="$DIST_DIR" "$slackbuild"; then + built+=("$name") + else + echo "!! Build failed: $name" >&2 + failed+=("$name") + fi + echo +done + +echo "============================================================" +echo "Build summary" +echo "============================================================" +echo "Succeeded (${#built[@]}): ${built[*]:-none}" +echo "Failed (${#failed[@]}): ${failed[*]:-none}" + +if [ "${#failed[@]}" -gt 0 ]; then + echo + echo "!! One or more packages failed to build — see log above." >&2 + exit 1 +fi + +echo +echo "All packages built successfully. Artifacts in $DIST_DIR:" +ls -1 "$DIST_DIR" diff --git a/scripts/checksums.sh b/scripts/checksums.sh new file mode 100755 index 0000000..abbe758 --- /dev/null +++ b/scripts/checksums.sh @@ -0,0 +1,70 @@ +#!/bin/bash +# ============================================================================= +# scripts/checksums.sh +# +# Verifies and consolidates checksums for everything in dist/. Each package +# already gets its own .sha256 / .md5 sidecar file from +# sb_make_package (scripts/lib/slackbuild-common.sh) at build time — this +# script: +# 1. Re-verifies every .txz against its own sidecar checksum (defense in +# depth: catches disk corruption or a tampered artifact between the +# build job and the release job in CI). +# 2. Writes a single consolidated CHECKSUMS.sha256 manifest covering all +# built packages, suitable for attaching to a GitHub Release so users +# can verify the whole set with one `sha256sum -c CHECKSUMS.sha256`. +# +# Usage: +# scripts/checksums.sh [dist-dir] # defaults to /dist +# ============================================================================= + +set -eu + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +DIST_DIR="${1:-$REPO_ROOT/dist}" + +if [ ! -d "$DIST_DIR" ]; then + echo "!! No such directory: $DIST_DIR (nothing built yet?)" >&2 + exit 1 +fi + +shopt -s nullglob +txz_files=("$DIST_DIR"/*.txz) +shopt -u nullglob + +if [ "${#txz_files[@]}" -eq 0 ]; then + echo "!! No .txz files found in $DIST_DIR" >&2 + exit 1 +fi + +echo "==> Verifying per-package checksums" +verify_failed=0 +for f in "${txz_files[@]}"; do + base=$(basename "$f") + sidecar="$f.sha256" + if [ ! -f "$sidecar" ]; then + echo "!! Missing $sidecar for $base" >&2 + verify_failed=1 + continue + fi + if ( cd "$DIST_DIR" && sha256sum -c "$(basename "$sidecar")" > /dev/null 2>&1 ); then + echo "OK $base" + else + echo "FAIL $base" >&2 + verify_failed=1 + fi +done + +if [ "$verify_failed" -ne 0 ]; then + echo "!! Checksum verification failed for one or more packages." >&2 + exit 1 +fi + +echo +echo "==> Writing consolidated manifest: $DIST_DIR/CHECKSUMS.sha256" +( cd "$DIST_DIR" && sha256sum ./*.txz > CHECKSUMS.sha256 ) + +echo "==> Writing consolidated MD5 manifest: $DIST_DIR/CHECKSUMS.md5" +( cd "$DIST_DIR" && md5sum ./*.txz > CHECKSUMS.md5 ) + +echo "==> Done." +cat "$DIST_DIR/CHECKSUMS.sha256" diff --git a/scripts/ci/buildenv-versions.env b/scripts/ci/buildenv-versions.env new file mode 100644 index 0000000..7a3e961 --- /dev/null +++ b/scripts/ci/buildenv-versions.env @@ -0,0 +1,46 @@ +# ============================================================================= +# scripts/ci/buildenv-versions.env +# +# Version pins for the BUILD ENVIRONMENT itself — the toolchains and C +# libraries needed to compile the seven packages in packages/, but which are +# not themselves shipped as part of the plugin. Kept separate from the +# top-level versions.env, which pins only what actually gets packaged and +# installed on an Unraid system (see that file's header comment). +# +# Consumed by scripts/ci/setup-slackware-buildenv.sh. +# ============================================================================= + +# Go toolchain (builds podman). Official upstream tarball, not a distro +# package — Slackware ships no Go toolchain in a stock install. +GO_VERSION="1.26.5" +GO_SRC_URL="https://go.dev/dl/go${GO_VERSION}.linux-amd64.tar.gz" +# Checksum as published by go.dev itself (`curl -s https://go.dev/dl/?mode=json`) +# — must be re-derived from the same source whenever GO_VERSION changes. +GO_SRC_SHA256="88c162b204e6eefcc32499453b492e80209f4a4c78c33092636901c540fb0d05" + +# Rust toolchain (builds netavark, aardvark-dns) — installed via rustup +# rather than a pinned tarball, since rustup itself provides reproducible, +# checksummed component installation. We pin the *channel*, not an exact +# rustc build; per-crate reproducibility comes from each Rust package's +# Cargo.lock (built with `cargo build --locked`, see the netavark/ +# aardvark-dns SlackBuilds) rather than from the compiler version. +RUST_CHANNEL="stable" +RUSTUP_INIT_URL="https://sh.rustup.rs" + +# --- C library build-time dependencies --------------------------------------- +# These are expected to already be present in the Slackware base image (part +# of a stock "full" Slackware 15.0 install): glib2 (conmon), libcap (crun), +# fuse3 (fuse-overlayfs). setup-slackware-buildenv.sh fails fast with a clear +# message if any of these are missing, rather than silently vendoring them. +# +# libseccomp and yajl are NOT part of a stock Slackware install and are +# built from source by setup-slackware-buildenv.sh if pkg-config doesn't +# find them. + +LIBSECCOMP_VERSION="2.6.1" +LIBSECCOMP_SRC_URL="https://github.com/seccomp/libseccomp/archive/refs/tags/v${LIBSECCOMP_VERSION}.tar.gz" +LIBSECCOMP_SRC_SHA256="f9a13e4c633d319a9240189760ca348caa0837c0ebe2a09b17061da8ceaf60f0" + +YAJL_VERSION="2.1.0" +YAJL_SRC_URL="https://github.com/lloyd/yajl/archive/refs/tags/${YAJL_VERSION}.tar.gz" +YAJL_SRC_SHA256="3fb73364a5a30efe615046d07e6db9d09fd2b41c763c5f7d3bfb121cd5c5ac5a" diff --git a/scripts/ci/setup-slackware-buildenv.sh b/scripts/ci/setup-slackware-buildenv.sh new file mode 100755 index 0000000..f3da58c --- /dev/null +++ b/scripts/ci/setup-slackware-buildenv.sh @@ -0,0 +1,152 @@ +#!/bin/bash +# ============================================================================= +# scripts/ci/setup-slackware-buildenv.sh +# +# Prepares a Slackware container (see .github/workflows/build-packages.yml) +# to build all seven packages under packages/. Idempotent and safe to re-run. +# +# Strategy: detect what's already present (a stock "full" Slackware 15.0 +# install already provides gcc/make/autotools/glib2/libcap/fuse3) and only +# bootstrap what's genuinely missing (libseccomp, yajl — neither ships in +# stock Slackware — plus the Go and Rust toolchains, which no Slackware +# install ships). This makes the script tolerant of small differences +# between Slackware base image variants instead of assuming one exact image +# layout, while still failing loudly if something we cannot self-provision +# (a C compiler, basically) is missing. +# +# Exits non-zero with a clear message if a required tool cannot be found or +# provisioned — this script is meant to run early in CI so failures surface +# immediately, not halfway through a 20-minute podman build. +# ============================================================================= + +set -eu + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +# shellcheck source=/dev/null +. "$REPO_ROOT/scripts/ci/buildenv-versions.env" + +WORK="/tmp/unraid-podman-buildenv" +mkdir -p "$WORK" + +require_binary() { + local bin="$1" hint="$2" + if ! command -v "$bin" > /dev/null 2>&1; then + echo "!! Required tool '$bin' not found in the build image." >&2 + echo "!! $hint" >&2 + exit 1 + fi + echo "==> found $bin: $(command -v "$bin")" +} + +require_pkgconfig() { + local module="$1" hint="$2" + if ! pkg-config --exists "$module" 2>/dev/null; then + echo "!! Required library '$module' not found via pkg-config." >&2 + echo "!! $hint" >&2 + return 1 + fi + echo "==> found pkg-config module: $module ($(pkg-config --modversion "$module"))" + return 0 +} + +# ----------------------------------------------------------------------------- +# 1. Baseline toolchain expected to already be present in the base image. +# ----------------------------------------------------------------------------- +require_binary gcc "Use a Slackware base image with the 'D' (development) series installed." +require_binary make "Use a Slackware base image with the 'D' (development) series installed." +require_binary autoconf "Needed by crun/fuse-overlayfs; part of Slackware's 'D' series." +require_binary automake "Needed by crun/fuse-overlayfs; part of Slackware's 'D' series." +require_binary libtool "Needed by crun/fuse-overlayfs; part of Slackware's 'D' series." +require_binary pkg-config "Needed to locate C library dependencies." +require_binary git "Needed to fetch crun's git submodules." +require_binary curl "Needed to fetch pinned source tarballs." +require_binary makepkg "Slackware's own packaging tool (pkgtools); should always be present." +require_binary strip "Part of binutils; part of Slackware's 'D' series." + +# ----------------------------------------------------------------------------- +# 2. C library dependencies expected to already be present. +# ----------------------------------------------------------------------------- +require_pkgconfig glib-2.0 "Install Slackware's glib2 package (needed by conmon)." +require_pkgconfig libcap "Install Slackware's libcap package (needed by crun)." || true +require_pkgconfig fuse3 "Install Slackware's fuse3 package (needed by fuse-overlayfs)." || true + +# ----------------------------------------------------------------------------- +# 3. libseccomp — not part of stock Slackware, build from source if missing. +# ----------------------------------------------------------------------------- +if ! pkg-config --exists libseccomp 2>/dev/null; then + echo "==> libseccomp not found, building v$LIBSECCOMP_VERSION from source" + d="$WORK/libseccomp" + mkdir -p "$d" + curl -fL --retry 3 -o "$d/src.tar.gz" "$LIBSECCOMP_SRC_URL" + actual=$(sha256sum "$d/src.tar.gz" | awk '{print $1}') + [ "$actual" = "$LIBSECCOMP_SRC_SHA256" ] || { + echo "!! libseccomp checksum mismatch (expected $LIBSECCOMP_SRC_SHA256, got $actual)" >&2 + exit 1 + } + mkdir -p "$d/src" && tar -xf "$d/src.tar.gz" -C "$d/src" --strip-components=1 + ( cd "$d/src" && ./autogen.sh && ./configure --prefix=/usr && make -j"$(nproc)" && make install ) +else + echo "==> libseccomp already present, skipping bootstrap build" +fi + +# ----------------------------------------------------------------------------- +# 4. yajl — not part of stock Slackware, build from source if missing. +# ----------------------------------------------------------------------------- +if ! pkg-config --exists yajl 2>/dev/null; then + echo "==> yajl not found, building v$YAJL_VERSION from source" + d="$WORK/yajl" + mkdir -p "$d" + curl -fL --retry 3 -o "$d/src.tar.gz" "$YAJL_SRC_URL" + actual=$(sha256sum "$d/src.tar.gz" | awk '{print $1}') + [ "$actual" = "$YAJL_SRC_SHA256" ] || { + echo "!! yajl checksum mismatch (expected $YAJL_SRC_SHA256, got $actual)" >&2 + exit 1 + } + mkdir -p "$d/src" && tar -xf "$d/src.tar.gz" -C "$d/src" --strip-components=1 + # yajl uses its own cmake-free ./configure wrapper script. + ( cd "$d/src" && ./configure -p /usr && make -C build install ) + ldconfig 2>/dev/null || true +else + echo "==> yajl already present, skipping bootstrap build" +fi + +# ----------------------------------------------------------------------------- +# 5. Go toolchain (podman) — official upstream tarball. +# ----------------------------------------------------------------------------- +if ! command -v go > /dev/null 2>&1; then + echo "==> Go not found, installing $GO_VERSION" + curl -fL --retry 3 -o "$WORK/go.tar.gz" "$GO_SRC_URL" + actual=$(sha256sum "$WORK/go.tar.gz" | awk '{print $1}') + [ "$actual" = "$GO_SRC_SHA256" ] || { + echo "!! Go toolchain checksum mismatch (expected $GO_SRC_SHA256, got $actual)" >&2 + exit 1 + } + rm -rf /usr/local/go + tar -C /usr/local -xf "$WORK/go.tar.gz" + export PATH="/usr/local/go/bin:$PATH" + # Persist PATH for subsequent steps in the same GitHub Actions job. + if [ -n "${GITHUB_PATH:-}" ]; then + echo "/usr/local/go/bin" >> "$GITHUB_PATH" + fi +else + echo "==> Go already present: $(go version)" +fi + +# ----------------------------------------------------------------------------- +# 6. Rust toolchain (netavark, aardvark-dns) — via rustup. +# ----------------------------------------------------------------------------- +if ! command -v cargo > /dev/null 2>&1; then + echo "==> Rust/cargo not found, installing via rustup ($RUST_CHANNEL channel)" + curl -fL --retry 3 --proto '=https' --tlsv1.2 -sSf "$RUSTUP_INIT_URL" \ + | sh -s -- -y --default-toolchain "$RUST_CHANNEL" --profile minimal + # shellcheck source=/dev/null + . "$HOME/.cargo/env" + if [ -n "${GITHUB_PATH:-}" ]; then + echo "$HOME/.cargo/bin" >> "$GITHUB_PATH" + fi +else + echo "==> Rust already present: $(cargo --version)" +fi + +echo +echo "==> Build environment ready." diff --git a/scripts/dev/lint.sh b/scripts/dev/lint.sh new file mode 100755 index 0000000..0548d27 --- /dev/null +++ b/scripts/dev/lint.sh @@ -0,0 +1,34 @@ +#!/bin/bash +# ============================================================================= +# scripts/dev/lint.sh +# +# Local developer entry point mirroring .github/workflows/lint.yml: runs +# ShellCheck over shell scripts/SlackBuilds and xmllint over plugin/podman.plg. +# Requires `shellcheck` and `xmllint` (libxml2) to be installed locally. +# ============================================================================= + +set -eu + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +cd "$REPO_ROOT" + +status=0 + +if command -v shellcheck > /dev/null 2>&1; then + echo "==> ShellCheck" + find plugin/rc.d plugin/sbin scripts -type f \ + \( -name '*.sh' -o -name 'rc.*' -o -name '*.SlackBuild' \) \ + -print0 \ + | xargs -0 shellcheck --severity=warning --external-sources || status=1 +else + echo "!! shellcheck not installed, skipping (install it to match CI: https://www.shellcheck.net/)" >&2 +fi + +if command -v xmllint > /dev/null 2>&1; then + echo "==> xmllint (plugin/podman.plg)" + xmllint --noout plugin/podman.plg || status=1 +else + echo "!! xmllint not installed, skipping (part of libxml2-utils)" >&2 +fi + +exit "$status" diff --git a/scripts/lib/slackbuild-common.sh b/scripts/lib/slackbuild-common.sh new file mode 100755 index 0000000..505817b --- /dev/null +++ b/scripts/lib/slackbuild-common.sh @@ -0,0 +1,176 @@ +#!/bin/bash +# ============================================================================= +# scripts/lib/slackbuild-common.sh +# +# Shared helper functions sourced by every packages/*/*.SlackBuild script. +# Centralizing this logic means each individual SlackBuild only has to +# describe *how to compile* its component — fetching, checksum verification, +# and final .txz packaging are implemented once, here, and used the same way +# by all seven packages. This is what keeps the seven build scripts +# consistent and short instead of each reinventing (and potentially +# forgetting) checksum verification or Slackware package metadata. +# +# Every SlackBuild is expected to: +# 1. `. "$(dirname "$0")/../../scripts/lib/slackbuild-common.sh"` +# 2. `. "$(dirname "$0")/../../versions.env"` +# 3. Call sb_init, sb_fetch_and_verify, do its own compile steps into +# $PKG, then call sb_make_package. +# +# Not meant to be run directly. +# ============================================================================= + +set -eu + +# ----------------------------------------------------------------------------- +# sb_init +# +# Sets up the standard SlackBuild working directories and exports the +# variables every subsequent helper (and the calling SlackBuild) relies on: +# CWD - directory the SlackBuild itself lives in (packages//) +# TMP - scratch/build directory (source is extracted and compiled here) +# PKG - staging directory that becomes the .txz payload +# OUTPUT - where the finished .txz + checksum files are written +# All three of TMP/PKG/OUTPUT are safe to delete/recreate; nothing outside +# of them is ever touched, and nothing under them is ever committed to git +# (see .gitignore: packages/**/src, packages/**/pkg, packages/**/work). +# ----------------------------------------------------------------------------- +sb_init() { + local pkg_name="$1" + + PRGNAM="$pkg_name" + CWD="$(cd "$(dirname "${BASH_SOURCE[1]}")" && pwd)" + TMP="${TMP:-$CWD/work}" + PKG="${PKG:-$TMP/package-$PRGNAM}" + OUTPUT="${OUTPUT:-$CWD/../../dist}" + + export PRGNAM CWD TMP PKG OUTPUT + + rm -rf "$TMP" + mkdir -p "$TMP" "$PKG" "$OUTPUT" + + echo "==> [$PRGNAM] TMP=$TMP" + echo "==> [$PRGNAM] PKG=$PKG" + echo "==> [$PRGNAM] OUTPUT=$OUTPUT" +} + +# ----------------------------------------------------------------------------- +# sb_fetch_and_verify +# +# Downloads a source tarball into $TMP and verifies it against the SHA256 +# pinned in versions.env before anything is extracted or built. Aborts the +# build loudly on any mismatch — a checksum mismatch means either +# versions.env is stale (a legitimate new upstream release) or the source is +# not what it claims to be; either way, an unreviewed build must not proceed. +# ----------------------------------------------------------------------------- +sb_fetch_and_verify() { + local url="$1" + local expected_sha256="$2" + local dest_name="$3" + local dest_path="$TMP/$dest_name" + + echo "==> [$PRGNAM] Fetching $url" + curl -fL --retry 3 --retry-delay 2 -o "$dest_path" "$url" + + local actual_sha256 + actual_sha256=$(sha256sum "$dest_path" | awk '{print $1}') + + if [ "$actual_sha256" != "$expected_sha256" ]; then + echo "!! [$PRGNAM] SHA256 MISMATCH for $dest_name" >&2 + echo "!! expected: $expected_sha256" >&2 + echo "!! actual: $actual_sha256" >&2 + echo "!! Refusing to build against unverified source. If upstream" >&2 + echo "!! genuinely released a new version, update versions.env via" >&2 + echo "!! scripts/update-versions.sh instead of editing the hash by hand." >&2 + exit 1 + fi + + echo "==> [$PRGNAM] SHA256 verified ($actual_sha256)" + echo "$dest_path" +} + +# ----------------------------------------------------------------------------- +# sb_extract [strip-components] +# +# Extracts a verified tarball into $TMP/src, normalizing away the +# "-/" wrapper directory GitHub/cgit archives always contain, +# so every SlackBuild can `cd "$TMP/src"` regardless of upstream's archive +# layout. +# ----------------------------------------------------------------------------- +sb_extract() { + local tarball="$1" + local strip="${2:-1}" + + mkdir -p "$TMP/src" + tar -xf "$tarball" -C "$TMP/src" --strip-components="$strip" + echo "$TMP/src" +} + +# ----------------------------------------------------------------------------- +# sb_install_docs [readme-file] +# +# Installs upstream's license (and optionally README) into the standard +# Slackware package documentation location, plus this project's own build +# metadata, so `installpkg` output and /usr/doc/-/ are +# populated per Slackware convention. +# ----------------------------------------------------------------------------- +sb_install_docs() { + local license_file="$1" + local readme_file="${2:-}" + local docdir="$PKG/usr/doc/$PRGNAM-$VERSION" + + mkdir -p "$docdir" + [ -f "$license_file" ] && cp "$license_file" "$docdir/" + [ -n "$readme_file" ] && [ -f "$readme_file" ] && cp "$readme_file" "$docdir/" + + { + echo "Built by unraid-podman from upstream source." + echo "Package: $PRGNAM $VERSION" + echo "Built: $(date -u +%Y-%m-%dT%H:%M:%SZ)" + } > "$docdir/unraid-podman.build-info" +} + +# ----------------------------------------------------------------------------- +# sb_make_package +# +# Finalizes the Slackware package: installs slack-desc + doinst.sh (if +# present) into $PKG/install/, strips binaries, sets root:root ownership, +# runs makepkg to produce the .txz, then writes SHA256 + MD5 sidecar files +# next to it. +# +# - SHA256 is used by scripts/build-packages.sh / CI to verify build +# artifacts between jobs. +# - MD5 is additionally generated because Unraid's .plg mechanism +# verifies downloads via MD5 by convention (see +# docs/ARCHITECTURE.md section 3.2) — scripts/release.sh reads the .md5 +# file to populate plugin/podman.plg's entities. +# +# Produces: $OUTPUT/---.txz (+ .sha256, .md5) +# ----------------------------------------------------------------------------- +sb_make_package() { + local version="$1" + local arch="$2" + local build="$3" + local tag="$4" + + mkdir -p "$PKG/install" + if [ -f "$CWD/slack-desc" ]; then + cp "$CWD/slack-desc" "$PKG/install/slack-desc" + else + echo "!! [$PRGNAM] missing packages/$PRGNAM/slack-desc" >&2 + exit 1 + fi + [ -f "$CWD/doinst.sh" ] && cp "$CWD/doinst.sh" "$PKG/install/doinst.sh" + + # Strip debug symbols where possible to keep package size down; ignore + # failures (e.g. static/stripped-already binaries, non-ELF files). + find "$PKG" -type f \( -perm -u+x -o -name '*.so*' \) -exec sh -c \ + 'file "$1" | grep -q ELF && strip --strip-unneeded "$1" 2>/dev/null || true' _ {} \; + + local pkg_file="$PRGNAM-$version-$arch-$build$tag.txz" + ( cd "$PKG" && makepkg --linkadd y --chown y "$OUTPUT/$pkg_file" ) + + ( cd "$OUTPUT" && sha256sum "$pkg_file" > "$pkg_file.sha256" ) + ( cd "$OUTPUT" && md5sum "$pkg_file" > "$pkg_file.md5" ) + + echo "==> [$PRGNAM] built $OUTPUT/$pkg_file" +} diff --git a/scripts/release.sh b/scripts/release.sh new file mode 100755 index 0000000..f69fd92 --- /dev/null +++ b/scripts/release.sh @@ -0,0 +1,146 @@ +#!/bin/bash +# ============================================================================= +# scripts/release.sh +# +# Cuts a release of the plugin itself: +# 1. Bumps the &version; entity in plugin/podman.plg to . +# 2. Builds all seven packages (scripts/build-packages.sh) unless +# SKIP_BUILD=1 is set (useful when CI already built them in a prior job +# and only wants this script to do the .plg/CHANGELOG bookkeeping). +# 3. Verifies + consolidates checksums (scripts/checksums.sh). +# 4. Rewrites the per-package +# entities in plugin/podman.plg from the freshly built dist/*.txz.md5 +# sidecar files, and the &baseURL; entity to point at the GitHub +# Release that will hold these assets. +# 5. Moves CHANGELOG.md's [Unreleased] section under a new dated heading. +# +# This script deliberately does NOT `git commit`, `git tag`, or `gh release +# create` — it only prepares files. Committing/tagging/publishing is left to +# the caller (locally) or to .github/workflows/release.yml (in CI), so a +# human always reviews the diff before anything becomes public. See +# docs/ARCHITECTURE.md section 13 (Updates). +# +# Usage: +# scripts/release.sh # e.g. scripts/release.sh 0.2.0 +# +# Env vars: +# SKIP_BUILD=1 Skip scripts/build-packages.sh (assume dist/ already built) +# ============================================================================= + +set -eu + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +PLG_FILE="$REPO_ROOT/plugin/podman.plg" +CHANGELOG_FILE="$REPO_ROOT/CHANGELOG.md" +DIST_DIR="$REPO_ROOT/dist" + +NEW_VERSION="${1:-}" +if [ -z "$NEW_VERSION" ]; then + echo "usage: $0 " >&2 + exit 1 +fi + +if ! [[ "$NEW_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "!! must be plain SemVer (X.Y.Z), got: $NEW_VERSION" >&2 + exit 1 +fi + +# The GitHub Release tag/URL this release's assets will be published under. +# Must match whatever .github/workflows/release.yml actually creates the +# release as (tag "v") — see that workflow for the release job. +RELEASE_TAG="v$NEW_VERSION" +REPO_SLUG="${GITHUB_REPOSITORY:-OWNER/unraid-podman}" +RELEASE_BASE_URL="https://github.com/$REPO_SLUG/releases/download/$RELEASE_TAG" + +# Component name -> the entity name prefix used in podman.plg. Must match +# plugin/podman.plg's declarations exactly. +# unraid-podman is the plugin's own scaffolding package (see +# packages/unraid-podman/README.md), not an upstream component, but it's +# released and entity-updated exactly like the other seven. +COMPONENTS=(podman conmon crun netavark aardvark-dns passt fuse-overlayfs unraid-podman) + +echo "==> Releasing unraid-podman plugin v$NEW_VERSION (packages tag: $RELEASE_TAG)" + +# --- 1. Build ---------------------------------------------------------------- +if [ "${SKIP_BUILD:-0}" != "1" ]; then + echo "==> Building all packages" + "$REPO_ROOT/scripts/build-packages.sh" +else + echo "==> SKIP_BUILD=1, assuming $DIST_DIR is already populated" +fi + +"$REPO_ROOT/scripts/checksums.sh" "$DIST_DIR" + +# --- 2. Update plugin version entity ------------------------------------------ +echo "==> Setting to $NEW_VERSION in $PLG_FILE" +sed -i -E "s|()|\1${NEW_VERSION}\2|" "$PLG_FILE" +sed -i -E "s|()|\1${RELEASE_BASE_URL}\2|" "$PLG_FILE" + +# --- 3. Update per-package entities from dist/*.txz.md5 ----------------------- +for name in "${COMPONENTS[@]}"; do + # dist/ contains files like podman-6.0.1-x86_64-1_unraidpodman.txz — find + # the one for this component (there should be exactly one per release). + txz_path=$(find "$DIST_DIR" -maxdepth 1 -name "${name}-*-*-*.txz" | head -n1) + if [ -z "$txz_path" ]; then + echo "!! No built .txz found for component '$name' in $DIST_DIR" >&2 + echo "!! Did scripts/build-packages.sh run successfully for it?" >&2 + exit 1 + fi + + txz_file=$(basename "$txz_path") + md5_path="$txz_path.md5" + if [ ! -f "$md5_path" ]; then + echo "!! Missing $md5_path" >&2 + exit 1 + fi + md5=$(awk '{print $1}' "$md5_path") + + # Component version is everything between "-" and the next "--" + # e.g. "podman-6.0.1-x86_64-1_unraidpodman.txz" -> "6.0.1" + txz_version=$(echo "$txz_file" | sed -E "s/^${name}-(.+)-[^-]+-[0-9]+[^-]*\.txz\$/\1/") + + entity_prefix=$(echo "$name" | tr '-' '_') + + echo "==> [$name] $txz_file (md5=$md5)" + sed -i -E "s|()|\1${txz_version}\2|" "$PLG_FILE" + sed -i -E "s|()|\1${txz_file}\2|" "$PLG_FILE" + sed -i -E "s|()|\1${md5}\2|" "$PLG_FILE" +done + +# --- 4. Update CHANGELOG.md --------------------------------------------------- +# Pure awk (no python/perl dependency — this script also has to run inside +# the minimal Slackware build container, see .github/workflows/release.yml): +# inserts a fresh "## [] - " heading right after the existing +# "## [Unreleased]" marker, leaving [Unreleased] itself empty and at the top +# for the next round of changes. +echo "==> Moving CHANGELOG.md [Unreleased] section under [$NEW_VERSION]" +TODAY=$(date -u +%Y-%m-%d) + +if ! grep -q '^## \[Unreleased\]$' "$CHANGELOG_FILE"; then + echo "!! No '## [Unreleased]' heading found in $CHANGELOG_FILE" >&2 + exit 1 +fi + +awk -v ver="$NEW_VERSION" -v date="$TODAY" ' + !done && $0 == "## [Unreleased]" { + print $0 + print "" + print "## [" ver "] - " date + done = 1 + next + } + { print $0 } +' "$CHANGELOG_FILE" > "$CHANGELOG_FILE.tmp" +mv "$CHANGELOG_FILE.tmp" "$CHANGELOG_FILE" + +echo +echo "==> Release preparation complete for v$NEW_VERSION." +echo "==> Review the diff, then:" +echo " git add plugin/podman.plg CHANGELOG.md" +echo " git commit -m \"release: v$NEW_VERSION\"" +echo " git tag $RELEASE_TAG" +echo " git push && git push origin $RELEASE_TAG" +echo "==> Pushing the tag triggers .github/workflows/release.yml, which" +echo "==> rebuilds artifacts in CI (for reproducibility/provenance) and" +echo "==> publishes the GitHub Release with dist/*.txz + CHECKSUMS.* + the" +echo "==> updated podman.plg attached." diff --git a/scripts/update-versions.sh b/scripts/update-versions.sh new file mode 100755 index 0000000..69618b2 --- /dev/null +++ b/scripts/update-versions.sh @@ -0,0 +1,151 @@ +#!/bin/bash +# ============================================================================= +# scripts/update-versions.sh +# +# Re-pins one (or all) upstream components in versions.env to their current +# latest release, recomputing the SHA256 checksum against the freshly +# downloaded source tarball. This is the ONLY supported way to change a +# version/checksum pair in versions.env — never hand-edit a checksum, since +# that defeats the entire point of pinning it (see the header comment in +# versions.env). +# +# Usage: +# scripts/update-versions.sh # check/update all components +# scripts/update-versions.sh podman crun # only these components +# +# This script only rewrites versions.env. It does not build anything, and it +# does not commit — review the diff (`git diff versions.env`) before +# committing, ideally by also running a build to confirm the new source +# still compiles (scripts/build-packages.sh ). +# ============================================================================= + +set -eu + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +VERSIONS_FILE="$REPO_ROOT/versions.env" + +# Maps our internal component name -> GitHub "owner/repo", for every +# component that actually has GitHub-tagged releases. passt is handled +# separately below (see versions.env for why). +declare -A GITHUB_REPO=( + [podman]="containers/podman" + [conmon]="containers/conmon" + [crun]="containers/crun" + [netavark]="containers/netavark" + [aardvark-dns]="containers/aardvark-dns" + [fuse-overlayfs]="containers/fuse-overlayfs" +) + +# Maps our internal component name -> the *_VERSION variable prefix used in +# versions.env (uppercased, hyphens -> underscores). +env_prefix() { + echo "$1" | tr '[:lower:]-' '[:upper:]_' +} + +update_github_component() { + local name="$1" + local repo="${GITHUB_REPO[$name]}" + local prefix + prefix=$(env_prefix "$name") + + echo "==> [$name] checking latest release for $repo" + local api_response + api_response=$(curl -sL --max-time 15 "https://api.github.com/repos/$repo/releases/latest") + + local tag + tag=$(echo "$api_response" | grep -m1 '"tag_name"' | sed -E 's/.*"tag_name":[[:space:]]*"([^"]+)".*/\1/') + + if [ -z "$tag" ]; then + echo "!! [$name] could not determine latest tag (rate-limited or repo has no releases?)" >&2 + return 1 + fi + + # Strip a leading "v" for the version we store, but keep it for the URL + # since GitHub tags for these projects are inconsistent about it (crun + # tags plain "1.28", others tag "v1.28"). + local version="${tag#v}" + local url="https://github.com/$repo/archive/refs/tags/$tag.tar.gz" + + echo "==> [$name] latest = $version, downloading to verify + checksum" + local tmpfile + tmpfile=$(mktemp) + curl -fL --max-time 120 -o "$tmpfile" "$url" + local sha256 + sha256=$(sha256sum "$tmpfile" | awk '{print $1}') + rm -f "$tmpfile" + + echo "==> [$name] sha256=$sha256" + apply_update "$prefix" "$version" "$url" "$sha256" +} + +apply_update() { + local prefix="$1" version="$2" url="$3" sha256="$4" + + # In-place rewrite of the three lines for this component. Using distinct + # sed expressions per variable (rather than one blanket substitution) + # keeps this safe even if variable order in versions.env changes. + sed -i \ + -e "s|^${prefix}_VERSION=.*|${prefix}_VERSION=\"${version}\"|" \ + -e "s|^${prefix}_SRC_SHA256=.*|${prefix}_SRC_SHA256=\"${sha256}\"|" \ + "$VERSIONS_FILE" + + # The *_SRC_URL line is templated against *_VERSION (e.g. + # ".../v${PODMAN_VERSION}.tar.gz") in most cases, so it doesn't need + # rewriting — only touch it if it isn't already parameterized. + if ! grep -q "^${prefix}_SRC_URL=.*\${${prefix}_VERSION}" "$VERSIONS_FILE" \ + && ! grep -q "^${prefix}_SRC_URL=.*\$${prefix}_VERSION" "$VERSIONS_FILE"; then + sed -i -e "s|^${prefix}_SRC_URL=.*|${prefix}_SRC_URL=\"${url}\"|" "$VERSIONS_FILE" + fi + + echo "==> updated ${prefix}_VERSION / ${prefix}_SRC_SHA256 in $VERSIONS_FILE" +} + +update_passt() { + echo "==> [passt] checking latest master commit at https://passt.top/passt/" + local atom + atom=$(curl -sL --max-time 15 "https://passt.top/passt/atom/?h=master") + local commit + commit=$(echo "$atom" | grep -m1 -oE '[a-f0-9]{40}' | sed -E 's/<\/?id>//g') + + if [ -z "$commit" ]; then + echo "!! [passt] could not determine latest commit" >&2 + return 1 + fi + + local url="https://passt.top/passt/snapshot/passt-${commit}.tar.gz" + echo "==> [passt] latest commit = $commit, downloading to verify + checksum" + local tmpfile + tmpfile=$(mktemp) + curl -fL --max-time 120 -o "$tmpfile" "$url" + local sha256 + sha256=$(sha256sum "$tmpfile" | awk '{print $1}') + rm -f "$tmpfile" + + sed -i \ + -e "s|^PASST_COMMIT=.*|PASST_COMMIT=\"${commit}\"|" \ + -e "s|^PASST_VERSION=.*|PASST_VERSION=\"git${commit:0:7}\"|" \ + -e "s|^PASST_SRC_SHA256=.*|PASST_SRC_SHA256=\"${sha256}\"|" \ + "$VERSIONS_FILE" + + echo "==> updated PASST_COMMIT / PASST_VERSION / PASST_SRC_SHA256 in $VERSIONS_FILE" +} + +requested=("$@") +if [ "${#requested[@]}" -eq 0 ]; then + requested=("${!GITHUB_REPO[@]}" passt) +fi + +for name in "${requested[@]}"; do + if [ "$name" = "passt" ]; then + update_passt + elif [ -n "${GITHUB_REPO[$name]:-}" ]; then + update_github_component "$name" + else + echo "!! Unknown component: $name" >&2 + exit 1 + fi +done + +echo +echo "==> Done. Review the diff before committing:" +echo " git -C \"$REPO_ROOT\" diff versions.env" diff --git a/scripts/version-bump.sh b/scripts/version-bump.sh new file mode 100755 index 0000000..11e22b7 --- /dev/null +++ b/scripts/version-bump.sh @@ -0,0 +1,21 @@ +#!/bin/bash +# +# version-bump.sh — bumps the plugin version entity in plugin/podman.plg +# without performing a full release (useful for pre-release testing builds). +# +# Usage (planned): scripts/version-bump.sh +# +# STATUS: placeholder skeleton, not yet functional. + +set -eu + +NEW_VERSION="${1:-}" +if [ -z "$NEW_VERSION" ]; then + echo "usage: $0 " >&2 + exit 1 +fi + +# TODO: update in plugin/podman.plg + +echo "TODO: implement version-bump.sh for version $NEW_VERSION" +exit 1 diff --git a/versions.env b/versions.env new file mode 100644 index 0000000..862c000 --- /dev/null +++ b/versions.env @@ -0,0 +1,86 @@ +# ============================================================================= +# versions.env — single source of truth for upstream component versions. +# +# Every SlackBuild under packages/*/ and the orchestrator +# (scripts/build-packages.sh) source this file instead of hardcoding a +# version or URL. This is what makes the build reproducible: given the same +# versions.env, the same source tarballs (verified by SHA256) are fetched and +# built, every time. +# +# To bump a component's version, run scripts/update-versions.sh +# (fetches the new upstream release, recomputes the checksum, rewrites the +# corresponding block below) rather than editing hashes by hand. +# +# SHA256 sums below were computed directly against the upstream source +# tarball/snapshot at the time of pinning (see the fetch command in each +# comment). GitHub's auto-generated "archive/refs/tags" tarballs are stable +# in practice but are NOT cryptographically signed by upstream — treat this +# checksum as tamper-evidence against a compromised mirror/CDN, not as a +# replacement for verifying upstream's own release signing where available. +# ============================================================================= + +# --- podman ------------------------------------------------------------------ +# https://github.com/containers/podman +PODMAN_VERSION="6.0.1" +PODMAN_SRC_URL="https://github.com/containers/podman/archive/refs/tags/v${PODMAN_VERSION}.tar.gz" +PODMAN_SRC_SHA256="4829d7c1423523a6a4d5537dea7968ae7f6c22ed7f1d5f416638fd81c83caa47" + +# --- conmon -------------------------------------------------------------- +# https://github.com/containers/conmon +CONMON_VERSION="2.2.1" +CONMON_SRC_URL="https://github.com/containers/conmon/archive/refs/tags/v${CONMON_VERSION}.tar.gz" +CONMON_SRC_SHA256="814fb5979a3a4b8576b1f901e606b482bebb41cb7e57926e6d5765ee786b96d3" + +# --- crun ---------------------------------------------------------------- +# https://github.com/containers/crun +CRUN_VERSION="1.28" +CRUN_SRC_URL="https://github.com/containers/crun/archive/refs/tags/${CRUN_VERSION}.tar.gz" +CRUN_SRC_SHA256="90284c7f097f8ee72a6447978c263e1b1355727c2f2ca0ac667e6d57788f46f5" + +# --- netavark -------------------------------------------------------------- +# https://github.com/containers/netavark +NETAVARK_VERSION="2.0.0" +NETAVARK_SRC_URL="https://github.com/containers/netavark/archive/refs/tags/v${NETAVARK_VERSION}.tar.gz" +NETAVARK_SRC_SHA256="031aeeacc930382e8635d40a885798eff1da164dfcf9024b698f822e5995d9c8" + +# --- aardvark-dns ---------------------------------------------------------- +# https://github.com/containers/aardvark-dns +AARDVARK_DNS_VERSION="2.0.0" +AARDVARK_DNS_SRC_URL="https://github.com/containers/aardvark-dns/archive/refs/tags/v${AARDVARK_DNS_VERSION}.tar.gz" +AARDVARK_DNS_SRC_SHA256="d3f5d6b3be3c2d80e8257fb9467e34ff104f299474427979454034dca6dc88cc" + +# --- fuse-overlayfs -------------------------------------------------------- +# https://github.com/containers/fuse-overlayfs +FUSE_OVERLAYFS_VERSION="1.17" +FUSE_OVERLAYFS_SRC_URL="https://github.com/containers/fuse-overlayfs/archive/refs/tags/v${FUSE_OVERLAYFS_VERSION}.tar.gz" +FUSE_OVERLAYFS_SRC_SHA256="cefffecfbb001b2784f19af344f27eae07b31a4faa38d345b738af96b2bec59e" + +# --- passt ------------------------------------------------------------------- +# https://passt.top/passt/about/ — "Plug A Simple Socket Transport". Podman's +# modern (post-slirp4netns) rootless network transport. Upstream has NO +# GitHub mirror and no semver tags; it is released continuously from the +# cgit-hosted git repository at https://passt.top/passt/, identified by full +# git commit hash. We pin to a specific commit snapshot for reproducibility, +# fetched via cgit's snapshot endpoint: +# https://passt.top/passt/snapshot/passt-.tar.gz +PASST_COMMIT="6ef3d1c86ffc690a17a9a4445df4a741446bcd44" +PASST_VERSION="git${PASST_COMMIT:0:7}" +PASST_SRC_URL="https://passt.top/passt/snapshot/passt-${PASST_COMMIT}.tar.gz" +PASST_SRC_SHA256="4c58a77504a77d613464dddf22ae69d749a5ba64cb87e44c3b8c252333e209fc" + +# ============================================================================= +# Slackware package BUILD number (not upstream version). Bump this if a +# package must be rebuilt without an upstream version change (e.g. a +# packaging-only fix). Reset to 1 whenever *_VERSION above changes. +# ============================================================================= +PKG_BUILD="1" + +# Slackware package architecture. Unraid is x86_64-only today; kept as a +# variable rather than hardcoded so the build scripts don't need a second +# source of truth if that ever changes. +PKG_ARCH="x86_64" + +# Suffix appended to every package's tag field (Slackware convention: +# ---.txz), identifies packages built by +# this project as opposed to a stock Slackware/SBo package of the same name. +PKG_TAG="_unraidpodman" diff --git a/webui/README.md b/webui/README.md new file mode 100644 index 0000000..22dcc0a --- /dev/null +++ b/webui/README.md @@ -0,0 +1,72 @@ +# webui/ + +Dynamix-style WebUI pages, following Unraid's plugin GUI convention of +`/usr/local/emhttp/plugins//`. `webui/plugins/podman/` is staged to +that path by the `unraid-podman` scaffolding package (see +`packages/unraid-podman/unraid-podman.SlackBuild`), which podman.plg installs +alongside the seven compiled components. + +**Status: implemented**, covering all ten sections from +[docs/ARCHITECTURE.md, section 18](../docs/ARCHITECTURE.md#18-zukünftige-webui): +Dashboard, Containers, Pods, Images, Volumes, Networks, Logs, Terminal, +Compose, Settings. Not yet exercised against a real Unraid/Podman install — +see [docs/ROADMAP.md](../docs/ROADMAP.md) for what "implemented" does and +doesn't cover yet. + +`webui/mockups/prototype.html` is the static, non-PHP clickable mockup this +implementation was built against — kept as the visual reference; it is not +staged into the package. + +## Structure + +``` +webui/ +├── mockups/ +│ └── prototype.html # static approved mockup, not shipped +└── plugins/ + └── podman/ + ├── Podman.page # page shell: header, sub-nav, one container per panel + ├── include/ + │ ├── PodmanClient.php # libpod REST API client (talks to podman.sock only) + │ ├── Config.php # reads podman.cfg (mirrors podman-common.sh) + │ ├── bootstrap.php # shared include + error handling for ajax/*.php + │ └── helpers.php # formatting + JSON-response helpers + ├── ajax/ # one endpoint per resource, each require()s bootstrap.php + │ ├── containers.php # list/start/stop/restart/remove/logs + │ ├── pods.php + │ ├── images.php + │ ├── volumes.php + │ ├── networks.php + │ ├── exec.php # Terminal — see its header comment for API scope + │ ├── compose.php # Compose — the one deliberate CLI exception, see header + │ ├── settings.php # plugin's own config, not a libpod resource + │ └── system.php # Dashboard aggregation + ├── javascript/ + │ ├── app.js # shared AJAX helper + sub-tab router + │ └── .js # one module per panel, registers with app.js + ├── styles/podman.css # design tokens ported 1:1 from the mockup + ├── event/ # official Unraid array-event hooks (see plugin/event/) + └── images/ # plugin icon assets +``` + +## Design constraints (see ARCHITECTURE.md for full rationale) + +- Every panel talks to `podman system service` over its Unix socket via + `PodmanClient` — no `exec()`/`shell_exec()` of the `podman` binary + anywhere in `include/` or in the Containers/Pods/Images/Volumes/Networks/ + Logs endpoints. +- **Two documented, deliberate exceptions**, not oversights: + - `ajax/exec.php` (Terminal) uses the real exec REST API, but as + one-command-in/output-out rather than a true interactive PTY — libpod's + interactive exec needs a persistent hijacked connection that doesn't + fit PHP-FPM's request lifecycle. See that file's header comment. + - `ajax/compose.php` (Compose) shells out to the `podman compose` CLI via + `proc_open()` with an argv array (never a shell string) — because no + REST endpoint for Compose exists in libpod at all. See that file's + header comment. +- The API socket is root-equivalent; no unauthenticated network exposure + beyond what Unraid's own WebUI auth already provides — see + [.github/SECURITY.md](../.github/SECURITY.md). +- Dark/light mode via CSS custom properties (`prefers-color-scheme` + + `[data-theme]` override), matching Unraid's own theme mechanism — no + separate theme toggle inside the plugin page. diff --git a/webui/mockups/prototype.html b/webui/mockups/prototype.html new file mode 100644 index 0000000..6af3652 --- /dev/null +++ b/webui/mockups/prototype.html @@ -0,0 +1,949 @@ +unraid-podman — WebUI Mockup + + + +
+
U unraid
+
TOWER · 14d 6h uptime · Array Started
+
+
ⓘ Updates🔔👤 root
+
+ + +
+
+ +
+

Podman

+
podman.sock connected  ·  5 of 8 containers running  ·  v6.0.1
+
+
+
+ + +
+
+ + + +
+ + +
+
+
Running
5 / 8
+
Pods
2
+
Images
14
+
Volumes
9
+
Networks
3
+
+
Storage Used
+
62.4 / 100 GB
+
+
+
+ +
+
+

Resource Usage

last 60 min
+
+ + + + + + + + + + + + + + + + + +
+ CPU · 24% + Memory · 6.1 / 16 GB +
+
+
+ +
+

Recent Activity

+
    +
  • sonarr started
    2 min ago
  • +
  • postgres health check failed
    11 min ago
  • +
  • Pulled radarr:latest (image updated)
    38 min ago
  • +
  • Autostart chain completed (7/8)
    6h ago · array start
  • +
  • watchtower paused (autostart Safe-Mode)
    6h ago
  • +
  • Network media-net created
    2d ago
  • +
+
+
+ +
+

Autostart Queue

/boot/config/plugins/podman/autostart
+
+ + + + + + + + + +
#ContainerDelayLast Result
1postgresstarted
2nextcloud5sstarted
3sonarrstarted
4radarrstarted
5watchtowerSafe-Mode (3 failures)
+
+
+
+ + +
+
+
+ +
+ +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
StatusNameImageCPUMemoryPortsUptime
running
Pxplex
lscr.io/linuxserver/plex:latest18%
1.6G
32400/tcp14d 6h
running
Sosonarr
lscr.io/linuxserver/sonarr:4.03%
420M
8989/tcp6h 12m
running
Raradarr update
lscr.io/linuxserver/radarr:5.22%
380M
7878/tcp6h 12m
unhealthy
Pgpostgres
postgres:16-alpine1%
210M
5432/tcp2d 1h
running
Ncnextcloud
nextcloud:29-apache6%
890M
443/tcp, 80/tcp6h 12m
exited
Prprowlarr
lscr.io/linuxserver/prowlarr:19696/tcp
Safe-Mode
Wtwatchtower
containrrr/watchtower:latest
running
Hahomeassistant
ghcr.io/home-assistant/home-assistant4%
610M
8123/tcp3d 4h
+
+
+
+ + +
+
+
+ running + media-stack + infra: localhost/podman-pause · shared network namespace +
+
+ + + + + + + +
ContainerImageStatusPorts
sonarrlinuxserver/sonarr:4.0running8989/tcp
radarrlinuxserver/radarr:5.2running7878/tcp
prowlarrlinuxserver/prowlarr:1exited9696/tcp
+
+
+
+
+ running + home-stack + infra: localhost/podman-pause · shared network namespace +
+
+ + + + + + +
ContainerImageStatusPorts
homeassistantghcr.io/home-assistant/home-assistantrunning8123/tcp
mosquittoeclipse-mosquitto:2running1883/tcp
+
+
+
+ + +
+
+
+ + + +
+
+ + + + + + + + + + + + + +
RepositoryTagImage IDSizeCreatedUsed By
lscr.io/linuxserver/plexlatesta3f9c2e1b7d41.42 GB3d ago1
lscr.io/linuxserver/sonarr4.07e21ac9f0b12612 MB6h ago1
lscr.io/linuxserver/radarr update5.244b6f1d8ce90598 MB38m ago1
postgres16-alpinec1a08e4477fd243 MB9d ago1
nextcloud29-apache9d3c72b1a5e6890 MB12d ago1
containrrr/watchtowerlatest0f5b9a2d7c3128 MB40d ago1
ghcr.io/home-assistant/home-assistant2026.7b7e4f0a19c221.1 GB3d ago1
eclipse-mosquitto22a6d0e5f8b4114 MB40d ago1
lscr.io/linuxserver/prowlarr1d8f231b9a706340 MB11d ago0
+
+
+
+ + +
+
+
+ + +
+
+ + + + + + + + + +
NameDriverMountpointSizeUsed By
postgres-datalocal…/storage/volumes/postgres-data/_data1.8 GB1
nextcloud-datalocal…/storage/volumes/nextcloud-data/_data14.2 GB1
ha-configlocal…/storage/volumes/ha-config/_data340 MB1
mosquitto-datalocal…/storage/volumes/mosquitto-data/_data4 MB1
unused-cachelocal…/storage/volumes/unused-cache/_data610 MB0
+
+
+ App data mapped from Unraid shares (e.g. /mnt/user/appdata/…) is managed as bind mounts per-container, not listed here — see each container's edit dialog. +
+
+
+ + +
+
+
+ + +
+
+ + + + + + + + + + +
NameDriverSubnetGatewayContainers
podman0 (default)bridge10.89.0.0/2410.89.0.13
media-netbridge10.89.1.0/2410.89.1.13
iot-macvlanmacvlan192.168.10.0/24192.168.10.11
+
+
+ note + macvlan networks can trigger a known kernel issue with some NIC drivers on Unraid ("macvlan call trap"). See the docs before attaching containers that need host-network access on the same interface. +
+
+
+ + +
+
+
+
+
● postgres
+
● plex
+
● sonarr
+
● radarr
+
● nextcloud
+
● homeassistant
+
● mosquitto
+
+
+
+ + +
+ +
+
+
2026-07-11T09:58:02Z INFO database system is ready to accept connections
+
2026-07-11T09:58:14Z INFO checkpoint starting: time
+
2026-07-11T10:04:41Z WARN could not receive data from client: Connection reset by peer
+
2026-07-11T10:07:52Z ERROR connection to server was lost
+
2026-07-11T10:07:53Z INFO restartpoint starting: time
+
2026-07-11T10:07:58Z INFO database system is ready to accept connections
+
2026-07-11T10:11:03Z WARN autovacuum: found orphan temp table in database "app"
+
+
+
+
+
+ + +
+
+
+
+ Exec into: + + Shell: + +
+
+
root@sonarr:/config$ podman exec -it sonarr /bin/bash
+
root@sonarr:/config# ls -la
+
drwxr-xr-x 6 abc abc 4096 Jul 11 09:12 .
+
drwxr-xr-x 1 root root 4096 Jun 02 17:03 ..
+
-rw-r--r-- 1 abc abc 22016 Jul 11 09:58 sonarr.db
+
drwxr-xr-x 2 abc abc 4096 Jul 11 06:00 logs
+
 
+
root@sonarr:/config# tail -n 3 logs/sonarr.txt
+
10:11:02|Info|RssSyncService: Starting RSS Sync
+
10:11:04|Info|RssSyncService: RSS Sync Completed. Reported 12 releases
+
10:11:04|Info|DownloadService: Report sent to SABnzbd
+
 
+
root@sonarr:/config#
+
+
+
+
+ + +
+
+
+
+
+
media-stack up
+
/boot/config/plugins/podman/compose/media-stack
+
+
+
home-stack up
+
/boot/config/plugins/podman/compose/home-stack
+
+
+
staging down
+
/boot/config/plugins/podman/compose/staging
+
+
+
+
+ media-stack / compose.yaml +
+ + + +
+
+
# managed via podman kube play — see docs/ARCHITECTURE.md +services: + sonarr: + image: lscr.io/linuxserver/sonarr:4.0 + ports: + - "8989:8989" + volumes: + - /mnt/user/appdata/sonarr:/config + - /mnt/user/media:/media + environment: + PUID: "99" + PGID: "100" + restart: unless-stopped + + radarr: + image: lscr.io/linuxserver/radarr:5.2 + ports: + - "7878:7878" + volumes: + - /mnt/user/appdata/radarr:/config + - /mnt/user/media:/media + restart: unless-stopped + + prowlarr: + image: lscr.io/linuxserver/prowlarr:1 + ports: + - "9696:9696" + restart: unless-stopped
+
+
+
+
+ + +
+
+
+

Storage

+
+ +
+ +
Cache pool or dedicated disk — never a path under /mnt/user (FUSE). See Architecture docs.
+
+
+
+ +
GB +
+
62.4 GB used of 20 GB nominal — image will need to grow soon.
+
+
+
+ +
+

Networking

+
+
+
+ +
+

Autostart & Lifecycle

+
+
seconds
+
consecutive failures +
watchtower is currently paused — see Dashboard.
+
+ +
+

Updates

unraid-podman 0.1.0
+
+
+
+ podman 6.0.1 · conmon 2.2.1 · crun 1.28 · netavark 2.0.0 · aardvark-dns 2.0.0 · passt git6ef3d1c · fuse-overlayfs 1.17 +
+
+
+ +
+

Danger Zone

+
+ +
+
+
+ +
+ +
Your configuration, backups, and container storage are preserved by default. Full data removal requires a separate confirmation.
+
+
+
+
+
+ +
+ +
Mockup only — static sample data, not connected to a live podman.sock. See webui/README.md.
+ + diff --git a/webui/plugins/podman/Podman.page b/webui/plugins/podman/Podman.page new file mode 100644 index 0000000..ad67e95 --- /dev/null +++ b/webui/plugins/podman/Podman.page @@ -0,0 +1,256 @@ +Menu="Podman" +Title="Podman" +Icon="podman" +--- +.js module — so this file has no + * business logic of its own and does not talk to PodmanClient directly. + * See docs/ARCHITECTURE.md section 18 for the overall WebUI design and + * webui/mockups/prototype.html for the approved visual reference this + * page's markup mirrors (same structure, same CSS classes, real data + * instead of static samples). + */ +?> + + +
+ +
+
+ +
+

Podman

+
Connecting…
+
+
+
+ +
+
+ + + +
+ + +
+
+
Running
+
Pods
+
Images
+
Volumes
+
Networks
+
Images on Disk
+
+
+ + +
+
+
+ +
+ + + +
+
+
+ + + +
StatusNameImageCPU/MemPortsUptime
+
+
+
+ + +
+ + +
+
+
+ + +
+
+ + + +
RepositoryTagImage IDSizeCreatedUsed By
+
+
+
+ + +
+
+
+
+ +
+
+ + + +
NameDriverMountpointUsed By
+
+
+ App data mapped from Unraid shares (e.g. /mnt/user/appdata/…) is managed as bind mounts per-container and does not appear here. +
+
+
+ + +
+
+
+
+ +
+
+ + + +
NameDriverSubnetGatewayContainers
+
+
+
+ + +
+
+
+
+
+
+ + + + + +
+
+
+
+
+
+ + +
+
+
+
+ Exec into: +
+
+ +
+
+
+ + +
+
+
+
+
+
+ + + + +
+

+          
+
+
+
+ + +
+
+
+

Storage

+
+ +
+ +
Cache pool or dedicated disk — never a path under /mnt/user (FUSE).
+
+
+
+ +
GB
+
+
+ +
+

Autostart & Lifecycle

+
+ +
+
+
+ +
seconds
+
+
+ +
+ + + +
#Container
+
+
+
+ +
+
+
+ +
+

Installed Packages

+
+ +
+
+
+
+
+ +
+
+ + + + + + + + + + + + diff --git a/webui/plugins/podman/ajax/compose.php b/webui/plugins/podman/ajax/compose.php new file mode 100644 index 0000000..c422956 --- /dev/null +++ b/webui/plugins/podman/ajax/compose.php @@ -0,0 +1,179 @@ +/compose.yaml — + * see docs/ARCHITECTURE.md; this mirrors how autostart/networks/backups + * are all rooted under /boot/config/plugins/podman/ for the same + * boot-persistence reasons. + * + * Actions (?action=...): + * list GET -> known projects with up/down status + * get GET (&project=...) -> raw compose.yaml content + * up POST {"project": "..."} + * down POST {"project": "..."} + * pull POST {"project": "..."} + */ + +declare(strict_types=1); + +require __DIR__ . '/../include/bootstrap.php'; + +$composeDir = $podmanConfig->bootDir . '/compose'; +$action = $_GET['action'] ?? ''; + +switch ($action) { + case 'list': + podman_json_response(compose_list($composeDir)); + break; + + case 'get': + $project = (string) ($_GET['project'] ?? ''); + podman_json_response(['yaml' => compose_read($composeDir, $project)]); + break; + + case 'up': + podman_json_response(compose_run($composeDir, require_project(podman_read_json_body()), ['up', '-d'])); + break; + + case 'down': + podman_json_response(compose_run($composeDir, require_project(podman_read_json_body()), ['down'])); + break; + + case 'pull': + podman_json_response(compose_run($composeDir, require_project(podman_read_json_body()), ['pull'])); + break; + + default: + podman_json_error("Unknown action '{$action}'", 400); +} + +/** @param array $body */ +function require_project(array $body): string +{ + $project = (string) ($body['project'] ?? ''); + if (!is_valid_project_name($project)) { + podman_json_error('Missing or invalid project name', 400); + } + return $project; +} + +/** + * Project names come from the user (a form field when creating a new + * compose project, or a value round-tripped from list()). Restricting + * them to a fixed safe character set here — BEFORE they're ever used to + * build a filesystem path or a command argument — is what makes it safe + * to pass them to proc_open() at all. + */ +function is_valid_project_name(string $name): bool +{ + return $name !== '' && preg_match('/^[a-zA-Z0-9_-]+$/', $name) === 1; +} + +/** @return array> */ +function compose_list(string $composeDir): array +{ + if (!is_dir($composeDir)) { + return []; + } + + $projects = []; + foreach (scandir($composeDir) ?: [] as $entry) { + if ($entry === '.' || $entry === '..' || !is_valid_project_name($entry)) { + continue; + } + $yamlPath = $composeDir . '/' . $entry . '/compose.yaml'; + if (!is_file($yamlPath)) { + continue; + } + $projects[] = [ + 'name' => $entry, + 'path' => $yamlPath, + 'status' => compose_status($composeDir, $entry), + ]; + } + + usort($projects, static fn($a, $b) => strcmp($a['name'], $b['name'])); + return $projects; +} + +/** Best-effort "up"/"down" status via `podman compose ps`; degrades to "unknown" rather than failing the whole list. */ +function compose_status(string $composeDir, string $project): string +{ + $result = run_compose_command($composeDir, $project, ['ps', '--format', 'json'], 5); + if ($result['exitCode'] !== 0) { + return 'unknown'; + } + $decoded = json_decode($result['output'], true); + return (is_array($decoded) && count($decoded) > 0) ? 'up' : 'down'; +} + +function compose_read(string $composeDir, string $project): string +{ + if (!is_valid_project_name($project)) { + podman_json_error('Invalid project name', 400); + } + $path = $composeDir . '/' . $project . '/compose.yaml'; + $content = is_file($path) ? file_get_contents($path) : false; + if ($content === false) { + podman_json_error("compose.yaml not found for project '{$project}'", 404); + } + return $content; +} + +/** @return array */ +function compose_run(string $composeDir, string $project, array $subcommand): array +{ + $result = run_compose_command($composeDir, $project, $subcommand, 300); + if ($result['exitCode'] !== 0) { + podman_json_error("podman compose " . implode(' ', $subcommand) . " failed:\n" . $result['output'], 502); + } + return ['output' => $result['output']]; +} + +/** + * Runs `podman compose -f /compose.yaml ` via + * proc_open with an argv array (never a shell string — proc_open with an + * array argument bypasses the shell entirely, so there is no injection + * surface even though $project has already been validated above too). + * + * @param array $subcommand + * @return array{exitCode:int,output:string} + */ +function run_compose_command(string $composeDir, string $project, array $subcommand, int $timeoutSeconds): array +{ + $yamlPath = $composeDir . '/' . $project . '/compose.yaml'; + $argv = array_merge(['podman', 'compose', '-f', $yamlPath], $subcommand); + + $descriptors = [1 => ['pipe', 'w'], 2 => ['pipe', 'w']]; + $process = proc_open($argv, $descriptors, $pipes, $composeDir . '/' . $project); + if (!is_resource($process)) { + return ['exitCode' => 127, 'output' => 'Could not start podman compose process']; + } + + stream_set_timeout($pipes[1], $timeoutSeconds); + $stdout = stream_get_contents($pipes[1]) ?: ''; + $stderr = stream_get_contents($pipes[2]) ?: ''; + fclose($pipes[1]); + fclose($pipes[2]); + $exitCode = proc_close($process); + + return ['exitCode' => $exitCode, 'output' => trim($stdout . $stderr)]; +} diff --git a/webui/plugins/podman/ajax/containers.php b/webui/plugins/podman/ajax/containers.php new file mode 100644 index 0000000..905ac32 --- /dev/null +++ b/webui/plugins/podman/ajax/containers.php @@ -0,0 +1,132 @@ + normalized array of containers for the table view + * inspect GET (&id=...) -> raw inspect JSON, for a detail dialog + * start POST {"id": "..."} + * stop POST {"id": "...", "timeout": 10} + * restart POST {"id": "...", "timeout": 10} + * remove POST {"id": "...", "force": false} + * logs GET (&id=...&tail=200) -> plain text + */ + +declare(strict_types=1); + +require __DIR__ . '/../include/bootstrap.php'; + +$action = $_GET['action'] ?? ''; + +switch ($action) { + case 'list': + podman_json_response(containers_list($client)); + break; + + case 'inspect': + $id = (string) ($_GET['id'] ?? ''); + if ($id === '') { + podman_json_error('Missing id', 400); + } + podman_json_response($client->inspectContainer($id)); + break; + + case 'logs': + $id = (string) ($_GET['id'] ?? ''); + if ($id === '') { + podman_json_error('Missing id', 400); + } + $tail = (int) ($_GET['tail'] ?? 200); + podman_json_response(['text' => $client->containerLogs($id, $tail)]); + break; + + case 'start': + $body = podman_read_json_body(); + $client->startContainer(require_id($body)); + podman_json_response(['status' => 'started']); + break; + + case 'stop': + $body = podman_read_json_body(); + $client->stopContainer(require_id($body), (int) ($body['timeout'] ?? $podmanConfig->stopTimeoutSeconds)); + podman_json_response(['status' => 'stopped']); + break; + + case 'restart': + $body = podman_read_json_body(); + $client->restartContainer(require_id($body), (int) ($body['timeout'] ?? $podmanConfig->stopTimeoutSeconds)); + podman_json_response(['status' => 'restarted']); + break; + + case 'remove': + $body = podman_read_json_body(); + $client->removeContainer(require_id($body), (bool) ($body['force'] ?? false)); + podman_json_response(['status' => 'removed']); + break; + + default: + podman_json_error("Unknown action '{$action}'", 400); +} + +/** @param array $body */ +function require_id(array $body): string +{ + $id = (string) ($body['id'] ?? ''); + if ($id === '') { + podman_json_error('Missing id in request body', 400); + } + return $id; +} + +/** + * Normalizes libpod's /containers/json entries into exactly what the + * Containers table (javascript/containers.js) renders — keeping this + * shaping logic server-side means the frontend never has to know libpod's + * raw field names/quirks (e.g. Names is an array, State vs Status, etc). + * + * @return array> + */ +function containers_list(PodmanClient $client): array +{ + $raw = $client->listContainers(true); + $out = []; + + foreach ($raw as $c) { + $names = $c['Names'] ?? []; + $name = is_array($names) && count($names) > 0 ? ltrim((string) $names[0], '/') : podman_short_id((string) ($c['Id'] ?? '')); + + $ports = []; + foreach (($c['Ports'] ?? []) as $p) { + if (isset($p['host_port'], $p['container_port'])) { + $ports[] = "{$p['host_port']}:{$p['container_port']}/" . ($p['protocol'] ?? 'tcp'); + } elseif (isset($p['container_port'])) { + $ports[] = "{$p['container_port']}/" . ($p['protocol'] ?? 'tcp'); + } + } + + $startedAt = podman_parse_time($c['StartedAt'] ?? null); + $state = strtolower((string) ($c['State'] ?? 'unknown')); + + $out[] = [ + 'id' => (string) ($c['Id'] ?? ''), + 'shortId' => podman_short_id((string) ($c['Id'] ?? '')), + 'name' => $name, + 'image' => (string) ($c['Image'] ?? ''), + 'state' => $state, + 'status' => (string) ($c['Status'] ?? ''), + 'health' => $c['Health']['Status'] ?? null, + 'ports' => $ports, + 'pod' => $c['Pod'] ?? null, + 'podName' => $c['PodName'] ?? null, + 'uptimeSeconds' => ($state === 'running' && $startedAt !== null) ? (time() - $startedAt) : null, + 'createdAt' => podman_parse_time($c['Created'] ?? null), + ]; + } + + usort($out, static fn($a, $b) => strcmp($a['name'], $b['name'])); + return $out; +} diff --git a/webui/plugins/podman/ajax/exec.php b/webui/plugins/podman/ajax/exec.php new file mode 100644 index 0000000..d5bed62 --- /dev/null +++ b/webui/plugins/podman/ajax/exec.php @@ -0,0 +1,67 @@ +execRun($id, ['/bin/sh', '-c', $commandLine], $cwd); + + podman_json_response(['output' => $output]); + break; + + default: + podman_json_error("Unknown action '{$action}'", 400); +} diff --git a/webui/plugins/podman/ajax/images.php b/webui/plugins/podman/ajax/images.php new file mode 100644 index 0000000..4401a4d --- /dev/null +++ b/webui/plugins/podman/ajax/images.php @@ -0,0 +1,88 @@ + normalized image list + * pull POST {"reference": "docker.io/library/postgres:16"} + * remove POST {"id": "...", "force": false} + */ + +declare(strict_types=1); + +require __DIR__ . '/../include/bootstrap.php'; + +$action = $_GET['action'] ?? ''; + +switch ($action) { + case 'list': + podman_json_response(images_list($client)); + break; + + case 'pull': + $body = podman_read_json_body(); + $reference = (string) ($body['reference'] ?? ''); + if ($reference === '') { + podman_json_error('Missing reference in request body', 400); + } + podman_json_response($client->pullImage($reference)); + break; + + case 'remove': + $body = podman_read_json_body(); + $id = (string) ($body['id'] ?? ''); + if ($id === '') { + podman_json_error('Missing id in request body', 400); + } + $client->removeImage($id, (bool) ($body['force'] ?? false)); + podman_json_response(['status' => 'removed']); + break; + + default: + podman_json_error("Unknown action '{$action}'", 400); +} + +/** @return array> */ +function images_list(PodmanClient $client): array +{ + $raw = $client->listImages(); + + // In-use counts let the frontend show "0" (safe to remove) vs a + // positive count, without a separate round trip per image. + $usageCounts = []; + foreach ($client->listContainers(true) as $c) { + $imageId = (string) ($c['ImageID'] ?? ''); + if ($imageId !== '') { + $usageCounts[$imageId] = ($usageCounts[$imageId] ?? 0) + 1; + } + } + + $out = []; + foreach ($raw as $img) { + $id = (string) ($img['Id'] ?? ''); + $repoTags = $img['RepoTags'] ?? []; + $repository = ''; + $tag = ''; + if (is_array($repoTags) && count($repoTags) > 0 && is_string($repoTags[0]) && str_contains($repoTags[0], ':')) { + [$repository, $tag] = explode(':', $repoTags[0], 2); + } + + $out[] = [ + 'id' => $id, + 'shortId' => podman_short_id($id), + 'repository' => $repository, + 'tag' => $tag, + 'sizeBytes' => (int) ($img['Size'] ?? 0), + 'sizeFormatted' => podman_format_bytes((int) ($img['Size'] ?? 0)), + // Unlike containers' Created/StartedAt (RFC3339 strings), libpod + // reports image Created as a Unix timestamp integer directly. + 'createdAt' => isset($img['Created']) ? (int) $img['Created'] : null, + 'usedBy' => $usageCounts[$id] ?? 0, + ]; + } + + usort($out, static fn($a, $b) => strcmp($a['repository'], $b['repository'])); + return $out; +} diff --git a/webui/plugins/podman/ajax/networks.php b/webui/plugins/podman/ajax/networks.php new file mode 100644 index 0000000..3a236f6 --- /dev/null +++ b/webui/plugins/podman/ajax/networks.php @@ -0,0 +1,93 @@ + normalized network list with subnet/gateway/usage + * create POST {"name": "...", "driver": "bridge", "subnet": "...", "gateway": "..."} + * remove POST {"name": "...", "force": false} + */ + +declare(strict_types=1); + +require __DIR__ . '/../include/bootstrap.php'; + +$action = $_GET['action'] ?? ''; + +switch ($action) { + case 'list': + podman_json_response(networks_list($client)); + break; + + case 'create': + $body = podman_read_json_body(); + $name = (string) ($body['name'] ?? ''); + if ($name === '') { + podman_json_error('Missing name in request body', 400); + } + podman_json_response($client->createNetwork( + $name, + (string) ($body['driver'] ?? 'bridge'), + isset($body['subnet']) ? (string) $body['subnet'] : null, + isset($body['gateway']) ? (string) $body['gateway'] : null + )); + break; + + case 'remove': + $body = podman_read_json_body(); + $name = (string) ($body['name'] ?? ''); + if ($name === '') { + podman_json_error('Missing name in request body', 400); + } + // podman0, the default bridge, refuses removal API-side — no + // special-casing needed here, PodmanApiException surfaces podman's + // own rejection message as-is. + $client->removeNetwork($name, (bool) ($body['force'] ?? false)); + podman_json_response(['status' => 'removed']); + break; + + default: + podman_json_error("Unknown action '{$action}'", 400); +} + +/** @return array> */ +function networks_list(PodmanClient $client): array +{ + $raw = $client->listNetworks(); + + $usageCounts = []; + foreach ($client->listContainers(true) as $c) { + $nets = $c['Networks'] ?? []; + if (is_array($nets)) { + foreach ($nets as $netName) { + if (is_string($netName)) { + $usageCounts[$netName] = ($usageCounts[$netName] ?? 0) + 1; + } + } + } + } + + $out = []; + foreach ($raw as $n) { + $name = (string) ($n['name'] ?? ''); + $subnets = $n['subnets'] ?? []; + $subnet = is_array($subnets) && count($subnets) > 0 ? (string) ($subnets[0]['subnet'] ?? '') : ''; + $gateway = is_array($subnets) && count($subnets) > 0 ? (string) ($subnets[0]['gateway'] ?? '') : ''; + + $out[] = [ + 'name' => $name, + 'driver' => (string) ($n['driver'] ?? 'bridge'), + 'subnet' => $subnet, + 'gateway' => $gateway, + 'isDefault' => $name === 'podman', + 'containers' => $usageCounts[$name] ?? 0, + ]; + } + + usort($out, static fn($a, $b) => strcmp($a['name'], $b['name'])); + return $out; +} diff --git a/webui/plugins/podman/ajax/pods.php b/webui/plugins/podman/ajax/pods.php new file mode 100644 index 0000000..1345235 --- /dev/null +++ b/webui/plugins/podman/ajax/pods.php @@ -0,0 +1,103 @@ + pods with nested container summaries + * start POST {"name": "..."} + * stop POST {"name": "...", "timeout": 10} + * remove POST {"name": "...", "force": false} + */ + +declare(strict_types=1); + +require __DIR__ . '/../include/bootstrap.php'; + +$action = $_GET['action'] ?? ''; + +switch ($action) { + case 'list': + podman_json_response(pods_list($client)); + break; + + case 'start': + $body = podman_read_json_body(); + $client->startPod(require_name($body)); + podman_json_response(['status' => 'started']); + break; + + case 'stop': + $body = podman_read_json_body(); + $client->stopPod(require_name($body), (int) ($body['timeout'] ?? $podmanConfig->stopTimeoutSeconds)); + podman_json_response(['status' => 'stopped']); + break; + + case 'remove': + $body = podman_read_json_body(); + $client->removePod(require_name($body), (bool) ($body['force'] ?? false)); + podman_json_response(['status' => 'removed']); + break; + + default: + podman_json_error("Unknown action '{$action}'", 400); +} + +/** @param array $body */ +function require_name(array $body): string +{ + $name = (string) ($body['name'] ?? ''); + if ($name === '') { + podman_json_error('Missing name in request body', 400); + } + return $name; +} + +/** @return array> */ +function pods_list(PodmanClient $client): array +{ + $pods = $client->listPods(); + $containersByPod = []; + foreach (containers_grouped_by_pod($client) as $podId => $members) { + $containersByPod[$podId] = $members; + } + + $out = []; + foreach ($pods as $p) { + $id = (string) ($p['Id'] ?? ''); + $out[] = [ + 'id' => $id, + 'name' => (string) ($p['Name'] ?? ''), + 'status' => strtolower((string) ($p['Status'] ?? 'unknown')), + 'containersTotal' => (int) ($p['NumContainers'] ?? count($containersByPod[$id] ?? [])), + 'infraId' => $p['InfraId'] ?? null, + 'members' => $containersByPod[$id] ?? [], + ]; + } + return $out; +} + +/** @return array>> keyed by pod id */ +function containers_grouped_by_pod(PodmanClient $client): array +{ + $grouped = []; + foreach ($client->listContainers(true) as $c) { + $podId = $c['Pod'] ?? null; + if (!is_string($podId) || $podId === '') { + continue; + } + $names = $c['Names'] ?? []; + $name = is_array($names) && count($names) > 0 ? ltrim((string) $names[0], '/') : podman_short_id((string) ($c['Id'] ?? '')); + $grouped[$podId][] = [ + 'id' => (string) ($c['Id'] ?? ''), + 'name' => $name, + 'image' => (string) ($c['Image'] ?? ''), + 'state' => strtolower((string) ($c['State'] ?? 'unknown')), + ]; + } + return $grouped; +} diff --git a/webui/plugins/podman/ajax/settings.php b/webui/plugins/podman/ajax/settings.php new file mode 100644 index 0000000..0b48d08 --- /dev/null +++ b/webui/plugins/podman/ajax/settings.php @@ -0,0 +1,159 @@ + current settings + autostart list + package versions + * save POST {"storagePath": "...", "storageImageSizeGb": 20, + * "enabled": true, "stopTimeoutSeconds": 10} + * autostart_save POST {"names": ["postgres", "nextcloud", ...]} + */ + +declare(strict_types=1); + +require __DIR__ . '/../include/bootstrap.php'; + +$action = $_GET['action'] ?? ''; + +switch ($action) { + case 'get': + podman_json_response(settings_get($podmanConfig)); + break; + + case 'save': + $body = podman_read_json_body(); + settings_save($podmanConfig, $body); + podman_json_response(['status' => 'saved']); + break; + + case 'autostart_save': + $body = podman_read_json_body(); + $names = $body['names'] ?? null; + if (!is_array($names)) { + podman_json_error('Missing names array in request body', 400); + } + autostart_save($podmanConfig, $names); + podman_json_response(['status' => 'saved']); + break; + + default: + podman_json_error("Unknown action '{$action}'", 400); +} + +/** @return array */ +function settings_get(PodmanConfig $config): array +{ + return [ + 'storagePath' => $config->storagePath, + 'storageImageSizeGb' => $config->storageImageSizeGb, + 'enabled' => $config->enabled, + 'stopTimeoutSeconds' => $config->stopTimeoutSeconds, + 'autostart' => autostart_read($config), + 'packageVersions' => installed_package_versions(), + ]; +} + +/** @param array $input */ +function settings_save(PodmanConfig $config, array $input): void +{ + $storagePath = isset($input['storagePath']) ? (string) $input['storagePath'] : $config->storagePath; + if (!storage_path_is_safe($storagePath)) { + podman_json_error( + "storagePath ({$storagePath}) is under /mnt/user (FUSE/shfs). " . + 'The overlay storage driver needs a real mounted filesystem — use a cache pool or a specific disk path instead.', + 400 + ); + } + + $lines = [ + '# Rewritten by the unraid-podman WebUI (ajax/settings.php).', + '# Applies on the next "rc.podman restart" — see plugin/rc.d/rc.podman.', + 'STORAGE_PATH="' . $storagePath . '"', + 'STORAGE_IMAGE_SIZE_GB="' . (int) ($input['storageImageSizeGb'] ?? $config->storageImageSizeGb) . '"', + 'PODMAN_ENABLED="' . ((bool) ($input['enabled'] ?? $config->enabled) ? 'yes' : 'no') . '"', + 'STOP_TIMEOUT="' . (int) ($input['stopTimeoutSeconds'] ?? $config->stopTimeoutSeconds) . '"', + 'CONFIG_SCHEMA_VERSION="1"', + '', + ]; + + $target = $config->bootDir . '/podman.cfg'; + if (file_put_contents($target, implode("\n", $lines), LOCK_EX) === false) { + podman_json_error("Could not write {$target} — check permissions on /boot/config/plugins/podman/", 500); + } +} + +/** + * Mirrors plugin/sbin/podman-common.sh's podman_storage_path_is_safe() — + * kept in sync deliberately (both reject the same /mnt/user prefix, for + * the same reason) rather than shelling out to the bash version, since + * this is a one-line string check, not worth a process spawn for. + */ +function storage_path_is_safe(string $path): bool +{ + return $path !== '/mnt/user' && !str_starts_with($path, '/mnt/user/'); +} + +/** @return array */ +function autostart_read(PodmanConfig $config): array +{ + if (!is_readable($config->autostartFile)) { + return []; + } + $lines = file($config->autostartFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) ?: []; + $names = []; + foreach ($lines as $line) { + $line = trim(preg_replace('/#.*$/', '', $line) ?? ''); + if ($line !== '') { + $names[] = $line; + } + } + return $names; +} + +/** @param array $names */ +function autostart_save(PodmanConfig $config, array $names): void +{ + $lines = array_map(static fn($n) => (string) $n, $names); + $content = implode("\n", $lines) . (count($lines) > 0 ? "\n" : ''); + if (file_put_contents($config->autostartFile, $content, LOCK_EX) === false) { + podman_json_error("Could not write {$config->autostartFile}", 500); + } +} + +/** + * Reads /usr/local/share/unraid-podman/installed-versions.env — the same + * manifest plugin/sbin/podman-verify-packages.sh and + * podman-update-packages.sh use (see plugin/podman.plg's postinstall step, + * which generates it) — so Settings shows exactly what those tools would + * report, not a second, possibly-diverging source of truth. + * + * @return array + */ +function installed_package_versions(): array +{ + $path = '/usr/local/share/unraid-podman/installed-versions.env'; + if (!is_readable($path)) { + return []; + } + $lines = file($path, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) ?: []; + $out = []; + foreach ($lines as $line) { + $line = trim($line); + if ($line === '' || str_starts_with($line, '#')) { + continue; + } + if (preg_match('/^([A-Z_][A-Z0-9_]*)="?([^"]*)"?$/', $line, $m)) { + $out[$m[1]] = $m[2]; + } + } + return $out; +} diff --git a/webui/plugins/podman/ajax/system.php b/webui/plugins/podman/ajax/system.php new file mode 100644 index 0000000..c11a6bd --- /dev/null +++ b/webui/plugins/podman/ajax/system.php @@ -0,0 +1,76 @@ + counts, storage usage, engine version, ping status + */ + +declare(strict_types=1); + +require __DIR__ . '/../include/bootstrap.php'; + +$action = $_GET['action'] ?? ''; + +switch ($action) { + case 'summary': + podman_json_response(system_summary($client, $podmanConfig)); + break; + + default: + podman_json_error("Unknown action '{$action}'", 400); +} + +/** @return array */ +function system_summary(PodmanClient $client, PodmanConfig $config): array +{ + if (!$client->ping()) { + return [ + 'reachable' => false, + 'socketPath' => $config->socketPath, + ]; + } + + $containers = $client->listContainers(true); + $running = 0; + foreach ($containers as $c) { + if (strtolower((string) ($c['State'] ?? '')) === 'running') { + $running++; + } + } + + $pods = $client->listPods(); + $images = $client->listImages(); + $volumes = $client->listVolumes(); + $networks = $client->listNetworks(); + $info = $client->info(); + $df = $client->systemDf(); + + $imagesSize = 0; + foreach (($df['Images'] ?? []) as $img) { + $imagesSize += (int) ($img['Size'] ?? 0); + } + + return [ + 'reachable' => true, + 'socketPath' => $config->socketPath, + 'podmanVersion' => $info['Version']['Version'] ?? null, + 'containers' => [ + 'total' => count($containers), + 'running' => $running, + ], + 'pods' => count($pods), + 'images' => count($images), + 'volumes' => count($volumes), + 'networks' => count($networks), + 'storage' => [ + 'imagesSizeBytes' => $imagesSize, + 'imagesSizeFormatted' => podman_format_bytes($imagesSize), + ], + ]; +} diff --git a/webui/plugins/podman/ajax/volumes.php b/webui/plugins/podman/ajax/volumes.php new file mode 100644 index 0000000..c29b559 --- /dev/null +++ b/webui/plugins/podman/ajax/volumes.php @@ -0,0 +1,79 @@ + normalized volume list, with usedBy counts + * create POST {"name": "...", "driver": "local"} + * remove POST {"name": "...", "force": false} + */ + +declare(strict_types=1); + +require __DIR__ . '/../include/bootstrap.php'; + +$action = $_GET['action'] ?? ''; + +switch ($action) { + case 'list': + podman_json_response(volumes_list($client)); + break; + + case 'create': + $body = podman_read_json_body(); + $name = (string) ($body['name'] ?? ''); + if ($name === '') { + podman_json_error('Missing name in request body', 400); + } + podman_json_response($client->createVolume($name, (string) ($body['driver'] ?? 'local'))); + break; + + case 'remove': + $body = podman_read_json_body(); + $name = (string) ($body['name'] ?? ''); + if ($name === '') { + podman_json_error('Missing name in request body', 400); + } + $client->removeVolume($name, (bool) ($body['force'] ?? false)); + podman_json_response(['status' => 'removed']); + break; + + default: + podman_json_error("Unknown action '{$action}'", 400); +} + +/** @return array> */ +function volumes_list(PodmanClient $client): array +{ + $raw = $client->listVolumes(); + + $usageCounts = []; + foreach ($client->listContainers(true) as $c) { + foreach (($c['Mounts'] ?? []) as $mount) { + $volName = $mount['Name'] ?? null; + if (is_string($volName) && $volName !== '') { + $usageCounts[$volName] = ($usageCounts[$volName] ?? 0) + 1; + } + } + } + + $out = []; + foreach ($raw as $v) { + $name = (string) ($v['Name'] ?? ''); + $out[] = [ + 'name' => $name, + 'driver' => (string) ($v['Driver'] ?? 'local'), + 'mountpoint' => (string) ($v['Mountpoint'] ?? ''), + 'createdAt' => podman_parse_time($v['CreatedAt'] ?? null), + 'usedBy' => $usageCounts[$name] ?? 0, + ]; + } + + usort($out, static fn($a, $b) => strcmp($a['name'], $b['name'])); + return $out; +} diff --git a/webui/plugins/podman/images/.gitkeep b/webui/plugins/podman/images/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/webui/plugins/podman/include/Config.php b/webui/plugins/podman/include/Config.php new file mode 100644 index 0000000..3865c9e --- /dev/null +++ b/webui/plugins/podman/include/Config.php @@ -0,0 +1,106 @@ +bootDir = '/boot/config/plugins/podman'; + $this->storagePath = '/mnt/cache/system/podman'; + $this->storageImageSizeGb = 20; + $this->enabled = true; + $this->stopTimeoutSeconds = 10; + $this->socketPath = '/var/run/podman/podman.sock'; + $this->autostartFile = $this->bootDir . '/autostart'; + $this->autostartDelayFile = $this->bootDir . '/autostart-delay'; + } + + public static function load(): self + { + $cfg = new self(); + + $cfgFile = $cfg->bootDir . '/podman.cfg'; + if (is_readable($cfgFile)) { + $values = self::parseShellStyleFile($cfgFile); + if (isset($values['STORAGE_PATH'])) { + $cfg->storagePath = $values['STORAGE_PATH']; + } + if (isset($values['STORAGE_IMAGE_SIZE_GB'])) { + $cfg->storageImageSizeGb = (int) $values['STORAGE_IMAGE_SIZE_GB']; + } + if (isset($values['PODMAN_ENABLED'])) { + $cfg->enabled = strtolower($values['PODMAN_ENABLED']) === 'yes'; + } + if (isset($values['STOP_TIMEOUT'])) { + $cfg->stopTimeoutSeconds = (int) $values['STOP_TIMEOUT']; + } + } + + return $cfg; + } + + /** + * Parses the simple `KEY="value"` / `KEY=value` shell-sourceable format + * used by podman.cfg (see config/podman.cfg.example) WITHOUT executing + * it as shell — this file is read by an unprivileged PHP-FPM worker, + * so treating it as data rather than sourcing it is a deliberate + * safety boundary, not just a convenience. + * + * @return array + */ + private static function parseShellStyleFile(string $path): array + { + $result = []; + $lines = file($path, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES); + if ($lines === false) { + return $result; + } + + foreach ($lines as $line) { + $line = trim($line); + if ($line === '' || str_starts_with($line, '#')) { + continue; + } + if (!preg_match('/^([A-Z_][A-Z0-9_]*)=(.*)$/', $line, $m)) { + continue; + } + [$_, $key, $value] = $m; + $value = trim($value); + // Strip one layer of matching quotes, if present. + if (strlen($value) >= 2 && ( + ($value[0] === '"' && str_ends_with($value, '"')) || + ($value[0] === "'" && str_ends_with($value, "'")) + )) { + $value = substr($value, 1, -1); + } + $result[$key] = $value; + } + + return $result; + } + + public function newClient(): PodmanClient + { + return new PodmanClient($this->socketPath); + } +} diff --git a/webui/plugins/podman/include/PodmanClient.php b/webui/plugins/podman/include/PodmanClient.php new file mode 100644 index 0000000..bcc6e7b --- /dev/null +++ b/webui/plugins/podman/include/PodmanClient.php @@ -0,0 +1,404 @@ +httpStatus = $httpStatus; + } +} + +final class PodmanClient +{ + /** libpod REST API version this client targets. */ + private const API_VERSION = 'v4.0.0'; + + private string $socketPath; + private int $timeoutSeconds; + + public function __construct(string $socketPath, int $timeoutSeconds = 15) + { + $this->socketPath = $socketPath; + $this->timeoutSeconds = $timeoutSeconds; + } + + // ------------------------------------------------------------------- + // System + // ------------------------------------------------------------------- + + /** GET /info — engine + host information (used by the Dashboard/Settings panels). */ + public function info(): array + { + return $this->request('GET', '/info'); + } + + /** GET /system/df — image/container/volume disk usage summary. */ + public function systemDf(): array + { + return $this->request('GET', '/system/df'); + } + + /** Quick reachability check — used by ajax/system.php's status endpoint. */ + public function ping(): bool + { + try { + $this->request('GET', '/_ping', [], true); + return true; + } catch (PodmanApiException $e) { + return false; + } + } + + // ------------------------------------------------------------------- + // Containers + // ------------------------------------------------------------------- + + /** GET /containers/json — list containers. $all=true includes stopped ones. */ + public function listContainers(bool $all = true): array + { + return $this->request('GET', '/containers/json', ['all' => $all ? 'true' : 'false']); + } + + /** GET /containers/{id}/json — full inspect data for one container. */ + public function inspectContainer(string $id): array + { + return $this->request('GET', '/containers/' . rawurlencode($id) . '/json'); + } + + /** GET /containers/{id}/stats?stream=false — one-shot CPU/memory snapshot. */ + public function containerStats(string $id): array + { + return $this->request('GET', '/containers/' . rawurlencode($id) . '/stats', ['stream' => 'false']); + } + + public function startContainer(string $id): void + { + $this->request('POST', '/containers/' . rawurlencode($id) . '/start', [], true); + } + + public function stopContainer(string $id, int $timeoutSeconds = 10): void + { + $this->request('POST', '/containers/' . rawurlencode($id) . '/stop', ['t' => (string) $timeoutSeconds], true); + } + + public function restartContainer(string $id, int $timeoutSeconds = 10): void + { + $this->request('POST', '/containers/' . rawurlencode($id) . '/restart', ['t' => (string) $timeoutSeconds], true); + } + + public function removeContainer(string $id, bool $force = false): void + { + $this->request('DELETE', '/containers/' . rawurlencode($id), ['force' => $force ? 'true' : 'false'], true); + } + + /** + * GET /containers/{id}/logs — returns the raw (already de-multiplexed + * where possible) log text. Podman's non-TTY log stream uses the same + * 8-byte-frame-header multiplexing as `attach`; we strip those frame + * headers in demuxStream() so callers just get plain text lines. + */ + public function containerLogs(string $id, int $tail = 200, bool $timestamps = true): string + { + $query = [ + 'stdout' => 'true', + 'stderr' => 'true', + 'tail' => (string) $tail, + 'timestamps' => $timestamps ? 'true' : 'false', + ]; + $raw = $this->requestRaw('GET', '/containers/' . rawurlencode($id) . '/logs', $query); + return self::demuxStream($raw); + } + + // ------------------------------------------------------------------- + // Exec — see webui/plugins/podman/ajax/exec.php for the important + // caveat: this implements one-shot "run a command, return its output" + // semantics over the exec API, not a true interactive PTY (which would + // require a persistent bidirectional connection this stack doesn't + // have — see that file's header comment for the full explanation). + // ------------------------------------------------------------------- + + /** + * Creates and immediately runs one command inside a container via the + * real libpod exec API (POST /containers/{id}/exec, then + * POST /exec/{id}/start) and returns its combined stdout+stderr output. + * Tty=true is used deliberately so the response is a plain byte stream + * with no frame-header demultiplexing needed (see containerLogs() for + * the non-TTY case, which does need it). + */ + public function execRun(string $containerId, array $cmd, string $workingDir = ''): string + { + $createBody = [ + 'AttachStdin' => false, + 'AttachStdout' => true, + 'AttachStderr' => true, + 'Tty' => true, + 'Cmd' => $cmd, + ]; + if ($workingDir !== '') { + $createBody['WorkingDir'] = $workingDir; + } + + $created = $this->request('POST', '/containers/' . rawurlencode($containerId) . '/exec', [], false, $createBody); + $execId = $created['Id'] ?? null; + if (!is_string($execId) || $execId === '') { + throw new PodmanApiException('exec create response did not include an Id'); + } + + $output = $this->requestRaw('POST', '/exec/' . rawurlencode($execId) . '/start', [], [ + 'Detach' => false, + 'Tty' => true, + ]); + + return $output; + } + + // ------------------------------------------------------------------- + // Pods + // ------------------------------------------------------------------- + + public function listPods(): array + { + return $this->request('GET', '/pods/json'); + } + + public function inspectPod(string $name): array + { + return $this->request('GET', '/pods/' . rawurlencode($name) . '/json'); + } + + public function startPod(string $name): void + { + $this->request('POST', '/pods/' . rawurlencode($name) . '/start', [], true); + } + + public function stopPod(string $name, int $timeoutSeconds = 10): void + { + $this->request('POST', '/pods/' . rawurlencode($name) . '/stop', ['t' => (string) $timeoutSeconds], true); + } + + public function removePod(string $name, bool $force = false): void + { + $this->request('DELETE', '/pods/' . rawurlencode($name), ['force' => $force ? 'true' : 'false'], true); + } + + // ------------------------------------------------------------------- + // Images + // ------------------------------------------------------------------- + + public function listImages(): array + { + return $this->request('GET', '/images/json'); + } + + /** POST /images/pull — pulls (or updates) an image by reference, e.g. "docker.io/library/postgres:16". */ + public function pullImage(string $reference): array + { + return $this->request('POST', '/images/pull', ['reference' => $reference]); + } + + public function removeImage(string $id, bool $force = false): void + { + $this->request('DELETE', '/images/' . rawurlencode($id), ['force' => $force ? 'true' : 'false'], true); + } + + // ------------------------------------------------------------------- + // Volumes + // ------------------------------------------------------------------- + + public function listVolumes(): array + { + return $this->request('GET', '/volumes/json'); + } + + public function createVolume(string $name, string $driver = 'local'): array + { + return $this->request('POST', '/volumes/create', [], false, ['Name' => $name, 'Driver' => $driver]); + } + + public function removeVolume(string $name, bool $force = false): void + { + $this->request('DELETE', '/volumes/' . rawurlencode($name), ['force' => $force ? 'true' : 'false'], true); + } + + // ------------------------------------------------------------------- + // Networks + // ------------------------------------------------------------------- + + public function listNetworks(): array + { + return $this->request('GET', '/networks/json'); + } + + public function createNetwork(string $name, string $driver, ?string $subnet = null, ?string $gateway = null): array + { + $body = ['name' => $name, 'driver' => $driver]; + if ($subnet !== null) { + $body['subnets'] = [array_filter(['subnet' => $subnet, 'gateway' => $gateway])]; + } + return $this->request('POST', '/networks/create', [], false, $body); + } + + public function removeNetwork(string $name, bool $force = false): void + { + $this->request('DELETE', '/networks/' . rawurlencode($name), ['force' => $force ? 'true' : 'false'], true); + } + + // ------------------------------------------------------------------- + // Internals + // ------------------------------------------------------------------- + + /** + * Issues a request and JSON-decodes the response body. + * + * @param array $query + * @param bool $expectEmptyBody set true for endpoints that reply 204/200 with no/irrelevant body + * @param array|null $jsonBody request body to send as JSON, for POST/PUT endpoints that take one + * @return array + */ + private function request(string $method, string $path, array $query = [], bool $expectEmptyBody = false, ?array $jsonBody = null): array + { + $raw = $this->requestRaw($method, $path, $query, $jsonBody); + if ($expectEmptyBody || trim($raw) === '') { + return []; + } + $decoded = json_decode($raw, true); + if (!is_array($decoded)) { + throw new PodmanApiException('Expected a JSON object/array response from ' . $path); + } + return $decoded; + } + + /** + * Issues a request and returns the raw response body as a string, + * without JSON decoding — used for endpoints whose response isn't + * JSON (logs, exec start) and internally by request(). + * + * @param array $query + * @param array|null $jsonBody + */ + private function requestRaw(string $method, string $path, array $query = [], ?array $jsonBody = null): string + { + $url = 'http://d/' . self::API_VERSION . '/libpod' . $path; + if (!empty($query)) { + $url .= '?' . http_build_query($query); + } + + $ch = curl_init(); + curl_setopt_array($ch, [ + CURLOPT_UNIX_SOCKET_PATH => $this->socketPath, + CURLOPT_URL => $url, + CURLOPT_CUSTOMREQUEST => $method, + CURLOPT_RETURNTRANSFER => true, + CURLOPT_TIMEOUT => $this->timeoutSeconds, + CURLOPT_HTTPHEADER => ['Accept: application/json'], + ]); + + if ($jsonBody !== null) { + curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($jsonBody)); + curl_setopt($ch, CURLOPT_HTTPHEADER, ['Content-Type: application/json', 'Accept: application/json']); + } + + $body = curl_exec($ch); + $errno = curl_errno($ch); + $error = curl_error($ch); + $status = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE); + curl_close($ch); + + if ($errno !== 0) { + throw new PodmanApiException( + "Could not reach podman API socket ({$this->socketPath}): {$error}. Is rc.podman running?", + 0 + ); + } + + if ($status >= 400) { + $detail = self::extractErrorMessage($body ?: ''); + throw new PodmanApiException("Podman API {$method} {$path} failed ({$status}): {$detail}", $status); + } + + return $body === false ? '' : $body; + } + + /** Best-effort extraction of libpod's {"cause":...,"message":...} error body shape. */ + private static function extractErrorMessage(string $body): string + { + $decoded = json_decode($body, true); + if (is_array($decoded) && isset($decoded['message']) && is_string($decoded['message'])) { + return $decoded['message']; + } + return $body !== '' ? $body : '(no response body)'; + } + + /** + * Strips Docker/Podman's attach-stream frame headers from a + * non-TTY multiplexed stdout/stderr stream. Each frame is an 8-byte + * header — [stream type (1 byte), 0, 0, 0, big-endian uint32 length] + * — followed by that many bytes of payload. Stream type 1 = stdout, + * 2 = stderr; both are concatenated here since the UI just needs + * readable log text, not separated channels. + */ + private static function demuxStream(string $raw): string + { + if ($raw === '') { + return ''; + } + // If the stream doesn't start with a recognizable frame header, + // assume it's already plain text (e.g. a TTY-attached container's + // logs, which libpod does not frame) and return it as-is. + $firstByte = ord($raw[0]); + if ($firstByte > 2) { + return $raw; + } + + $out = ''; + $offset = 0; + $len = strlen($raw); + while ($offset + 8 <= $len) { + $header = substr($raw, $offset, 8); + $unpacked = unpack('Ctype/C3pad/Nsize', $header); + if ($unpacked === false) { + break; + } + $frameLen = $unpacked['size']; + $offset += 8; + if ($offset + $frameLen > $len) { + // Truncated final frame — take what's left and stop. + $out .= substr($raw, $offset); + break; + } + $out .= substr($raw, $offset, $frameLen); + $offset += $frameLen; + } + return $out; + } +} diff --git a/webui/plugins/podman/include/bootstrap.php b/webui/plugins/podman/include/bootstrap.php new file mode 100644 index 0000000..7f7bb2b --- /dev/null +++ b/webui/plugins/podman/include/bootstrap.php @@ -0,0 +1,37 @@ +httpStatus > 0 ? $e->httpStatus : 503; + podman_json_error($e->getMessage(), $status); + } + podman_json_error('Internal error: ' . $e->getMessage(), 500); +}); + +$podmanConfig = PodmanConfig::load(); +$client = $podmanConfig->newClient(); diff --git a/webui/plugins/podman/include/helpers.php b/webui/plugins/podman/include/helpers.php new file mode 100644 index 0000000..0a48cb3 --- /dev/null +++ b/webui/plugins/podman/include/helpers.php @@ -0,0 +1,107 @@ += 100 || $i === 0 ? '%.0f %s' : '%.1f %s', $value, $units[$i]); +} + +/** Formats a duration in seconds as a compact "14d 6h" / "6h 12m" / "38m" style string. */ +function podman_format_duration(int $seconds): string +{ + if ($seconds < 60) { + return $seconds . 's'; + } + $days = intdiv($seconds, 86400); + $hours = intdiv($seconds % 86400, 3600); + $minutes = intdiv($seconds % 3600, 60); + + if ($days > 0) { + return "{$days}d {$hours}h"; + } + if ($hours > 0) { + return "{$hours}h {$minutes}m"; + } + return "{$minutes}m"; +} + +/** Converts a libpod RFC3339 timestamp (as found in inspect output) to a Unix timestamp, or null if unparsable. */ +function podman_parse_time(?string $rfc3339): ?int +{ + if ($rfc3339 === null || $rfc3339 === '' || str_starts_with($rfc3339, '0001-01-01')) { + return null; + } + $ts = strtotime($rfc3339); + return $ts === false ? null : $ts; +} + +/** + * Shortens a full image/container ID to the 12-character form Docker/ + * Podman CLIs conventionally display, matching what users expect to see + * (and copy-paste into `podman inspect `, which accepts short IDs). + */ +function podman_short_id(string $id): string +{ + // Some APIs prefix with "sha256:" for image IDs. + $id = str_starts_with($id, 'sha256:') ? substr($id, 7) : $id; + return substr($id, 0, 12); +} + +/** + * Sends a JSON response and terminates the request — every ajax/*.php + * endpoint's single exit point, so response shape (envelope with "ok" and + * either "data" or "error") is consistent for the frontend's shared AJAX + * helper (javascript/app.js's request() function) to rely on. + */ +function podman_json_response(mixed $data, int $httpStatus = 200): never +{ + http_response_code($httpStatus); + header('Content-Type: application/json'); + echo json_encode(['ok' => $httpStatus < 400, 'data' => $data], JSON_UNESCAPED_SLASHES); + exit; +} + +function podman_json_error(string $message, int $httpStatus = 500): never +{ + http_response_code($httpStatus); + header('Content-Type: application/json'); + echo json_encode(['ok' => false, 'error' => $message], JSON_UNESCAPED_SLASHES); + exit; +} + +/** + * Reads and JSON-decodes the request body for POST/DELETE actions that + * take parameters (e.g. {"id": "..."}), with a friendly error on + * malformed input instead of a fatal error deep inside an endpoint. + * + * @return array + */ +function podman_read_json_body(): array +{ + $raw = file_get_contents('php://input'); + if ($raw === false || trim($raw) === '') { + return []; + } + $decoded = json_decode($raw, true); + if (!is_array($decoded)) { + podman_json_error('Request body must be a JSON object', 400); + } + return $decoded; +} diff --git a/webui/plugins/podman/javascript/app.js b/webui/plugins/podman/javascript/app.js new file mode 100644 index 0000000..46e6d84 --- /dev/null +++ b/webui/plugins/podman/javascript/app.js @@ -0,0 +1,186 @@ +/** + * javascript/app.js + * + * Shared runtime for the Podman plugin page: the AJAX helper every panel + * module uses to talk to webui/plugins/podman/ajax/*.php, small DOM + * utilities to avoid repeating the same escaping/formatting logic in ten + * places, and the sub-tab router that shows/hides panels and lazily + * initializes each one's module the first time it's opened. + * + * Loaded first (before any panel module) — see Podman.page. + */ +window.Podman = (function () { + 'use strict'; + + const BASE = '/plugins/podman/ajax/'; + + /** + * Calls one ajax/.php?action= endpoint and resolves with + * response.data, or rejects with an Error carrying the server's message + * — every ajax/*.php endpoint replies with the same {ok, data|error} + * envelope (see include/helpers.php's podman_json_response/_error), so + * this one function is the only place that envelope shape is known. + * + * @param {string} file e.g. "containers" + * @param {string} action e.g. "list" + * @param {('GET'|'POST')} method + * @param {object|null} body sent as JSON for POST + * @param {object} query extra query-string params (e.g. {id: "..."}) + */ + function call(file, action, method, body, query) { + const params = new URLSearchParams(Object.assign({ action: action }, query || {})); + const url = BASE + file + '.php?' + params.toString(); + + const opts = { method: method, headers: {} }; + if (body !== undefined && body !== null) { + opts.headers['Content-Type'] = 'application/json'; + opts.body = JSON.stringify(body); + } + + return fetch(url, opts) + .then(function (res) { + return res.json().then(function (envelope) { + if (!envelope.ok) { + throw new Error(envelope.error || ('Request failed (' + res.status + ')')); + } + return envelope.data; + }); + }); + } + + function get(file, action, query) { + return call(file, action, 'GET', null, query); + } + + function post(file, action, body) { + return call(file, action, 'POST', body || {}, {}); + } + + // --- DOM helpers --------------------------------------------------------- + + function escapeHtml(value) { + return String(value == null ? '' : value).replace(/[&<>"']/g, function (c) { + return { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]; + }); + } + + function el(id) { + return document.getElementById(id); + } + + function formatBytes(bytes) { + if (!bytes || bytes <= 0) return '0 B'; + const units = ['B', 'KB', 'MB', 'GB', 'TB']; + const i = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1); + const value = bytes / Math.pow(1024, i); + return (value >= 100 || i === 0 ? value.toFixed(0) : value.toFixed(1)) + ' ' + units[i]; + } + + function formatDuration(seconds) { + if (seconds == null) return '—'; + if (seconds < 60) return seconds + 's'; + const days = Math.floor(seconds / 86400); + const hours = Math.floor((seconds % 86400) / 3600); + const minutes = Math.floor((seconds % 3600) / 60); + if (days > 0) return days + 'd ' + hours + 'h'; + if (hours > 0) return hours + 'h ' + minutes + 'm'; + return minutes + 'm'; + } + + function formatRelativeTime(unixSeconds) { + if (!unixSeconds) return '—'; + const diff = Math.max(0, Math.floor(Date.now() / 1000) - unixSeconds); + if (diff < 60) return 'just now'; + if (diff < 3600) return Math.floor(diff / 60) + 'm ago'; + if (diff < 86400) return Math.floor(diff / 3600) + 'h ago'; + return Math.floor(diff / 86400) + 'd ago'; + } + + /** Status string (from libpod's container "State") -> chip color class. */ + function stateChipClass(state) { + switch (state) { + case 'running': return 'podman-chip-good'; + case 'paused': return 'podman-chip-warn'; + case 'exited': + case 'created': return 'podman-chip-neutral'; + default: return 'podman-chip-bad'; + } + } + + function loadingRow(colspan, label) { + return '' + escapeHtml(label || 'Loading…') + ''; + } + + function errorRow(colspan, message) { + return '' + escapeHtml(message) + ''; + } + + // --- Panel router ---------------------------------------------------------- + + const panelModules = {}; + const initialized = {}; + + /** Called by each panel's own JS file (e.g. containers.js) to register itself. */ + function registerPanel(name, module) { + panelModules[name] = module; + } + + function activatePanel(name) { + document.querySelectorAll('.podman-subnav button').forEach(function (btn) { + btn.classList.toggle('active', btn.dataset.panel === name); + }); + document.querySelectorAll('.podman-panel').forEach(function (panel) { + panel.classList.toggle('active', panel.id === 'podman-panel-' + name); + }); + + const module = panelModules[name]; + if (!module) return; + + if (!initialized[name]) { + initialized[name] = true; + if (typeof module.init === 'function') module.init(); + } else if (typeof module.refresh === 'function') { + module.refresh(); + } + } + + function boot() { + const subnav = document.querySelector('.podman-subnav'); + if (!subnav) return; + + subnav.addEventListener('click', function (e) { + const btn = e.target.closest('button[data-panel]'); + if (btn) activatePanel(btn.dataset.panel); + }); + + const refreshBtn = el('podman-refresh-all'); + if (refreshBtn) { + refreshBtn.addEventListener('click', function () { + const active = document.querySelector('.podman-subnav button.active'); + if (active) activatePanel(active.dataset.panel); + }); + } + + // Activate whichever panel is marked active in the initial HTML + // (Dashboard, by default — see Podman.page). + const initial = document.querySelector('.podman-subnav button.active'); + activatePanel(initial ? initial.dataset.panel : 'dashboard'); + } + + document.addEventListener('DOMContentLoaded', boot); + + return { + get: get, + post: post, + escapeHtml: escapeHtml, + el: el, + formatBytes: formatBytes, + formatDuration: formatDuration, + formatRelativeTime: formatRelativeTime, + stateChipClass: stateChipClass, + loadingRow: loadingRow, + errorRow: errorRow, + registerPanel: registerPanel, + activatePanel: activatePanel, + }; +})(); diff --git a/webui/plugins/podman/javascript/compose.js b/webui/plugins/podman/javascript/compose.js new file mode 100644 index 0000000..68f286e --- /dev/null +++ b/webui/plugins/podman/javascript/compose.js @@ -0,0 +1,83 @@ +/** + * javascript/compose.js + * + * Compose panel: project list + read-only YAML view + up/down/pull, + * backed by ajax/compose.php. See that file's header comment — this is + * the one panel whose backend shells out to the `podman compose` CLI, + * because no REST equivalent for Compose exists in libpod. + */ +(function () { + 'use strict'; + const P = window.Podman; + let projects = []; + let selected = null; + + function statusChip(status) { + const cls = status === 'up' ? 'podman-chip-good' : (status === 'down' ? 'podman-chip-neutral' : 'podman-chip-warn'); + return '' + P.escapeHtml(status) + ''; + } + + function renderSidebar() { + P.el('compose-sidebar').innerHTML = projects.map(function (p) { + return '
' + + '
' + P.escapeHtml(p.name) + ' ' + statusChip(p.status) + '
' + + '
' + P.escapeHtml(p.path) + '
' + + '
'; + }).join('') || '
No compose projects under /boot/config/plugins/podman/compose/
'; + } + + function loadYaml(name) { + P.el('compose-title').textContent = name + ' / compose.yaml'; + P.el('compose-yaml').textContent = 'Loading…'; + return P.get('compose', 'get', { project: name }).then(function (data) { + P.el('compose-yaml').textContent = data.yaml; + }).catch(function (err) { + P.el('compose-yaml').textContent = 'Error: ' + err.message; + }); + } + + function selectProject(name) { + selected = name; + renderSidebar(); + loadYaml(name); + } + + function loadProjects() { + return P.get('compose', 'list').then(function (data) { + projects = data; + if (!selected && projects.length > 0) selected = projects[0].name; + renderSidebar(); + if (selected) loadYaml(selected); + }).catch(function (err) { + P.el('compose-sidebar').innerHTML = '
' + P.escapeHtml(err.message) + '
'; + }); + } + + function runAction(action) { + if (!selected) return; + const btn = P.el('compose-action-' + action); + btn.disabled = true; + P.post('compose', action, { project: selected }).then(function (data) { + alert((data.output || 'Done.').slice(0, 2000)); + return loadProjects(); + }).catch(function (err) { + alert('podman compose ' + action + ' failed: ' + err.message); + }).finally(function () { + btn.disabled = false; + }); + } + + function init() { + P.el('compose-sidebar').addEventListener('click', function (e) { + const item = e.target.closest('.podman-compose-proj[data-name]'); + if (item) selectProject(item.dataset.name); + }); + P.el('compose-action-up').addEventListener('click', function () { runAction('up'); }); + P.el('compose-action-down').addEventListener('click', function () { runAction('down'); }); + P.el('compose-action-pull').addEventListener('click', function () { runAction('pull'); }); + + return loadProjects(); + } + + P.registerPanel('compose', { init: init, refresh: loadProjects }); +})(); diff --git a/webui/plugins/podman/javascript/containers.js b/webui/plugins/podman/javascript/containers.js new file mode 100644 index 0000000..0a94783 --- /dev/null +++ b/webui/plugins/podman/javascript/containers.js @@ -0,0 +1,125 @@ +/** + * javascript/containers.js + * + * Containers panel: table of all containers with lifecycle actions + * (start/stop/restart/remove), backed entirely by ajax/containers.php. + */ +(function () { + 'use strict'; + const P = window.Podman; + let allContainers = []; + let filter = 'all'; + let searchTerm = ''; + + function iconLabel(name) { + return P.escapeHtml(name.slice(0, 2).toUpperCase()); + } + + function rowHtml(c) { + const cpuMem = c.state === 'running' + ? 'running' + : ''; + + return '' + + '' + + '' + P.escapeHtml(c.health || c.state) + '' + + '
' + iconLabel(c.name) + '' + P.escapeHtml(c.name) + '
' + + '' + P.escapeHtml(c.image) + '' + + '' + cpuMem + '' + + '' + P.escapeHtml(c.ports.join(', ') || '—') + '' + + '' + P.formatDuration(c.uptimeSeconds) + '' + + '' + actionButtons(c) + '' + + ''; + } + + function actionButtons(c) { + if (c.state === 'running') { + return '' + + '' + + '' + + ''; + } + return '' + + '' + + ''; + } + + function applyFilters() { + return allContainers.filter(function (c) { + if (filter === 'running' && c.state !== 'running') return false; + if (filter === 'stopped' && c.state === 'running') return false; + if (searchTerm && c.name.toLowerCase().indexOf(searchTerm) === -1 && c.image.toLowerCase().indexOf(searchTerm) === -1) return false; + return true; + }); + } + + function renderTable() { + const tbody = P.el('containers-tbody'); + const visible = applyFilters(); + tbody.innerHTML = visible.length + ? visible.map(rowHtml).join('') + : 'No containers match.'; + } + + function renderCounts() { + const running = allContainers.filter(function (c) { return c.state === 'running'; }).length; + P.el('containers-count-all').textContent = 'All ' + allContainers.length; + P.el('containers-count-running').textContent = 'Running ' + running; + P.el('containers-count-stopped').textContent = 'Stopped ' + (allContainers.length - running); + } + + function load() { + const tbody = P.el('containers-tbody'); + tbody.innerHTML = P.loadingRow(7); + return P.get('containers', 'list').then(function (data) { + allContainers = data; + renderCounts(); + renderTable(); + }).catch(function (err) { + tbody.innerHTML = P.errorRow(7, err.message); + }); + } + + function handleAction(id, action, btn) { + const doIt = function (extra) { + btn.disabled = true; + return P.post('containers', action, Object.assign({ id: id }, extra)).then(load).catch(function (err) { + alert('Action failed: ' + err.message); + btn.disabled = false; + }); + }; + if (action === 'remove') { + if (!confirm('Remove this container? This does not remove its volumes.')) return; + doIt({ force: true }); + } else { + doIt({}); + } + } + + function init() { + P.el('containers-search').addEventListener('input', function (e) { + searchTerm = e.target.value.trim().toLowerCase(); + renderTable(); + }); + + document.querySelectorAll('#containers-filterset button').forEach(function (btn) { + btn.addEventListener('click', function () { + document.querySelectorAll('#containers-filterset button').forEach(function (b) { b.classList.remove('active'); }); + btn.classList.add('active'); + filter = btn.dataset.filter; + renderTable(); + }); + }); + + P.el('containers-tbody').addEventListener('click', function (e) { + const btn = e.target.closest('button[data-action]'); + if (!btn || btn.disabled) return; + const row = btn.closest('tr'); + handleAction(row.dataset.id, btn.dataset.action, btn); + }); + + return load(); + } + + P.registerPanel('containers', { init: init, refresh: load }); +})(); diff --git a/webui/plugins/podman/javascript/dashboard.js b/webui/plugins/podman/javascript/dashboard.js new file mode 100644 index 0000000..f7816a6 --- /dev/null +++ b/webui/plugins/podman/javascript/dashboard.js @@ -0,0 +1,52 @@ +/** + * javascript/dashboard.js + * + * Dashboard panel: summary stat tiles fed by ajax/system.php?action=summary. + * The Activity list and the CPU/Memory sparkline in the mockup were + * illustrative sample data with no backing API (libpod has no "recent + * events for a container fleet" convenience endpoint beyond raw + * /events streaming, which is a separate follow-up — see the note + * rendered in place of it below) — rather than fake data pretending to be + * live, this real implementation shows what's genuinely available now + * (the summary counts) and a clear placeholder for what needs the events + * stream, so nobody mistakes a mock for a working feature. + */ +(function () { + 'use strict'; + const P = window.Podman; + + function render(summary) { + if (!summary.reachable) { + P.el('podman-panel-dashboard').innerHTML = + '
' + + 'Cannot reach the Podman API socket (' + P.escapeHtml(summary.socketPath) + '). ' + + 'Is rc.podman running? Try rc.podman status from a terminal.' + + '
'; + return; + } + + P.el('stat-running').textContent = summary.containers.running; + P.el('stat-running-total').textContent = '/ ' + summary.containers.total; + P.el('stat-pods').textContent = summary.pods; + P.el('stat-images').textContent = summary.images; + P.el('stat-volumes').textContent = summary.volumes; + P.el('stat-networks').textContent = summary.networks; + P.el('stat-images-size').textContent = summary.storage.imagesSizeFormatted; + + const meta = P.el('podman-header-meta'); + if (meta) { + meta.innerHTML = + ' podman.sock connected' + + (summary.podmanVersion ? ' · v' + P.escapeHtml(summary.podmanVersion) : '') + + ' · ' + summary.containers.running + ' of ' + summary.containers.total + ' containers running'; + } + } + + function load() { + return P.get('system', 'summary').then(render).catch(function (err) { + P.el('podman-panel-dashboard').innerHTML = '
' + P.escapeHtml(err.message) + '
'; + }); + } + + P.registerPanel('dashboard', { init: load, refresh: load }); +})(); diff --git a/webui/plugins/podman/javascript/images.js b/webui/plugins/podman/javascript/images.js new file mode 100644 index 0000000..608b3a6 --- /dev/null +++ b/webui/plugins/podman/javascript/images.js @@ -0,0 +1,69 @@ +/** + * javascript/images.js + * + * Images panel: table + a "Pull Image" action, backed by ajax/images.php. + */ +(function () { + 'use strict'; + const P = window.Podman; + let images = []; + + function rowHtml(img) { + const created = img.createdAt ? P.formatRelativeTime(img.createdAt) : '—'; + return '' + + '' + + '' + P.escapeHtml(img.repository) + '' + + '' + P.escapeHtml(img.tag) + '' + + '' + P.escapeHtml(img.shortId) + '' + + '' + P.escapeHtml(img.sizeFormatted) + '' + + '' + created + '' + + '' + img.usedBy + '' + + '' + + ''; + } + + function render() { + const tbody = P.el('images-tbody'); + tbody.innerHTML = images.length + ? images.map(rowHtml).join('') + : 'No images.'; + } + + function load() { + const tbody = P.el('images-tbody'); + tbody.innerHTML = P.loadingRow(7); + return P.get('images', 'list').then(function (data) { + images = data; + render(); + }).catch(function (err) { + tbody.innerHTML = P.errorRow(7, err.message); + }); + } + + function init() { + P.el('images-pull-btn').addEventListener('click', function () { + const reference = prompt('Image to pull (e.g. docker.io/library/postgres:16):'); + if (!reference) return; + P.post('images', 'pull', { reference: reference }).then(load).catch(function (err) { + alert('Pull failed: ' + err.message); + }); + }); + + P.el('images-tbody').addEventListener('click', function (e) { + const btn = e.target.closest('button[data-action="remove"]'); + if (!btn || btn.disabled) return; + const id = btn.closest('tr').dataset.id; + if (!confirm('Remove this image?')) return; + btn.disabled = true; + P.post('images', 'remove', { id: id }).then(load).catch(function (err) { + alert('Remove failed: ' + err.message); + btn.disabled = false; + }); + }); + + return load(); + } + + P.registerPanel('images', { init: init, refresh: load }); +})(); diff --git a/webui/plugins/podman/javascript/logs.js b/webui/plugins/podman/javascript/logs.js new file mode 100644 index 0000000..9626f13 --- /dev/null +++ b/webui/plugins/podman/javascript/logs.js @@ -0,0 +1,102 @@ +/** + * javascript/logs.js + * + * Logs panel: container selector sidebar + log pane, backed by + * ajax/containers.php?action=logs. "Follow" polls on an interval rather + * than opening a persistent stream — see javascript/terminal.js for the + * same underlying constraint (PHP-FPM's request lifecycle) applied to a + * different panel. + */ +(function () { + 'use strict'; + const P = window.Podman; + let containers = []; + let selectedId = null; + let follow = true; + let pollHandle = null; + + function levelClass(line) { + if (/\berror\b/i.test(line)) return 'lvl-error'; + if (/\bwarn(ing)?\b/i.test(line)) return 'lvl-warn'; + return ''; + } + + function renderLog(text) { + const pane = P.el('log-pane'); + const atBottom = pane.scrollTop + pane.clientHeight >= pane.scrollHeight - 20; + const lines = text.split('\n').filter(function (l) { return l.length > 0; }); + pane.innerHTML = lines.map(function (line) { + const cls = levelClass(line); + return '
' + P.escapeHtml(line) + '
'; + }).join('') || '
(no output)
'; + if (atBottom) pane.scrollTop = pane.scrollHeight; + } + + function loadLogs() { + if (!selectedId) return Promise.resolve(); + return P.get('containers', 'logs', { id: selectedId, tail: 300 }).then(function (data) { + renderLog(data.text); + }).catch(function (err) { + P.el('log-pane').innerHTML = '
' + P.escapeHtml(err.message) + '
'; + }); + } + + function renderSidebar() { + const side = P.el('logs-sidebar'); + side.innerHTML = containers.map(function (c) { + return '
' + + ' ' + + P.escapeHtml(c.name) + '
'; + }).join(''); + } + + function selectContainer(id) { + selectedId = id; + renderSidebar(); + loadLogs(); + } + + function setPolling(enabled) { + follow = enabled; + if (pollHandle) clearInterval(pollHandle); + if (follow) { + pollHandle = setInterval(loadLogs, 4000); + } + } + + function init() { + P.el('logs-sidebar').addEventListener('click', function (e) { + const item = e.target.closest('.item[data-id]'); + if (item) selectContainer(item.dataset.id); + }); + + document.querySelectorAll('#logs-follow-toggle button').forEach(function (btn) { + btn.addEventListener('click', function () { + document.querySelectorAll('#logs-follow-toggle button').forEach(function (b) { b.classList.remove('active'); }); + btn.classList.add('active'); + setPolling(btn.dataset.follow === 'true'); + }); + }); + + P.el('logs-filter').addEventListener('input', function (e) { + const term = e.target.value.toLowerCase(); + P.el('log-pane').querySelectorAll('.l').forEach(function (line) { + line.style.display = term === '' || line.textContent.toLowerCase().includes(term) ? '' : 'none'; + }); + }); + + return P.get('containers', 'list').then(function (data) { + containers = data; + if (containers.length > 0) { + selectedId = containers[0].id; + } + renderSidebar(); + setPolling(true); + return loadLogs(); + }).catch(function (err) { + P.el('logs-sidebar').innerHTML = '
' + P.escapeHtml(err.message) + '
'; + }); + } + + P.registerPanel('logs', { init: init, refresh: loadLogs }); +})(); diff --git a/webui/plugins/podman/javascript/networks.js b/webui/plugins/podman/javascript/networks.js new file mode 100644 index 0000000..72d5cc4 --- /dev/null +++ b/webui/plugins/podman/javascript/networks.js @@ -0,0 +1,72 @@ +/** + * javascript/networks.js + * + * Networks panel: table + create/remove, backed by ajax/networks.php. + */ +(function () { + 'use strict'; + const P = window.Podman; + let networks = []; + + function rowHtml(n) { + const nameCell = n.isDefault + ? '' + P.escapeHtml(n.name) + ' (default)' + : P.escapeHtml(n.name); + const removeDisabled = n.isDefault || n.containers > 0; + return '' + + '' + + '' + nameCell + '' + + '' + P.escapeHtml(n.driver) + '' + + '' + P.escapeHtml(n.subnet || '—') + '' + + '' + P.escapeHtml(n.gateway || '—') + '' + + '' + n.containers + '' + + '' + + ''; + } + + function render() { + const tbody = P.el('networks-tbody'); + tbody.innerHTML = networks.length + ? networks.map(rowHtml).join('') + : 'No networks.'; + } + + function load() { + const tbody = P.el('networks-tbody'); + tbody.innerHTML = P.loadingRow(6); + return P.get('networks', 'list').then(function (data) { + networks = data; + render(); + }).catch(function (err) { + tbody.innerHTML = P.errorRow(6, err.message); + }); + } + + function init() { + P.el('networks-create-btn').addEventListener('click', function () { + const name = prompt('New network name:'); + if (!name) return; + const subnet = prompt('Subnet (optional, e.g. 10.89.2.0/24):') || undefined; + P.post('networks', 'create', { name: name, driver: 'bridge', subnet: subnet }).then(load).catch(function (err) { + alert('Create failed: ' + err.message); + }); + }); + + P.el('networks-tbody').addEventListener('click', function (e) { + const btn = e.target.closest('button[data-action="remove"]'); + if (!btn || btn.disabled) return; + const name = btn.closest('tr').dataset.name; + if (!confirm('Remove network "' + name + '"?')) return; + btn.disabled = true; + P.post('networks', 'remove', { name: name }).then(load).catch(function (err) { + alert('Remove failed: ' + err.message); + btn.disabled = false; + }); + }); + + return load(); + } + + P.registerPanel('networks', { init: init, refresh: load }); +})(); diff --git a/webui/plugins/podman/javascript/pods.js b/webui/plugins/podman/javascript/pods.js new file mode 100644 index 0000000..4e07fbe --- /dev/null +++ b/webui/plugins/podman/javascript/pods.js @@ -0,0 +1,50 @@ +/** + * javascript/pods.js + * + * Pods panel: one card per pod with its member containers nested inside, + * backed by ajax/pods.php (which itself cross-references containers.php's + * data server-side — see that file for why). + */ +(function () { + 'use strict'; + const P = window.Podman; + + function memberRow(m) { + return '' + + '' + + '' + P.escapeHtml(m.name) + '' + + '' + P.escapeHtml(m.image) + '' + + '' + P.escapeHtml(m.state) + '' + + ''; + } + + function podCard(pod) { + const members = pod.members.length + ? pod.members.map(memberRow).join('') + : 'No member containers'; + + return '' + + '
' + + '
' + + '' + P.escapeHtml(pod.status) + '' + + '' + P.escapeHtml(pod.name) + '' + + '' + pod.containersTotal + ' container(s)' + + '
' + + '
' + + '' + members + '
ContainerImageStatus
' + + '
'; + } + + function load() { + const container = P.el('podman-panel-pods'); + return P.get('pods', 'list').then(function (pods) { + container.innerHTML = pods.length + ? pods.map(podCard).join('') + : '
No pods yet.
'; + }).catch(function (err) { + container.innerHTML = '
' + P.escapeHtml(err.message) + '
'; + }); + } + + P.registerPanel('pods', { init: load, refresh: load }); +})(); diff --git a/webui/plugins/podman/javascript/settings.js b/webui/plugins/podman/javascript/settings.js new file mode 100644 index 0000000..83f9dec --- /dev/null +++ b/webui/plugins/podman/javascript/settings.js @@ -0,0 +1,95 @@ +/** + * javascript/settings.js + * + * Settings panel: reads/writes unraid-podman's own configuration via + * ajax/settings.php (podman.cfg + the autostart list) — not a + * PodmanClient/libpod concern, see that file's header comment. + */ +(function () { + 'use strict'; + const P = window.Podman; + let autostartNames = []; + + function renderAutostart() { + const tbody = P.el('autostart-tbody'); + tbody.innerHTML = autostartNames.length + ? autostartNames.map(function (name, i) { + return '' + + '' + (i + 1) + '' + + '' + P.escapeHtml(name) + '' + + '' + + '' + + '' + + '' + + ''; + }).join('') + : 'No containers in the autostart chain.'; + } + + function saveAutostart() { + return P.post('settings', 'autostart_save', { names: autostartNames }).catch(function (err) { + alert('Could not save autostart order: ' + err.message); + }); + } + + function fillForm(settings) { + P.el('settings-storage-path').value = settings.storagePath; + P.el('settings-storage-size').value = settings.storageImageSizeGb; + P.el('settings-enabled').checked = settings.enabled; + P.el('settings-stop-timeout').value = settings.stopTimeoutSeconds; + + autostartNames = settings.autostart.slice(); + renderAutostart(); + + const versions = settings.packageVersions || {}; + const order = ['PODMAN', 'CONMON', 'CRUN', 'NETAVARK', 'AARDVARK_DNS', 'PASST', 'FUSE_OVERLAYFS']; + P.el('settings-package-versions').textContent = order + .map(function (k) { return k.toLowerCase().replace('_', '-') + ' ' + (versions[k + '_INSTALLED_VERSION'] || '?'); }) + .join(' · '); + } + + function load() { + return P.get('settings', 'get').then(fillForm).catch(function (err) { + alert('Could not load settings: ' + err.message); + }); + } + + function save() { + const body = { + storagePath: P.el('settings-storage-path').value.trim(), + storageImageSizeGb: parseInt(P.el('settings-storage-size').value, 10) || 20, + enabled: P.el('settings-enabled').checked, + stopTimeoutSeconds: parseInt(P.el('settings-stop-timeout').value, 10) || 10, + }; + return P.post('settings', 'save', body).then(function () { + alert('Saved. Restart podman (rc.podman restart) to apply storage/enabled changes.'); + }).catch(function (err) { + alert('Save failed: ' + err.message); + }); + } + + function init() { + P.el('settings-save-btn').addEventListener('click', save); + + P.el('autostart-tbody').addEventListener('click', function (e) { + const btn = e.target.closest('button[data-action]'); + if (!btn) return; + const i = parseInt(btn.closest('tr').dataset.index, 10); + const action = btn.dataset.action; + + if (action === 'remove') { + autostartNames.splice(i, 1); + } else if (action === 'up' && i > 0) { + [autostartNames[i - 1], autostartNames[i]] = [autostartNames[i], autostartNames[i - 1]]; + } else if (action === 'down' && i < autostartNames.length - 1) { + [autostartNames[i + 1], autostartNames[i]] = [autostartNames[i], autostartNames[i + 1]]; + } + renderAutostart(); + saveAutostart(); + }); + + return load(); + } + + P.registerPanel('settings', { init: init, refresh: load }); +})(); diff --git a/webui/plugins/podman/javascript/terminal.js b/webui/plugins/podman/javascript/terminal.js new file mode 100644 index 0000000..262637e --- /dev/null +++ b/webui/plugins/podman/javascript/terminal.js @@ -0,0 +1,89 @@ +/** + * javascript/terminal.js + * + * Terminal panel: one-command-at-a-time exec via ajax/exec.php. See that + * file's header comment for the full, honest explanation of why this is + * "type a command, see its output" rather than a true interactive PTY — + * the short version is that libpod's interactive exec needs a persistent + * bidirectional connection this PHP/AJAX stack doesn't have, and faking + * interactivity on top of that would break the moment a user ran anything + * that expects a real terminal (vim, an interactive prompt, etc). + * + * `cd` is handled client-side: this module tracks a per-session `cwd` and + * passes it as the exec's working directory on every call, so at least + * directory navigation feels persistent even though nothing else is. + */ +(function () { + 'use strict'; + const P = window.Podman; + let cwd = '/'; + let containerId = null; + + function appendLine(html) { + const out = P.el('term-output'); + const div = document.createElement('div'); + div.innerHTML = html; + out.appendChild(div); + out.scrollTop = out.scrollHeight; + } + + function promptHtml() { + return 'root:' + P.escapeHtml(cwd) + '$'; + } + + function runCommand(cmd) { + appendLine(promptHtml() + ' ' + P.escapeHtml(cmd)); + + // `cd ` is intercepted client-side (see file header) rather than + // sent as a real command, since a one-shot exec has no way to report + // "the working directory changed" back to us otherwise. + const cdMatch = cmd.trim().match(/^cd\s+(\S+)$/); + if (cdMatch) { + cwd = cdMatch[1].startsWith('/') ? cdMatch[1] : (cwd.replace(/\/$/, '') + '/' + cdMatch[1]); + return Promise.resolve(); + } + + return P.post('exec', 'run', { id: containerId, cmd: cmd, cwd: cwd }).then(function (data) { + if (data.output) appendLine('' + P.escapeHtml(data.output).replace(/\n/g, '
') + '
'); + }).catch(function (err) { + appendLine('' + P.escapeHtml(err.message) + ''); + }); + } + + function populateContainerSelect(containers) { + const select = P.el('term-container-select'); + select.innerHTML = containers + .filter(function (c) { return c.state === 'running'; }) + .map(function (c) { return ''; }) + .join(''); + containerId = select.value || null; + } + + function init() { + const input = P.el('term-input'); + + P.el('term-container-select').addEventListener('change', function (e) { + containerId = e.target.value; + cwd = '/'; + P.el('term-output').innerHTML = ''; + }); + + input.addEventListener('keydown', function (e) { + if (e.key !== 'Enter') return; + const cmd = input.value; + input.value = ''; + if (!containerId) { + appendLine('No running container selected.'); + return; + } + if (cmd.trim() === '') return; + runCommand(cmd); + }); + + return P.get('containers', 'list').then(populateContainerSelect).catch(function (err) { + appendLine('' + P.escapeHtml(err.message) + ''); + }); + } + + P.registerPanel('terminal', { init: init }); +})(); diff --git a/webui/plugins/podman/javascript/volumes.js b/webui/plugins/podman/javascript/volumes.js new file mode 100644 index 0000000..6c6581c --- /dev/null +++ b/webui/plugins/podman/javascript/volumes.js @@ -0,0 +1,68 @@ +/** + * javascript/volumes.js + * + * Volumes panel: named-volume table + create/remove, backed by + * ajax/volumes.php. Bind mounts are deliberately not shown here — see + * that file's header comment. + */ +(function () { + 'use strict'; + const P = window.Podman; + let volumes = []; + + function rowHtml(v) { + return '' + + '' + + '' + P.escapeHtml(v.name) + '' + + '' + P.escapeHtml(v.driver) + '' + + '' + P.escapeHtml(v.mountpoint) + '' + + '' + v.usedBy + '' + + '' + + ''; + } + + function render() { + const tbody = P.el('volumes-tbody'); + tbody.innerHTML = volumes.length + ? volumes.map(rowHtml).join('') + : 'No named volumes.'; + } + + function load() { + const tbody = P.el('volumes-tbody'); + tbody.innerHTML = P.loadingRow(5); + return P.get('volumes', 'list').then(function (data) { + volumes = data; + render(); + }).catch(function (err) { + tbody.innerHTML = P.errorRow(5, err.message); + }); + } + + function init() { + P.el('volumes-create-btn').addEventListener('click', function () { + const name = prompt('New volume name:'); + if (!name) return; + P.post('volumes', 'create', { name: name }).then(load).catch(function (err) { + alert('Create failed: ' + err.message); + }); + }); + + P.el('volumes-tbody').addEventListener('click', function (e) { + const btn = e.target.closest('button[data-action="remove"]'); + if (!btn || btn.disabled) return; + const name = btn.closest('tr').dataset.name; + if (!confirm('Remove volume "' + name + '"? This deletes its data.')) return; + btn.disabled = true; + P.post('volumes', 'remove', { name: name }).then(load).catch(function (err) { + alert('Remove failed: ' + err.message); + btn.disabled = false; + }); + }); + + return load(); + } + + P.registerPanel('volumes', { init: init, refresh: load }); +})(); diff --git a/webui/plugins/podman/styles/podman.css b/webui/plugins/podman/styles/podman.css new file mode 100644 index 0000000..60c4ae5 --- /dev/null +++ b/webui/plugins/podman/styles/podman.css @@ -0,0 +1,225 @@ +/** + * styles/podman.css + * + * Design tokens and component styles for the Podman plugin page. Ported + * directly from the approved mockup (webui/mockups/prototype.html) so the + * real implementation is visually identical to what was reviewed — the + * masthead/main-nav chrome from that mockup is NOT included here, since + * Podman.page renders inside Unraid's own real chrome, not a recreation + * of it. + * + * Token strategy: custom properties on :root, redefined under + * prefers-color-scheme (OS default) and under [data-theme] (explicit + * override, e.g. from Unraid's own theme setting) — component rules below + * only ever reference the tokens, never hardcode a color, so both themes + * stay in sync automatically. See docs/ARCHITECTURE.md section 18. + */ + +.podman-plugin { + --bg: #eef0f2; --surface: #ffffff; --surface-2: #f4f5f7; --surface-3: #e9ebee; + --border: #dde1e6; --text: #1c2024; --text-dim: #5b6572; --text-faint: #8a94a1; + --accent: #d8541a; --accent-strong: #b8420f; --accent-contrast: #fff8f3; + --good: #1a8f4c; --good-bg: #e4f6ea; --warn: #9a6b00; --warn-bg: #fdf1d6; + --bad: #c22b3a; --bad-bg: #fbe6e8; --neutral: #5b6572; --neutral-bg: #e9ebee; + --shadow: 0 1px 2px rgba(20, 22, 26, .06), 0 4px 12px rgba(20, 22, 26, .05); + --font-ui: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; + --font-mono: ui-monospace, "SF Mono", "Cascadia Code", "Roboto Mono", Consolas, "Liberation Mono", monospace; + font-family: var(--font-ui); + color: var(--text); + font-size: 14px; + line-height: 1.5; +} +@media (prefers-color-scheme: dark) { + .podman-plugin { + --bg: #15171b; --surface: #1c1f24; --surface-2: #23262c; --surface-3: #2b2f36; + --border: #34383f; --text: #e7e9ec; --text-dim: #9aa1ab; --text-faint: #6b7280; + --accent: #ef7f3f; --accent-strong: #f6975f; --accent-contrast: #1a1002; + --good: #4cc785; --good-bg: #123322; --warn: #e0b23d; --warn-bg: #3a2e0d; + --bad: #ef6470; --bad-bg: #3a1519; --neutral: #9aa1ab; --neutral-bg: #2b2f36; + --shadow: 0 1px 2px rgba(0,0,0,.3), 0 8px 24px rgba(0,0,0,.35); + } +} +:root[data-theme="dark"] .podman-plugin { + --bg: #15171b; --surface: #1c1f24; --surface-2: #23262c; --surface-3: #2b2f36; + --border: #34383f; --text: #e7e9ec; --text-dim: #9aa1ab; --text-faint: #6b7280; + --accent: #ef7f3f; --accent-strong: #f6975f; --accent-contrast: #1a1002; + --good: #4cc785; --good-bg: #123322; --warn: #e0b23d; --warn-bg: #3a2e0d; + --bad: #ef6470; --bad-bg: #3a1519; --neutral: #9aa1ab; --neutral-bg: #2b2f36; + --shadow: 0 1px 2px rgba(0,0,0,.3), 0 8px 24px rgba(0,0,0,.35); +} +:root[data-theme="light"] .podman-plugin { + --bg: #eef0f2; --surface: #ffffff; --surface-2: #f4f5f7; --surface-3: #e9ebee; + --border: #dde1e6; --text: #1c2024; --text-dim: #5b6572; --text-faint: #8a94a1; + --accent: #d8541a; --accent-strong: #b8420f; --accent-contrast: #fff8f3; + --good: #1a8f4c; --good-bg: #e4f6ea; --warn: #9a6b00; --warn-bg: #fdf1d6; + --bad: #c22b3a; --bad-bg: #fbe6e8; --neutral: #5b6572; --neutral-bg: #e9ebee; + --shadow: 0 1px 2px rgba(20,22,26,.06), 0 4px 12px rgba(20,22,26,.05); +} + +.podman-plugin * { box-sizing: border-box; } +.podman-plugin h1, .podman-plugin h2, .podman-plugin h3 { text-wrap: balance; margin: 0; font-weight: 600; } +.podman-plugin .tnum { font-variant-numeric: tabular-nums; } +.podman-plugin .mono { font-family: var(--font-mono); } +.podman-plugin :focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: 3px; } + +.podman-pagehead { + display: flex; align-items: flex-end; justify-content: space-between; + gap: 16px; flex-wrap: wrap; padding: 4px 0 0; +} +.podman-pagehead .titlewrap { display: flex; align-items: center; gap: 12px; } +.podman-pagehead .icon { + width: 38px; height: 38px; border-radius: 9px; background: var(--surface-3); + display: grid; place-items: center; color: var(--accent); border: 1px solid var(--border); +} +.podman-pagehead h1 { font-size: 21px; } +.podman-pagehead .meta { font-size: 12.5px; color: var(--text-dim); margin-top: 2px; } +.podman-pagehead .meta .dot-good { color: var(--good); } +.podman-pagehead .meta .dot-bad { color: var(--bad); } + +.podman-btn { + appearance: none; border: 1px solid var(--border); background: var(--surface); color: var(--text); + padding: 8px 14px; border-radius: 7px; font-size: 13px; font-weight: 600; cursor: pointer; + display: inline-flex; align-items: center; gap: 6px; transition: border-color .12s, background .12s; + font-family: var(--font-ui); +} +.podman-btn:hover { border-color: var(--text-faint); } +.podman-btn-primary { background: var(--accent); border-color: var(--accent); color: var(--accent-contrast); } +.podman-btn-primary:hover { background: var(--accent-strong); border-color: var(--accent-strong); } +.podman-btn-danger { color: var(--bad); } +.podman-btn-danger:hover { border-color: var(--bad); } +.podman-btn-icon { padding: 6px 8px; } +.podman-btn[disabled] { opacity: .4; cursor: not-allowed; } + +.podman-subnav { + margin: 14px 0 0; padding: 0; display: flex; gap: 4px; border-bottom: 1px solid var(--border); + overflow-x: auto; list-style: none; +} +.podman-subnav button { + appearance: none; background: none; border: none; border-bottom: 2px solid transparent; + color: var(--text-dim); padding: 10px 12px; font-size: 13px; font-weight: 600; cursor: pointer; + white-space: nowrap; display: flex; align-items: center; gap: 6px; font-family: var(--font-ui); +} +.podman-subnav button:hover { color: var(--text); } +.podman-subnav button.active { color: var(--accent-strong); border-bottom-color: var(--accent); } +.podman-subnav button .n { font-size: 10.5px; padding: 1px 5px; border-radius: 8px; background: var(--surface-3); color: var(--text-dim); } + +.podman-main { padding: 20px 0 48px; } +.podman-panel { display: none; } +.podman-panel.active { display: block; } + +.podman-card { background: var(--surface); border: 1px solid var(--border); border-radius: 10px; box-shadow: var(--shadow); } +.podman-card-pad { padding: 16px 18px; } +.podman-card-head { display: flex; align-items: center; justify-content: space-between; padding: 14px 18px; border-bottom: 1px solid var(--border); } +.podman-card-head h2 { font-size: 14.5px; } +.podman-card-head .sub { font-size: 12px; color: var(--text-faint); font-weight: 500; } + +.podman-grid { display: grid; gap: 14px; } +.podman-stat-grid { grid-template-columns: repeat(6, 1fr); } +@media (max-width: 1080px) { .podman-stat-grid { grid-template-columns: repeat(3, 1fr); } } +@media (max-width: 620px) { .podman-stat-grid { grid-template-columns: repeat(2, 1fr); } } + +.podman-stat { background: var(--surface); border: 1px solid var(--border); border-radius: 10px; padding: 14px 16px; box-shadow: var(--shadow); } +.podman-stat .label { font-size: 11px; text-transform: uppercase; letter-spacing: .05em; color: var(--text-faint); font-weight: 700; } +.podman-stat .value { font-size: 24px; font-weight: 700; margin-top: 6px; } +.podman-stat .value small { font-size: 13px; color: var(--text-dim); font-weight: 600; } +.podman-stat .bar { height: 5px; border-radius: 3px; background: var(--surface-3); margin-top: 10px; overflow: hidden; } +.podman-stat .bar > span { display: block; height: 100%; background: var(--accent); border-radius: 3px; } + +.podman-chip { + display: inline-flex; align-items: center; gap: 5px; padding: 3px 9px; border-radius: 100px; + font-size: 11.5px; font-weight: 700; letter-spacing: .01em; white-space: nowrap; +} +.podman-chip .d { width: 6px; height: 6px; border-radius: 50%; background: currentColor; flex: none; } +.podman-chip-good { background: var(--good-bg); color: var(--good); } +.podman-chip-warn { background: var(--warn-bg); color: var(--warn); } +.podman-chip-bad { background: var(--bad-bg); color: var(--bad); } +.podman-chip-neutral { background: var(--neutral-bg); color: var(--neutral); } + +.podman-plugin table { width: 100%; border-collapse: collapse; font-size: 13px; } +.podman-plugin thead th { + text-align: left; font-size: 11px; text-transform: uppercase; letter-spacing: .04em; + color: var(--text-faint); font-weight: 700; padding: 10px 18px; border-bottom: 1px solid var(--border); +} +.podman-plugin tbody td { padding: 11px 18px; border-bottom: 1px solid var(--border); vertical-align: middle; } +.podman-plugin tbody tr:last-child td { border-bottom: none; } +.podman-plugin tbody tr:hover { background: var(--surface-2); } +.podman-table-wrap { overflow-x: auto; } +.podman-row-name { display: flex; align-items: center; gap: 10px; font-weight: 600; } +.podman-row-name .ico { + width: 26px; height: 26px; border-radius: 6px; flex: none; background: var(--surface-3); + display: grid; place-items: center; font-size: 12px; border: 1px solid var(--border); color: var(--text-dim); +} +.podman-row-sub { font-size: 11.5px; color: var(--text-faint); font-weight: 500; margin-top: 1px; } +.podman-actions { display: flex; gap: 4px; justify-content: flex-end; } + +.podman-usage-mini { display: flex; align-items: center; gap: 8px; min-width: 110px; } +.podman-usage-mini .track { flex: 1; height: 5px; border-radius: 3px; background: var(--surface-3); overflow: hidden; } +.podman-usage-mini .track > span { display: block; height: 100%; background: var(--accent); } +.podman-usage-mini .num { font-size: 11.5px; color: var(--text-dim); width: 34px; text-align: right; } + +.podman-toolbar { display: flex; align-items: center; gap: 10px; padding: 14px 18px; border-bottom: 1px solid var(--border); flex-wrap: wrap; } +.podman-search { flex: 1; min-width: 180px; background: var(--surface-2); border: 1px solid var(--border); border-radius: 7px; padding: 7px 11px; font-size: 13px; color: var(--text); font-family: var(--font-ui); } +.podman-search::placeholder { color: var(--text-faint); } + +.podman-two-col { display: grid; grid-template-columns: 1.3fr 1fr; gap: 14px; align-items: start; } +@media (max-width: 920px) { .podman-two-col { grid-template-columns: 1fr; } } + +.podman-activity-list { list-style: none; margin: 0; padding: 6px 0; } +.podman-activity-list li { display: flex; gap: 10px; padding: 9px 18px; border-bottom: 1px solid var(--border); font-size: 12.5px; } +.podman-activity-list li:last-child { border-bottom: none; } +.podman-activity-list .dot { width: 7px; height: 7px; border-radius: 50%; margin-top: 5px; flex: none; } +.podman-activity-list .when { color: var(--text-faint); font-size: 11px; margin-top: 1px; } + +.podman-empty-note { padding: 28px 18px; text-align: center; color: var(--text-faint); font-size: 13px; } + +.podman-pod-card { border: 1px solid var(--border); border-radius: 10px; overflow: hidden; margin-bottom: 14px; background: var(--surface); box-shadow: var(--shadow); } +.podman-pod-head { display: flex; align-items: center; gap: 10px; padding: 13px 16px; background: var(--surface-2); border-bottom: 1px solid var(--border); } +.podman-pod-head .name { font-weight: 700; font-size: 13.5px; } +.podman-pod-head .infra { font-size: 11.5px; color: var(--text-faint); } + +.podman-logs-layout { display: grid; grid-template-columns: 200px 1fr; min-height: 460px; } +@media (max-width: 760px) { .podman-logs-layout { grid-template-columns: 1fr; } } +.podman-logs-side { border-right: 1px solid var(--border); } +.podman-logs-side .item { padding: 10px 14px; font-size: 12.5px; border-bottom: 1px solid var(--border); cursor: pointer; display: flex; align-items: center; gap: 8px; } +.podman-logs-side .item.active { background: var(--surface-2); font-weight: 700; box-shadow: inset 2px 0 0 var(--accent); } +.podman-log-pane { + background: #0f1114; color: #c7ccd4; font-family: var(--font-mono); font-size: 12.3px; + padding: 14px 16px; height: 400px; overflow-y: auto; line-height: 1.65; +} +.podman-log-pane .l { white-space: pre-wrap; word-break: break-word; } +.podman-log-pane .ts { color: #6b7280; } +.podman-log-pane .lvl-warn { color: #e0b23d; } +.podman-log-pane .lvl-error { color: #ef6470; } + +.podman-term { background: #0f1114; color: #d7dbe0; font-family: var(--font-mono); font-size: 12.6px; border-radius: 8px; padding: 14px 16px; height: 380px; overflow-y: auto; line-height: 1.7; } +.podman-term .prompt { color: #4cc785; } +.podman-term .path { color: #6fb2f5; } +.podman-term-input { + width: 100%; margin-top: 10px; background: #0f1114; color: #d7dbe0; border: 1px solid var(--border); + border-radius: 6px; padding: 8px 10px; font-family: var(--font-mono); font-size: 12.6px; +} + +.podman-compose-layout { display: grid; grid-template-columns: 230px 1fr; min-height: 480px; } +@media (max-width: 800px) { .podman-compose-layout { grid-template-columns: 1fr; } } +.podman-compose-side { border-right: 1px solid var(--border); } +.podman-compose-proj { padding: 12px 14px; border-bottom: 1px solid var(--border); cursor: pointer; } +.podman-compose-proj.active { background: var(--surface-2); box-shadow: inset 2px 0 0 var(--accent); } +.podman-compose-proj .path { font-size: 11px; color: var(--text-faint); margin-top: 2px; font-family: var(--font-mono); } +.podman-yaml { background: #0f1114; color: #c7ccd4; font-family: var(--font-mono); font-size: 12.4px; padding: 16px 18px; height: 420px; overflow: auto; line-height: 1.7; white-space: pre-wrap; } + +.podman-field-row { display: grid; grid-template-columns: 220px 1fr; gap: 16px; padding: 14px 18px; border-bottom: 1px solid var(--border); align-items: start; } +.podman-field-row:last-child { border-bottom: none; } +.podman-field-row label { font-weight: 600; font-size: 13px; } +.podman-field-row .hint { font-size: 11.5px; color: var(--text-faint); margin-top: 4px; max-width: 46ch; } +.podman-field-row input[type="text"], .podman-field-row input[type="number"], .podman-field-row select { + background: var(--surface-2); border: 1px solid var(--border); border-radius: 7px; padding: 8px 10px; + font-size: 13px; color: var(--text); width: 100%; max-width: 340px; font-family: var(--font-ui); +} +.podman-danger-card { border-color: color-mix(in srgb, var(--bad) 40%, var(--border)); } +.podman-danger-card .podman-card-head { border-bottom-color: color-mix(in srgb, var(--bad) 30%, var(--border)); } +.podman-danger-card .podman-card-head h2 { color: var(--bad); } + +.podman-badge-update { font-size: 10px; font-weight: 700; color: var(--accent-strong); background: color-mix(in srgb, var(--accent) 16%, transparent); padding: 2px 7px; border-radius: 100px; margin-left: 8px; } + +.podman-loading, .podman-error { padding: 32px 18px; text-align: center; color: var(--text-faint); font-size: 13px; } +.podman-error { color: var(--bad); }