From 95af12e1d74e14f8e7ff3703b5fbcebd45725516 Mon Sep 17 00:00:00 2001 From: Adrien Mannocci Date: Wed, 21 Oct 2026 19:51:17 +0200 Subject: [PATCH 1/3] feat: release ignity as deb, rpm and apk packages Build static (musl) execline, s6 and s6-portable-utils in a container and bundle them with ignity into deb, rpm and apk packages for amd64 and arm64, published on GitHub Releases. Same layout as techcode-io/terranova. - distributions: build script, Containerfile and nfpm configs - scripts: binds (container, nfpm, git, gh) and tasks (build, package, release, project); poe build, package:{deb,rpm,apk}, release:{pre,post} - stage3: execline-cd and /usr/libexec/ignity/wait, as bash owns cd, umask and wait on rpm distributions; install-ignity.sh links the wait - packages: GNU findutils and grep on alpine (fix-perms, gen-accounts) - tests: IGNITY_INSTALL=package runs the suite against the package, portable owner matching, no-start services get time to print - ci: package per arch, attest, run the suite from the packages on debian, alpine and fedora; pre-release and release workflows - docs: install from a package, build and release --- .claude/CLAUDE.md | 15 +- .github/workflows/ci.yml | 100 ++++++++++- .github/workflows/env-install/action.yml | 16 ++ .github/workflows/pre-release.yml | 42 +++++ .github/workflows/release.yml | 76 +++++++++ README.md | 58 +++++++ distributions/build/Containerfile | 10 ++ distributions/build/build-ignity.sh | 138 ++++++++++++++++ distributions/packages/nfpm.amd64.yaml | 87 ++++++++++ distributions/packages/nfpm.arm64.yaml | 87 ++++++++++ pyproject.toml | 60 ++++++- scripts/__init__.py | 0 scripts/binds/__init__.py | 45 +++++ scripts/binds/container.py | 57 +++++++ scripts/binds/gh.py | 29 ++++ scripts/binds/git.py | 41 +++++ scripts/binds/nfpm.py | 24 +++ scripts/tasks/__init__.py | 0 scripts/tasks/build.py | 29 ++++ scripts/tasks/package.py | 40 +++++ scripts/{ => tasks}/project.py | 4 +- scripts/tasks/release.py | 156 ++++++++++++++++++ scripts/utils.py | 10 ++ src/etc/s6/boot/stage3 | 6 +- src/usr/src/install-ignity.sh | 4 + tests/conftest.py | 48 +++++- tests/fixtures/capabilities/Containerfile.tpl | 3 +- tests/fixtures/preboot/Containerfile.tpl | 3 +- tests/test_perms.py | 4 +- tests/test_services.py | 5 +- uv.lock | 4 +- 31 files changed, 1176 insertions(+), 25 deletions(-) create mode 100644 .github/workflows/env-install/action.yml create mode 100644 .github/workflows/pre-release.yml create mode 100644 .github/workflows/release.yml create mode 100644 distributions/build/Containerfile create mode 100644 distributions/build/build-ignity.sh create mode 100644 distributions/packages/nfpm.amd64.yaml create mode 100644 distributions/packages/nfpm.arm64.yaml create mode 100644 scripts/__init__.py create mode 100644 scripts/binds/__init__.py create mode 100644 scripts/binds/container.py create mode 100644 scripts/binds/gh.py create mode 100644 scripts/binds/git.py create mode 100644 scripts/binds/nfpm.py create mode 100644 scripts/tasks/__init__.py create mode 100644 scripts/tasks/build.py create mode 100644 scripts/tasks/package.py rename scripts/{ => tasks}/project.py (97%) create mode 100644 scripts/tasks/release.py create mode 100644 scripts/utils.py diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index aba20cf..3a42463 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -24,8 +24,14 @@ uv run poe test tests/test_boot.py::test_execlineb_installed # Single test **Building:** ```bash uv run poe package # Create dist/ignity.tar.gz tarball from src/ +uv run poe build # Static (musl) execline/s6/s6-portable-utils + src/ into dist/ignity, in a container +uv run poe package:deb # Also package:rpm, package:apk (Linux only): nfpm, dist/ignity--linux-. +IGNITY_INSTALL=package DOCKER_BASE_IMAGE=alpine:3 uv run poe test # Suite against the package in dist/ ``` +**Releasing** (GitHub Actions, workflow_dispatch, same flow as techcode-io/terranova): +`Pre release` (version) opens a bump PR (`release:pre`) -> `Release` (next `x.y.z-dev`) reuses CI, tags `x.y.z` (no `v`), attaches the packages (`release`) and opens the next iteration PR (`release:post`). The version lives in `pyproject.toml` and `distributions/packages/nfpm.{amd64,arm64}.yaml`. + All commands require: - uv (modern Python package manager) - podman (container runtime for tests) @@ -80,7 +86,8 @@ Containers of a pod share files through a user-mounted volume. No env is handed - `/usr/bin/with-retries` — Retry command execution with backoff **Installation:** -- `/usr/src/install-ignity.sh` — Compiles and installs s6, execline, skalibs from source +- `/usr/src/install-ignity.sh` — Compiles and installs s6, execline, skalibs from source (Debian/Ubuntu). Its `readonly *_VERSION`/`*_URL` are the single source of truth for the skarnet versions, also read by `distributions/build/build-ignity.sh` and bumped by `poe project:upgrade` +- Linux packages (deb/rpm/apk, amd64/arm64): `distributions/build/` builds the bundle (static binaries in `/bin`, s6 helpers in `/usr/libexec`), `distributions/packages/nfpm..yaml` packages it. The rpm leaves out execline's `cd`, `umask` and `wait` (owned by bash there), so ignity scripts must not use them (`execline-cd`, `execline-umask`, `/usr/libexec/ignity/wait`, which install-ignity.sh links too) ## Container Configuration (User-Provided) @@ -105,9 +112,10 @@ Scripts use numbered prefixes (00-, 01-, etc.) for deterministic ordering. Conve - Each module sets `IMAGE_KIND`; the shared `image` fixture (conftest) builds it from `tests/fixtures//`: its own `Containerfile.tpl` if present, else ignity + `rootfs/` (see `DEFAULT_CONTAINERFILE`). Extra images can still be built with `build_image()` - Test fixtures include rootfs overlays with pre-configured services/scripts - conftest.py provides the `image` fixture, `build_image(kind)`, `run_in_container(image, cmd, extra_args)`, `env_args(**env)` (`-e` arguments) and `READ_ONLY` (read-only root arguments) +- Containerfile templates install ignity with `{{IGNITY_INSTALL}}`: from source by default, from the dist/ package matching `DOCKER_BASE_IMAGE` (debian/ubuntu/fedora/alpine) with `IGNITY_INSTALL=package` **Test Flow:** -1. `build_image()` renders Containerfile.tpl (substitutes `{{DOCKER_BASE_IMAGE}}`), builds with random tag via podman +1. `build_image()` renders Containerfile.tpl (substitutes `{{DOCKER_BASE_IMAGE}}` and `{{IGNITY_INSTALL}}`), builds with random tag via podman 2. `run_in_container()` executes shell command inside container with `/init` entrypoint 3. Tests assert on exit codes and stdout content @@ -126,9 +134,10 @@ Follow conventional commits: `type: description` (types: build, ci, docs, feat, **Key Files:** - `pyproject.toml` — Project manifest, poe tasks, uv config +- `scripts/binds/` — Thin wrappers around container (docker/podman), nfpm, git, gh; `scripts/tasks/` — poe task implementations (build, package, release, project) - `tests/conftest.py` — Pytest helpers, immutable tuples, type annotations - `tests/fixtures/` — Containerfile templates and rootfs overlays (not actual test code) -- `.github/workflows/ci.yml` — Runs `uv run poe test` on PR/push +- `.github/workflows/ci.yml` — On PR/push (and reused by release): `poe test` (source), `package` per arch (native runners), `sanitize` runs the suite from the packages on debian/ubuntu/alpine/fedora **Environment Variables (Runtime):** - `IGNITY_CMD_WAIT_FOR_SERVICES` — Wait for services before CMD (0/1) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2c6ef2d..c86f224 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,6 +2,7 @@ name: CI on: + workflow_call: ~ pull_request: push: branches: @@ -10,14 +11,109 @@ on: permissions: contents: read +env: + # Ref: https://docs.astral.sh/uv/concepts/projects/sync/#checking-if-the-lockfile-is-up-to-date + UV_FROZEN: "true" + +## Concurrency only allowed in the main branch. +## So old builds running for old commits within the same Pull Request are cancelled +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} + jobs: test: runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 - - name: Install uv - uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0 + - name: Install environment + uses: ./.github/workflows/env-install + + - name: Run tests + run: uv run poe test + + # The bundle is packaged in the job building it: artifacts don't keep file modes + package: + permissions: + id-token: write + attestations: write + artifact-metadata: write + strategy: + matrix: + os: + - runner: ubuntu-24.04 + target: linux-amd64 + - runner: ubuntu-24.04-arm + target: linux-arm64 + runs-on: ${{ matrix.os.runner }} + steps: + - uses: actions/checkout@v7 + + - name: Install environment + uses: ./.github/workflows/env-install + + - name: Build + run: uv run poe build + + - name: Build deb packages + run: uv run poe package:deb + + - name: Build rpm packages + run: uv run poe package:rpm + + - name: Build apk packages + run: uv run poe package:apk + + - uses: actions/attest@v4 + with: + subject-path: | + dist/*.deb + dist/*.rpm + dist/*.apk + + - uses: actions/upload-artifact@v7 + with: + name: ignity-package-${{ matrix.os.target }} + path: | + dist/*.deb + dist/*.rpm + dist/*.apk + + # Run the whole suite with ignity installed from its package + sanitize: + needs: + - package + strategy: + fail-fast: false + matrix: + os: + - runner: ubuntu-24.04 + target: linux-amd64 + - runner: ubuntu-24.04-arm + target: linux-arm64 + # Not ubuntu: its image already has a uid/gid 1000 user, which the tests map + base: + - debian:bookworm-slim + - alpine:3 + - fedora:latest + runs-on: ${{ matrix.os.runner }} + steps: + - uses: actions/checkout@v7 + + - name: Install environment + uses: ./.github/workflows/env-install + + - name: Install podman + run: command -v podman || { sudo apt-get update && sudo apt-get install -y podman; } + + - uses: actions/download-artifact@v8 + with: + name: ignity-package-${{ matrix.os.target }} + path: dist - name: Run tests run: uv run poe test + env: + IGNITY_INSTALL: package + DOCKER_BASE_IMAGE: ${{ matrix.base }} diff --git a/.github/workflows/env-install/action.yml b/.github/workflows/env-install/action.yml new file mode 100644 index 0000000..103f6be --- /dev/null +++ b/.github/workflows/env-install/action.yml @@ -0,0 +1,16 @@ +--- +name: Environment installation +description: Install uv, and dependencies +runs: + using: "composite" + steps: + - name: Install uv + uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 #v10.3.0 + + - name: Install dependencies + run: | + # Enable frozen mode to avoid updating `uv.lock` in CI + echo "UV_FROZEN=true" >> "${GITHUB_ENV}" + uv sync + uv run poe env:configure + shell: "bash" diff --git a/.github/workflows/pre-release.yml b/.github/workflows/pre-release.yml new file mode 100644 index 0000000..efd4489 --- /dev/null +++ b/.github/workflows/pre-release.yml @@ -0,0 +1,42 @@ +--- +name: Pre release + +on: + workflow_dispatch: + inputs: + version: + description: "The version to release (e.g. 1.2.3). This workflow will automatically perform the required version bumps" + required: true + +permissions: + contents: read + +env: + # Ref: https://docs.astral.sh/uv/concepts/projects/sync/#checking-if-the-lockfile-is-up-to-date + UV_FROZEN: "true" + +concurrency: + group: ${{ github.workflow }} + +jobs: + pre-release: + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write + steps: + - uses: actions/checkout@v7 + with: + fetch-depth: 0 + + - name: Setup github actions bot + uses: techcode-io/github-actions/setup-github-bot@setup-github-bot/v1 + + - name: Install environment + uses: ./.github/workflows/env-install + + - name: Run pre-release step + run: uv run poe release:pre + env: + GH_TOKEN: ${{ github.token }} + RELEASE_VERSION: ${{ inputs.version }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..2f638e3 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,76 @@ +--- +name: Release + +on: + workflow_dispatch: + inputs: + version: + description: "The next iteration version. This workflow will automatically perform the required version bumps" + required: true + +permissions: + contents: read + +env: + # Ref: https://docs.astral.sh/uv/concepts/projects/sync/#checking-if-the-lockfile-is-up-to-date + UV_FROZEN: "true" + +concurrency: + group: ${{ github.workflow }} + +jobs: + ci: + permissions: + contents: read + id-token: write + attestations: write + artifact-metadata: write + uses: ./.github/workflows/ci.yml + secrets: inherit + + release: + runs-on: ubuntu-latest + needs: + - ci + permissions: + contents: write + steps: + - uses: actions/checkout@v7 + + - uses: actions/download-artifact@v8 + with: + pattern: ignity-package-* + merge-multiple: true + path: dist + + - name: Install environment + uses: ./.github/workflows/env-install + + - name: Release + env: + GH_TOKEN: ${{ github.token }} + run: uv run poe release + + post-release: + needs: + - release + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write + steps: + - uses: actions/checkout@v7 + with: + fetch-depth: 0 + + - name: Setup github actions bot + uses: techcode-io/github-actions/setup-github-bot@setup-github-bot/v1 + + - name: Install environment + uses: ./.github/workflows/env-install + + - name: Run post-release step + run: uv run poe release:post + env: + GH_TOKEN: ${{ github.token }} + NEXT_VERSION: ${{ inputs.version }} diff --git a/README.md b/README.md index f67bcd2..a0b2844 100644 --- a/README.md +++ b/README.md @@ -71,6 +71,28 @@ uv run poe package ``` - It will create a tar archive containing ignity. +- To build the Linux packages of the current architecture (on Linux only for the packages): + +```bash +uv run poe build +uv run poe package:deb +uv run poe package:rpm +uv run poe package:apk +``` + +- `build` compiles static (musl) `execline`, `s6` and `s6-portable-utils` in a container, into `dist/ignity`. +- `package:` packages it with [nfpm](https://nfpm.goreleaser.com) into `dist/ignity--linux-.`. +- To run the tests with ignity installed from these packages instead of from source: + +```bash +IGNITY_INSTALL=package DOCKER_BASE_IMAGE=alpine:3 uv run poe test +``` + +### Release + +- Run the `Pre release` workflow with the version to release: it opens a PR bumping the version. +- Once merged, run the `Release` workflow with the next development version (e.g. `0.2.0-dev`). +- It tags the release, publishes the packages on GitHub Releases and opens a PR for the next iteration. ## 📖 Usage @@ -106,6 +128,42 @@ uv run poe package | `USERMAP_GID` | User gid to map on files, and group of the `CMD` (see "How to run the CMD as a non root user") | 0 | | `USERMAP_UID` | User uid to map on files, and user of the `CMD` (see "How to run the CMD as a non root user") | 0 | +### How to integrate ignity in your project from a package + +- Each [release](https://github.com/techcode-io/ignity/releases) ships `.deb`, `.rpm` and `.apk` packages for `amd64` and `arm64`. +- They bundle static builds of `execline`, `s6` and `s6-portable-utils`: nothing is compiled in your image. +- They conflict with the `execline`, `s6` and `s6-portable-utils` packages of the distribution. + +```Dockerfile +# Debian / Ubuntu +ARG IGNITY_VERSION=0.1.0 +ARG TARGETARCH +ADD https://github.com/techcode-io/ignity/releases/download/${IGNITY_VERSION}/ignity-${IGNITY_VERSION}-linux-${TARGETARCH}.deb /tmp/ignity.deb +RUN apt-get update \ + && apt-get install -y --no-install-recommends /tmp/ignity.deb \ + && rm -rf /tmp/ignity.deb /var/lib/apt/lists/* +``` + +```Dockerfile +# Fedora / RHEL / UBI +ARG IGNITY_VERSION=0.1.0 +ARG TARGETARCH +ADD https://github.com/techcode-io/ignity/releases/download/${IGNITY_VERSION}/ignity-${IGNITY_VERSION}-linux-${TARGETARCH}.rpm /tmp/ignity.rpm +RUN dnf install -y /tmp/ignity.rpm && dnf clean all && rm -f /tmp/ignity.rpm +``` + +```Dockerfile +# Alpine (the packages aren't signed, their provenance is attested instead) +ARG IGNITY_VERSION=0.1.0 +ARG TARGETARCH +ADD https://github.com/techcode-io/ignity/releases/download/${IGNITY_VERSION}/ignity-${IGNITY_VERSION}-linux-${TARGETARCH}.apk /tmp/ignity.apk +RUN apk add --no-cache --allow-untrusted /tmp/ignity.apk && rm -f /tmp/ignity.apk +``` + +- `TARGETARCH` is set by `docker buildx` and `podman build`: otherwise, set it to `amd64` or `arm64`. +- The provenance of a package can be checked with `gh attestation verify --repo techcode-io/ignity`. +- On rpm distributions, `bash` already ships `cd`, `umask` and `wait`: the rpm doesn't ship the `execline` ones, use `execline-cd`, `execline-umask` and `/usr/libexec/ignity/wait` in your scripts. + ### How to integrate ignity in your project from source - You can simply `git clone` the project in the scope of the dockerfile. diff --git a/distributions/build/Containerfile b/distributions/build/Containerfile new file mode 100644 index 0000000..5626269 --- /dev/null +++ b/distributions/build/Containerfile @@ -0,0 +1,10 @@ +# Build the ignity bundle (see build-ignity.sh), copied out of /ignity by scripts/tasks/build.py +FROM docker.io/library/alpine:3 AS build +RUN apk add --no-cache bash +WORKDIR /workspace +COPY src/ src/ +COPY distributions/build/build-ignity.sh distributions/build/build-ignity.sh +RUN bash distributions/build/build-ignity.sh /out + +FROM scratch +COPY --from=build /out/ /ignity/ diff --git a/distributions/build/build-ignity.sh b/distributions/build/build-ignity.sh new file mode 100644 index 0000000..d743ce4 --- /dev/null +++ b/distributions/build/build-ignity.sh @@ -0,0 +1,138 @@ +#!/usr/bin/env bash +# +# Build the ignity bundle: a rootfs with ignity and a static (musl) build of skalibs, execline, s6 +# and s6-portable-utils, packaged by nfpm (see distributions/packages). +# +# Usage: build-ignity.sh +# Runs inside an alpine container with the repository as working directory (see scripts/tasks/build.py). + +# Bash strict mode +set -eo pipefail + +# Generate stacktrace on error +function runtime::stacktrace { + local exit_code=$? + echo >&2 "$0: Error on line '${LINENO}': ${BASH_COMMAND}" + exit ${exit_code} +} +trap runtime::stacktrace ERR + +# Versions and urls are read from the source install, its single source of truth +readonly INSTALL_SCRIPT="src/usr/src/install-ignity.sh" +readonly OUTPUT_DIR="${1:?usage: build-ignity.sh }" +readonly BUILD_DIR="/tmp/build" + +####################################### +# Log a failure. +# Arguments: +# ${*}: Any texts. +# Outputs: +# Log a message as failure. +####################################### +function log::failure { + echo -e "Failed to ${*}" >&2 +} + +####################################### +# Log a success. +# Arguments: +# ${*}: Any texts. +# Outputs: +# Log a message as success. +####################################### +function log::success { + echo -e "Succeeded to ${*}" +} + +####################################### +# Try to run a command with arguments. +# Arguments: +# $1: Suffix for log success or error. +# ${@:2}: Any arguments. +# Returns: +# Exit status of the command executed. +####################################### +function runtime::try { + local status=0 + "${@:2}" || status=$? + if [ ${status} -eq 0 ]; then + log::success "${1}" + else + log::failure "${1}" + exit ${status} + fi +} + +####################################### +# Read a readonly variable from the source install. +# Arguments: +# $1: Variable name. +# Outputs: +# Variable value. +####################################### +function install::get { + sed -n "s/^readonly ${1}=\"\(.*\)\"$/\1/p" "${INSTALL_SCRIPT}" +} + +####################################### +# Compile a skarnet package and install it in the container, for the next packages to build against. +# Arguments: +# $1: Package name. +# $2: Package version. +# $3: Git url. +# $4: Extra install root, empty for none. +# ${@:5}: Configure arguments. +####################################### +function pkg::install { + ( + runtime::try "clone ${1} v${2}" git clone -q -b "v${2}" --depth 1 "${3}" "${BUILD_DIR}/${1}" + cd "${BUILD_DIR}/${1}" + runtime::try "configure ${1} v${2}" ./configure "${@:5}" + runtime::try "compile ${1} v${2}" make + runtime::try "install ${1} v${2}" make install + if [ -n "${4}" ]; then + runtime::try "install ${1} v${2} in ${4}" make DESTDIR="${4}" install + fi + ) +} + +# Build dependencies +apk add --no-cache -q build-base git + +rm -rf "${OUTPUT_DIR}" "${BUILD_DIR}" +mkdir -p "${OUTPUT_DIR}" "${BUILD_DIR}" +OUTPUT_ROOT="$(cd "${OUTPUT_DIR}" && pwd)" + +# skalibs is only needed at build time: it goes in the throwaway container, not in the bundle +pkg::install "skalibs" "$(install::get SKALIBS_VERSION)" "$(install::get SKALIBS_URL)" "" \ + --disable-shared +pkg::install "execline" "$(install::get EXECLINE_VERSION)" "$(install::get EXECLINE_URL)" "${OUTPUT_ROOT}" \ + --enable-static-libc +# s6 hardcodes the path of its helpers (s6-ftrigrd): keep them out of the root directory +pkg::install "s6" "$(install::get S6_VERSION)" "$(install::get S6_URL)" "${OUTPUT_ROOT}" \ + --enable-static-libc --libexecdir=/usr/libexec +pkg::install "s6-portable-utils" "$(install::get S6_PORTABLE_UTILS_VERSION)" "$(install::get S6_PORTABLE_UTILS_URL)" "${OUTPUT_ROOT}" \ + --enable-static-libc + +# Only binaries are shipped: drop headers and static libraries +rm -rf "${OUTPUT_ROOT}/usr/include" "${OUTPUT_ROOT}/usr/lib" +runtime::try "strip binaries" strip "${OUTPUT_ROOT}"/bin/* "${OUTPUT_ROOT}"/usr/libexec/s6-* + +# Every binary must be static, the bundle runs on any distribution +for bin in "${OUTPUT_ROOT}"/bin/* "${OUTPUT_ROOT}"/usr/libexec/s6-*; do + if [ -n "$(readelf -l "${bin}" | grep INTERP || true)" ]; then + log::failure "check ${bin} is static" + exit 1 + fi +done +log::success "check binaries are static" + +# rpm distributions ship their own cd, umask and wait (bash): set them apart, only deb and apk ship them. +# stage3 calls execline's wait by a path of its own, shipped by every package +mkdir -p "${OUTPUT_ROOT}/posix" "${OUTPUT_ROOT}/usr/libexec/ignity" +cp "${OUTPUT_ROOT}/bin/wait" "${OUTPUT_ROOT}/usr/libexec/ignity/wait" +mv "${OUTPUT_ROOT}"/bin/{cd,umask,wait} "${OUTPUT_ROOT}/posix/" + +# Ignity itself, without the source install +cp -a src/. "${OUTPUT_ROOT}/" +rm -rf "${OUTPUT_ROOT}/usr/src" "${OUTPUT_ROOT}/run" diff --git a/distributions/packages/nfpm.amd64.yaml b/distributions/packages/nfpm.amd64.yaml new file mode 100644 index 0000000..85bd76d --- /dev/null +++ b/distributions/packages/nfpm.amd64.yaml @@ -0,0 +1,87 @@ +name: "ignity" +arch: "amd64" +version: "0.1.0-dev" +section: "default" +priority: "extra" +maintainer: "Adrien Mannocci " +description: | + Ignity is a dead simple process supervision system for containers based on s6. + It bundles static builds of execline, s6 and s6-portable-utils. +vendor: "techcode.io" +homepage: "https://github.com/techcode-io/ignity" +license: "Apache-2.0" +# The bundled binaries are the ones of execline, s6 and s6-portable-utils +provides: + - "execline" + - "s6" + - "s6-portable-utils" +conflicts: + - "execline" + - "s6" + - "s6-portable-utils" +replaces: + - "execline" + - "s6" + - "s6-portable-utils" +overrides: + # useradd, groupadd and getent (preboot, gen-accounts), GNU find (fix-perms) and grep (gen-accounts) + deb: + depends: + - "passwd" + - "findutils" + - "grep" + rpm: + depends: + - "shadow-utils" + - "findutils" + - "grep" + apk: + depends: + - "shadow" + - "findutils" + - "grep" +contents: + - src: ./dist/ignity/bin/* + dst: /bin/ + + # cd, umask and wait: rpm distributions ship their own (bash) + - src: ./dist/ignity/posix/* + dst: /bin/ + packager: deb + + - src: ./dist/ignity/posix/* + dst: /bin/ + packager: apk + + - src: ./dist/ignity/usr/libexec + dst: /usr/libexec + type: tree + + - src: ./dist/ignity/init + dst: /init + + - src: ./dist/ignity/etc/s6 + dst: /etc/s6 + type: tree + + - src: ./dist/ignity/usr/bin/* + dst: /usr/bin/ + + - src: ./dist/ignity/etc/ignity/init/00-container-discover + dst: /etc/ignity/init/00-container-discover + type: config|noreplace + + - dst: /etc/ignity/envs + type: dir + + - dst: /etc/ignity/services + type: dir + + - dst: /etc/ignity/perms + type: dir + + - dst: /etc/ignity/finalize + type: dir + + - dst: /run/ignity + type: dir diff --git a/distributions/packages/nfpm.arm64.yaml b/distributions/packages/nfpm.arm64.yaml new file mode 100644 index 0000000..ab98157 --- /dev/null +++ b/distributions/packages/nfpm.arm64.yaml @@ -0,0 +1,87 @@ +name: "ignity" +arch: "arm64" +version: "0.1.0-dev" +section: "default" +priority: "extra" +maintainer: "Adrien Mannocci " +description: | + Ignity is a dead simple process supervision system for containers based on s6. + It bundles static builds of execline, s6 and s6-portable-utils. +vendor: "techcode.io" +homepage: "https://github.com/techcode-io/ignity" +license: "Apache-2.0" +# The bundled binaries are the ones of execline, s6 and s6-portable-utils +provides: + - "execline" + - "s6" + - "s6-portable-utils" +conflicts: + - "execline" + - "s6" + - "s6-portable-utils" +replaces: + - "execline" + - "s6" + - "s6-portable-utils" +overrides: + # useradd, groupadd and getent (preboot, gen-accounts), GNU find (fix-perms) and grep (gen-accounts) + deb: + depends: + - "passwd" + - "findutils" + - "grep" + rpm: + depends: + - "shadow-utils" + - "findutils" + - "grep" + apk: + depends: + - "shadow" + - "findutils" + - "grep" +contents: + - src: ./dist/ignity/bin/* + dst: /bin/ + + # cd, umask and wait: rpm distributions ship their own (bash) + - src: ./dist/ignity/posix/* + dst: /bin/ + packager: deb + + - src: ./dist/ignity/posix/* + dst: /bin/ + packager: apk + + - src: ./dist/ignity/usr/libexec + dst: /usr/libexec + type: tree + + - src: ./dist/ignity/init + dst: /init + + - src: ./dist/ignity/etc/s6 + dst: /etc/s6 + type: tree + + - src: ./dist/ignity/usr/bin/* + dst: /usr/bin/ + + - src: ./dist/ignity/etc/ignity/init/00-container-discover + dst: /etc/ignity/init/00-container-discover + type: config|noreplace + + - dst: /etc/ignity/envs + type: dir + + - dst: /etc/ignity/services + type: dir + + - dst: /etc/ignity/perms + type: dir + + - dst: /etc/ignity/finalize + type: dir + + - dst: /run/ignity + type: dir diff --git a/pyproject.toml b/pyproject.toml index cc9598f..4ad6083 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "ignity" -version = "0.1.0" +version = "0.1.0-dev" description = "Dead simple process supervision for containers based on s6" readme = "README.md" requires-python = ">=3.13" @@ -20,6 +20,19 @@ package = false help = "Setup project environment" cmd = "pre-commit install" +[tool.poe.tasks."env:wipe"] +help = "Wipe project environment." +shell = """ +import shutil + +for path in ["dist"]: + try: + shutil.rmtree(path) + except FileNotFoundError as err: + print(f"Skipping {path} deletion...") +""" +interpreter = "python" + [tool.poe.tasks."test"] help = "Run all tests." cmd = "pytest tests" @@ -31,10 +44,51 @@ sequence = [ { cmd = "tar zcvf dist/ignity.tar.gz -C src/ ." }, ] +[tool.poe.tasks.build] +deps = ["env:wipe"] +help = "Build the ignity bundle with static s6 binaries into dist/ignity." +script = "scripts.tasks.build:run" + +[tool.poe.tasks."package:deb"] +control.expr = "sys.platform" +help = "Build debian packages." + +[[tool.poe.tasks."package:deb".switch]] +case = "linux" +script = "scripts.tasks.package:deb" + +[tool.poe.tasks."package:rpm"] +control.expr = "sys.platform" +help = "Build rpm packages." + +[[tool.poe.tasks."package:rpm".switch]] +case = "linux" +script = "scripts.tasks.package:rpm" + +[tool.poe.tasks."package:apk"] +control.expr = "sys.platform" +help = "Build alpine packages." + +[[tool.poe.tasks."package:apk".switch]] +case = "linux" +script = "scripts.tasks.package:apk" + [tool.poe.tasks."project:check"] help = "Check for s6 ecosystem package updates without applying" -script = "scripts.project:upgrade(dry_run=True)" +script = "scripts.tasks.project:upgrade(dry_run=True)" [tool.poe.tasks."project:upgrade"] help = "Upgrade s6 ecosystem package versions in install-ignity.sh" -script = "scripts.project:upgrade(dry_run=False)" +script = "scripts.tasks.project:upgrade(dry_run=False)" + +[tool.poe.tasks."release:pre"] +help = "Create a PR with changes for release." +script = "scripts.tasks.release:pre" + +[tool.poe.tasks."release"] +help = "Create a new ignity release." +script = "scripts.tasks.release:run" + +[tool.poe.tasks."release:post"] +help = "Prepare next iteration." +script = "scripts.tasks.release:post" diff --git a/scripts/__init__.py b/scripts/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/scripts/binds/__init__.py b/scripts/binds/__init__.py new file mode 100644 index 0000000..75ae67e --- /dev/null +++ b/scripts/binds/__init__.py @@ -0,0 +1,45 @@ +import os +import shutil +import subprocess +from collections.abc import Mapping + +# A command exited with a non-zero code +ErrorReturnCode = subprocess.CalledProcessError + + +class CommandNotFound(Exception): + """Raised when a command can't be found in PATH.""" + + def __init__(self, name: str) -> None: + super().__init__(f"The `{name}` command can't be found in PATH") + + +class Bind: + """Represents a bind to a command found in PATH.""" + + def __init__(self, name: str) -> None: + """Init bind, raising CommandNotFound when the command isn't in PATH.""" + path = shutil.which(name) + if path is None: + raise CommandNotFound(name) + self._path: str = path + + def _env(self) -> Mapping[str, str]: + """Environment the command runs with.""" + return os.environ + + def _exec(self, *args: str, capture: bool = False) -> str: + """ + Run the command with arguments, inheriting stderr and, unless captured, stdout. + + Raises: + ErrorReturnCode: if the command exits with a non-zero code. + """ + result = subprocess.run( + [self._path, *args], + check=True, + env=self._env(), + stdout=subprocess.PIPE if capture else None, + text=True, + ) + return (result.stdout or "").strip() diff --git a/scripts/binds/container.py b/scripts/binds/container.py new file mode 100644 index 0000000..ca02b9f --- /dev/null +++ b/scripts/binds/container.py @@ -0,0 +1,57 @@ +from collections.abc import Mapping +from pathlib import Path +from typing import override + +from scripts.binds import Bind, CommandNotFound +from scripts.utils import fatal + + +class Container(Bind): + """Represents a bind to a container backend, either docker or podman.""" + + def __init__(self) -> None: + """Init container bind.""" + backend: str | None = None + for candidate in ("docker", "podman"): + try: + super().__init__(candidate) + backend = candidate + break + except CommandNotFound: + continue + + if backend is None: + fatal( + "detect container backend", + CommandNotFound("docker or podman"), + ) + + self.__backend: str = backend + + @override + def _env(self) -> Mapping[str, str]: + env = dict(super()._env()) + if self.__backend == "podman": + env["BUILDAH_FORMAT"] = "docker" + return env + + def build(self, tag: str, containerfile: Path, context: Path) -> None: + """Build an image from a containerfile.""" + self._exec("build", "-t", tag, "-f", containerfile.as_posix(), context.as_posix()) + + def create(self, image: str) -> str: + """Create a container from an image without starting it, and return its id.""" + # The command is never run, it is only required for images without one + return self._exec("create", image, "noop", capture=True) + + def cp(self, src: str, dst: str) -> None: + """Copy files between a container and the host.""" + self._exec("cp", src, dst) + + def rm(self, container: str) -> None: + """Remove a container.""" + self._exec("rm", "-f", container, capture=True) + + def rmi(self, image: str) -> None: + """Remove an image.""" + self._exec("rmi", "-f", image, capture=True) diff --git a/scripts/binds/gh.py b/scripts/binds/gh.py new file mode 100644 index 0000000..9acd930 --- /dev/null +++ b/scripts/binds/gh.py @@ -0,0 +1,29 @@ +from scripts.binds import Bind, CommandNotFound +from scripts.utils import fatal + + +class Gh(Bind): + """Represents a bind to gh command.""" + + def __init__(self) -> None: + """Init gh bind.""" + try: + super().__init__("gh") + except CommandNotFound as err: + fatal("detect gh binary", err) + + def pr_create(self, base: str, head: str) -> None: + """Create a pull request, filling title/body from commits.""" + self._exec("pr", "create", "--fill", f"--base={base}", f"--head={head}") + + def release_create(self, version: str, title: str, assets: list[str]) -> None: + """Create a release with generated notes and attached assets.""" + self._exec( + "release", + "create", + "--generate-notes", + "--latest", + f"--title={title}", + version, + *assets, + ) diff --git a/scripts/binds/git.py b/scripts/binds/git.py new file mode 100644 index 0000000..508def0 --- /dev/null +++ b/scripts/binds/git.py @@ -0,0 +1,41 @@ +from scripts.binds import Bind, CommandNotFound +from scripts.utils import fatal + + +class Git(Bind): + """Represents a bind to git command.""" + + def __init__(self) -> None: + """Init git bind.""" + try: + super().__init__("git") + except CommandNotFound as err: + fatal("detect git binary", err) + + def checkout_new_branch(self, name: str) -> None: + """Create and switch to a new branch.""" + self._exec("checkout", "-b", name) + + def add_all(self) -> None: + """Stage all changes.""" + self._exec("add", "--all") + + def commit(self, message: str, no_verify: bool = False) -> None: + """Commit staged changes.""" + args = ["commit", "-m", message] + if no_verify: + args.append("--no-verify") + self._exec(*args) + + def push(self, remote: str, ref: str) -> None: + """Push a ref to a remote.""" + self._exec("push", remote, ref) + + def tag(self, name: str) -> None: + """ + Create a tag. + + Raises: + ErrorReturnCode: if the tag already exists. + """ + self._exec("tag", name) diff --git a/scripts/binds/nfpm.py b/scripts/binds/nfpm.py new file mode 100644 index 0000000..65b56d0 --- /dev/null +++ b/scripts/binds/nfpm.py @@ -0,0 +1,24 @@ +from scripts.binds.container import Container + + +class Nfpm(Container): + """Represents a bind to the `goreleaser/nfpm` packaging container.""" + + def package(self, config: str, target: str, packager: str) -> None: + """Package a build bundle into an OS package using nfpm.""" + self._exec( + "run", + "--rm", + "-v", + ".:/tmp", + "-w", + "/tmp", + "goreleaser/nfpm", + "package", + "--config", + config, + "--target", + target, + "--packager", + packager, + ) diff --git a/scripts/tasks/__init__.py b/scripts/tasks/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/scripts/tasks/build.py b/scripts/tasks/build.py new file mode 100644 index 0000000..488f270 --- /dev/null +++ b/scripts/tasks/build.py @@ -0,0 +1,29 @@ +import secrets +import shutil +from pathlib import Path +from typing import Final + +from scripts.binds.container import Container + +ROOT_DIR: Final[Path] = Path(__file__).parent.parent.parent +DIST_DIR: Final[Path] = ROOT_DIR / "dist" +CONTAINERFILE_PATH: Final[Path] = ROOT_DIR / "distributions/build/Containerfile" + + +def run() -> None: + """Build the ignity bundle for the current architecture into dist/ignity.""" + # The bundle is built in a container, then copied out of its image (see the Containerfile) + container = Container() + image = f"ignity-bundle-{secrets.token_hex(8)}" + container.build(image, CONTAINERFILE_PATH, ROOT_DIR) + try: + container_id = container.create(image) + try: + bundle_dir = DIST_DIR / "ignity" + shutil.rmtree(bundle_dir, ignore_errors=True) + DIST_DIR.mkdir(parents=True, exist_ok=True) + container.cp(f"{container_id}:/ignity", bundle_dir.as_posix()) + finally: + container.rm(container_id) + finally: + container.rmi(image) diff --git a/scripts/tasks/package.py b/scripts/tasks/package.py new file mode 100644 index 0000000..0ec0400 --- /dev/null +++ b/scripts/tasks/package.py @@ -0,0 +1,40 @@ +import platform +import tomllib +from pathlib import Path + +from scripts.binds.nfpm import Nfpm + +DISTRIBUTIONS_PACKAGES_PATH = "distributions/packages" +PYPROJECT_PATH = Path("pyproject.toml") + + +def __build_package(packager: str) -> None: + """Package the current architecture bundle with nfpm.""" + arch = platform.machine().lower() + arch = "amd64" if arch == "x86_64" else arch + arch = "arm64" if arch == "aarch64" else arch + + # Same name for every format, so that a Dockerfile can pick one from TARGETARCH + with PYPROJECT_PATH.open("rb") as file: + version = tomllib.load(file)["project"]["version"] + + Nfpm().package( + config=f"{DISTRIBUTIONS_PACKAGES_PATH}/nfpm.{arch}.yaml", + target=f"/tmp/dist/ignity-{version}-linux-{arch}.{packager}", + packager=packager, + ) + + +def deb() -> None: + """Build a debian package.""" + __build_package("deb") + + +def rpm() -> None: + """Build an rpm package.""" + __build_package("rpm") + + +def apk() -> None: + """Build an alpine package.""" + __build_package("apk") diff --git a/scripts/project.py b/scripts/tasks/project.py similarity index 97% rename from scripts/project.py rename to scripts/tasks/project.py index 53c0f96..a3359f5 100644 --- a/scripts/project.py +++ b/scripts/tasks/project.py @@ -6,7 +6,7 @@ Can be run locally or in CI to keep dependencies up-to-date. Usage: - from scripts.project import upgrade + from scripts.tasks.project import upgrade result = upgrade(dry_run=False) """ @@ -27,7 +27,7 @@ ("S6_PORTABLE_UTILS_VERSION", "skarnet/s6-portable-utils"), ] -INSTALL_SCRIPT = Path(__file__).parent.parent / "src/usr/src/install-ignity.sh" +INSTALL_SCRIPT = Path(__file__).parent.parent.parent / "src/usr/src/install-ignity.sh" @dataclass diff --git a/scripts/tasks/release.py b/scripts/tasks/release.py new file mode 100644 index 0000000..eca45d0 --- /dev/null +++ b/scripts/tasks/release.py @@ -0,0 +1,156 @@ +import os +import re +import sys +import tomllib +from pathlib import Path +from typing import Final + +from scripts.binds import ErrorReturnCode +from scripts.binds.gh import Gh +from scripts.binds.git import Git + +PYPROJECT_PATH: Final[Path] = Path("pyproject.toml") +DISTRIBUTIONS_NFPM_AMD64_PATH: Final[Path] = Path( + "distributions/packages/nfpm.amd64.yaml" +) +DISTRIBUTIONS_NFPM_ARM64_PATH: Final[Path] = Path( + "distributions/packages/nfpm.arm64.yaml" +) +# Packages built for the release: deb, rpm and apk +RELEASE_ASSETS_PATTERNS: Final[tuple[str, ...]] = ( + "ignity*.deb", + "ignity*.rpm", + "ignity*.apk", +) + + +def __update_file_version(file_path: Path, pattern: str, replacement: str) -> None: + """Substitute the version in a file matching a regex pattern.""" + try: + data = file_path.read_text() + except Exception: + print( + f"The `{file_path.as_posix()}` can't be read", + file=sys.stderr, + ) + raise + + data = re.sub(pattern, replacement, data, count=1) + try: + file_path.write_text(data) + except Exception: + print( + f"The `{file_path.as_posix()}` file can't be written", + file=sys.stderr, + ) + raise + + +def __set_version(version: str) -> None: + """Update version in pyproject.toml and the nfpm package configs.""" + # Update project version + __update_file_version( + PYPROJECT_PATH, r"version = \"(.+)\"", f'version = "{version}"' + ) + + # Update nfpm package versions + __update_file_version( + DISTRIBUTIONS_NFPM_AMD64_PATH, r"version: \"(.+)\"", f'version: "{version}"' + ) + __update_file_version( + DISTRIBUTIONS_NFPM_ARM64_PATH, r"version: \"(.+)\"", f'version: "{version}"' + ) + + +def __project_version() -> str: + """Return the project version from pyproject.toml.""" + with PYPROJECT_PATH.open("rb") as file: + return tomllib.load(file)["project"]["version"] + + +def pre() -> None: + """Create a release branch and PR to prepare for release.""" + # Ensure we have inputs + release_version = os.getenv("RELEASE_VERSION") + if not release_version: + return print( + "You must define `RELEASE_VERSION` environment variable.", file=sys.stderr + ) + if not re.fullmatch(r"(\d){1,2}\.(\d){1,2}\.(\d){1,2}", release_version): + return print( + "The `RELEASE_VERSION` should match semver format.", file=sys.stderr + ) + + # Create a new branch + branch_name = f"feat/pre-release-v{release_version}" + git = Git() + git.checkout_new_branch(branch_name) + + # Update all files + __set_version(release_version) + + # Push release branch + git.add_all() + git.commit(f"chore: release ignity v{release_version}", no_verify=True) + git.push("origin", branch_name) + + # Create a PR + Gh().pr_create(base="main", head=branch_name) + + +def run() -> None: + """Create a release tag and GitHub release with the built packages.""" + # Read project version + release_version = __project_version() + if release_version.endswith("-dev"): + return print( + f"The `{release_version}` development version can't be released.", + file=sys.stderr, + ) + + # Create the release tag + try: + git = Git() + git.tag(release_version) + git.push("origin", release_version) + except ErrorReturnCode: + return print( + f"The release `v{release_version}` already exists.", file=sys.stderr + ) + + # Create the release + assets = sorted( + file.absolute().as_posix() + for pattern in RELEASE_ASSETS_PATTERNS + for file in Path("dist").glob(pattern) + if file.is_file() + ) + Gh().release_create(release_version, f"ignity v{release_version}", assets) + + +def post() -> None: + """Prepare for the next development iteration after release.""" + # Ensure we have inputs + next_version = os.getenv("NEXT_VERSION") + if not next_version: + return print( + "You must define `NEXT_VERSION` environment variable", file=sys.stderr + ) + if not re.fullmatch(r"(\d){1,2}\.(\d){1,2}\.(\d){1,2}-dev", next_version): + return print("The `NEXT_VERSION` should match semver format.", file=sys.stderr) + + # Create a new branch + branch_name = f"feat/post-release-v{next_version}" + git = Git() + git.checkout_new_branch(branch_name) + + # Update all files + __set_version(next_version) + + # Push changes + git.add_all() + git.commit("chore: prepare for next iteration", no_verify=True) + git.push("origin", branch_name) + + # Create a PR + Gh().pr_create(base="main", head=branch_name) diff --git a/scripts/utils.py b/scripts/utils.py new file mode 100644 index 0000000..92689e9 --- /dev/null +++ b/scripts/utils.py @@ -0,0 +1,10 @@ +import sys +from typing import NoReturn + + +def fatal(msg: str, err: Exception | None = None) -> NoReturn: + """Print error message on stderr and die.""" + print(msg, file=sys.stderr) + if err: + print(err, file=sys.stderr) + sys.exit(1) diff --git a/src/etc/s6/boot/stage3 b/src/etc/s6/boot/stage3 index c2af4ef..12848ef 100755 --- a/src/etc/s6/boot/stage3 +++ b/src/etc/s6/boot/stage3 @@ -1,7 +1,8 @@ #!/bin/execlineb -S0 # This is the shutdown script, running as process 1. -cd / +# execline-cd, not cd: rpm distributions ship a cd of their own (bash), which the ignity rpm leaves in place +execline-cd / # Use CMD exit code (written by stage2) defaulting to zero if not present. backtick -D 0 STAGE2_EXITED { redirfd -r 0 /run/ignity/stage2-exited s6-cat } @@ -49,7 +50,8 @@ s6-sleep -m 200 foreground { redirfd -a 2 /dev/null redirfd -a 1 /dev/null s6-nuke -k } # foreground is process 1: it survives again # Reap all the zombies then sync, and we're done. -wait { } +# execline's wait by its own path: rpm distributions ship a wait of their own (bash), which the ignity rpm leaves in place +/usr/libexec/ignity/wait { } foreground { if { eltest ${IGNITY_SYNC_DISKS} -eq 1 } s6-sync diff --git a/src/usr/src/install-ignity.sh b/src/usr/src/install-ignity.sh index a99d00a..3a0fe9f 100644 --- a/src/usr/src/install-ignity.sh +++ b/src/usr/src/install-ignity.sh @@ -176,6 +176,10 @@ pkg::install "execline" "${EXECLINE_VERSION}" "${EXECLINE_URL}" "--enable-shared pkg::install "s6" "${S6_VERSION}" "${S6_URL}" pkg::install "s6-portable-utils" "${S6_PORTABLE_UTILS_VERSION}" "${S6_PORTABLE_UTILS_URL}" +# execline's wait, by the path stage3 calls it with (see distributions/build/build-ignity.sh) +mkdir -p /usr/libexec/ignity +ln -sf /bin/wait /usr/libexec/ignity/wait + # Diff to do iso uninstall dpkg -l | awk '{print $2;}' > /tmp/after BUILD_DEPENDENCIES=$(diff /tmp/before /tmp/after | grep ">" | awk '{print $2;}' || true) diff --git a/tests/conftest.py b/tests/conftest.py index 000d426..120faf3 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -25,16 +25,56 @@ # Used when tests/fixtures// has no Containerfile.tpl of its own: ignity plus the kind's rootfs DEFAULT_CONTAINERFILE: Final[str] = """\ FROM {{DOCKER_BASE_IMAGE}} -COPY src/ / -RUN bash /usr/src/install-ignity.sh +{{IGNITY_INSTALL}} """ +# How {{IGNITY_INSTALL}} installs ignity, from IGNITY_INSTALL: built from source (default) +SOURCE_INSTALL: Final[str] = """\ +COPY src/ / +RUN bash /usr/src/install-ignity.sh""" + +# Or from the package built in dist/ (`poe build` then `poe package:`), by base image family. +# bash is installed too: test commands are run with it +PACKAGE_INSTALLS: Final[dict[str, str]] = { + "deb": """\ +COPY dist/*.deb /tmp/ignity/ +RUN apt-get update && apt-get install -y --no-install-recommends bash /tmp/ignity/*.deb \\ + && rm -rf /tmp/ignity /var/lib/apt/lists/*""", + "rpm": """\ +COPY dist/*.rpm /tmp/ignity/ +RUN dnf install -y bash /tmp/ignity/*.rpm && dnf clean all && rm -rf /tmp/ignity""", + "apk": """\ +COPY dist/*.apk /tmp/ignity/ +RUN apk add --no-cache --allow-untrusted bash /tmp/ignity/*.apk && rm -rf /tmp/ignity""", +} + +# Package format of a base image, from its name +PACKAGE_FORMATS: Final[dict[str, str]] = { + "debian": "deb", + "ubuntu": "deb", + "fedora": "rpm", + "alpine": "apk", +} + def env_args(**env: str | int) -> tuple[str, ...]: """Turn keyword arguments into `-e NAME=value` container arguments.""" return tuple(arg for name, value in env.items() for arg in ("-e", f"{name}={value}")) +def ignity_install(base_image: str) -> str: + """Containerfile instructions installing ignity on a base image, as IGNITY_INSTALL asks.""" + install = os.environ.get("IGNITY_INSTALL", "source") + if install == "source": + return SOURCE_INSTALL + if install != "package": + raise ValueError(f"IGNITY_INSTALL must be `source` or `package`, not `{install}`") + name = base_image.rsplit("/", 1)[-1].split(":", 1)[0] + if name not in PACKAGE_FORMATS: + raise ValueError(f"No package format known for the `{base_image}` base image") + return PACKAGE_INSTALLS[PACKAGE_FORMATS[name]] + + def build_image(kind: str) -> str: """Build a container image for a test suite kind.""" tag = secrets.token_hex(16) @@ -48,7 +88,9 @@ def build_image(kind: str) -> str: if (fixture_dir / "rootfs").is_dir(): template += f"COPY tests/fixtures/{kind}/rootfs/ /\n" base_image = os.environ.get("DOCKER_BASE_IMAGE", "debian:bookworm-slim") - rendered = template.replace("{{DOCKER_BASE_IMAGE}}", base_image) + rendered = template.replace("{{DOCKER_BASE_IMAGE}}", base_image).replace( + "{{IGNITY_INSTALL}}", ignity_install(base_image) + ) with tempfile.NamedTemporaryFile( mode="w", delete=False, suffix=".Containerfile" ) as f: diff --git a/tests/fixtures/capabilities/Containerfile.tpl b/tests/fixtures/capabilities/Containerfile.tpl index 662c62d..45793d4 100644 --- a/tests/fixtures/capabilities/Containerfile.tpl +++ b/tests/fixtures/capabilities/Containerfile.tpl @@ -2,8 +2,7 @@ FROM {{DOCKER_BASE_IMAGE}} # Install ignity -COPY src/ / -RUN bash /usr/src/install-ignity.sh +{{IGNITY_INSTALL}} COPY tests/fixtures/capabilities/rootfs/ / # /data/sub already belongs to another uid and can't be entered by anyone else: fix-perms diff --git a/tests/fixtures/preboot/Containerfile.tpl b/tests/fixtures/preboot/Containerfile.tpl index 955b4a1..8d612c5 100644 --- a/tests/fixtures/preboot/Containerfile.tpl +++ b/tests/fixtures/preboot/Containerfile.tpl @@ -2,8 +2,7 @@ FROM {{DOCKER_BASE_IMAGE}} # Install ignity -COPY src/ / -RUN bash /usr/src/install-ignity.sh +{{IGNITY_INSTALL}} ENV \ USERMAP_UID="1000" \ diff --git a/tests/test_perms.py b/tests/test_perms.py index 684c8df..24c54a2 100644 --- a/tests/test_perms.py +++ b/tests/test_perms.py @@ -28,7 +28,7 @@ def test_multi_uid_gid_permissions(image): def test_dynamic_uid_gid_default_owner(image): assert ( run_in_container( - image, 'ls -alh /var/www/dynamic-uid-gid | grep -- "root root"' + image, 'ls -alh /var/www/dynamic-uid-gid | grep -E -- "root +root"' ).returncode == 0 ) @@ -38,7 +38,7 @@ def test_dynamic_uid_gid_custom_owner(image): assert ( run_in_container( image, - 'ls -alh /var/www/dynamic-uid-gid | grep -- "exploit exploit"', + 'ls -alh /var/www/dynamic-uid-gid | grep -E -- "exploit +exploit"', extra_args=_USERMAP_ARGS, ).returncode == 0 diff --git a/tests/test_services.py b/tests/test_services.py index 9d43127..8c50f06 100644 --- a/tests/test_services.py +++ b/tests/test_services.py @@ -16,12 +16,13 @@ def test_no_start_service_did_not_run(image): def test_no_start_at_build_time_can_be_started(image): - cmd = "rm -f /run/ignity/services-state/no-start-at-build-time/down && s6-svc -u /run/ignity/services-state/no-start-at-build-time" + # The CMD waits for the service to print: it exits right away, and the service is killed with it + cmd = "rm -f /run/ignity/services-state/no-start-at-build-time/down && s6-svc -u /run/ignity/services-state/no-start-at-build-time && sleep 1" result = run_in_container(image, cmd) assert "no-start service at build time" in result.stdout def test_no_start_at_runtime_can_be_started(image): - cmd = "rm -f /run/ignity/services-state/no-start-at-runtime/down && s6-svc -u /run/ignity/services-state/no-start-at-runtime" + cmd = "rm -f /run/ignity/services-state/no-start-at-runtime/down && s6-svc -u /run/ignity/services-state/no-start-at-runtime && sleep 1" result = run_in_container(image, cmd) assert "no-start service at runtime" in result.stdout diff --git a/uv.lock b/uv.lock index e4a6bd2..6491c31 100644 --- a/uv.lock +++ b/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 3 +revision = 5 requires-python = ">=3.13" [[package]] @@ -49,7 +49,7 @@ wheels = [ [[package]] name = "ignity" -version = "0.1.0" +version = "0.1.0.dev0" source = { virtual = "." } [package.dev-dependencies] From 141a23466afe1dfe039e43bc630b36abd35448c1 Mon Sep 17 00:00:00 2001 From: Adrien Mannocci Date: Thu, 22 Oct 2026 12:47:33 +0200 Subject: [PATCH 2/3] refactor: install s6 binaries in /usr/lib/ignity Keep execline, s6 and s6-portable-utils out of the root filesystem, for both the packages and the source install: nothing conflicts with the distribution anymore, its own execline/s6 can be installed alongside. - build with --bindir/--libexecdir under /usr/lib/ignity and --enable-absolute-paths - /init and the entry points that can run outside it put /usr/lib/ignity/bin first in PATH (with-ignity-path); the PATH reset of fix-perms, gen-accounts and load-envfile is gone - ignity scripts use #!/usr/lib/ignity/bin/execlineb; /bin/execlineb is a compat link for user scripts, created only when absent - packages: no conflicts/provides, no rpm special case; stage3 back to plain cd and wait - drop the default 00-container-discover init script - tests: binaries resolved from /usr/lib/ignity, compat link present - docs: where ignity installs its binaries, ENV PATH for exec sessions --- .claude/CLAUDE.md | 5 ++- README.md | 14 ++++++- distributions/build/build-ignity.sh | 25 ++++++------- distributions/packages/nfpm.amd64.yaml | 41 +++++---------------- distributions/packages/nfpm.arm64.yaml | 41 +++++---------------- distributions/packages/postinstall.sh | 5 +++ distributions/packages/preremove.sh | 8 ++++ src/etc/ignity/init/00-container-discover | 3 -- src/etc/s6/boot/stage1 | 2 +- src/etc/s6/boot/stage1-envs | 2 +- src/etc/s6/boot/stage2 | 2 +- src/etc/s6/boot/stage2-cmd | 2 +- src/etc/s6/boot/stage2-copy | 2 +- src/etc/s6/boot/stage2-init | 2 +- src/etc/s6/boot/stage2-services | 2 +- src/etc/s6/boot/stage3 | 8 ++-- src/etc/s6/boot/stage3-finalize | 2 +- src/etc/s6/services/.s6-svscan/crash | 2 +- src/etc/s6/services/.s6-svscan/finish | 2 +- src/init | 6 +-- src/usr/bin/fix-perms | 5 ++- src/usr/bin/gen-accounts | 5 ++- src/usr/bin/load-envfile | 5 ++- src/usr/bin/preboot | 6 +-- src/usr/bin/with-retries | 4 +- src/usr/lib/ignity/libexec/with-ignity-path | 18 +++++++++ src/usr/src/install-ignity.sh | 19 ++++++---- tests/test_boot.py | 15 ++++++++ 28 files changed, 136 insertions(+), 117 deletions(-) create mode 100644 distributions/packages/postinstall.sh create mode 100644 distributions/packages/preremove.sh delete mode 100755 src/etc/ignity/init/00-container-discover create mode 100755 src/usr/lib/ignity/libexec/with-ignity-path diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 3a42463..ccfbf66 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -72,7 +72,7 @@ Containers of a pod share files through a user-mounted volume. No env is handed ## Key Components **Boot Stages:** -- `/init` — Entry point (execlineb wrapper calling stage1) +- `/init` — Entry point (execlineb wrapper calling stage1, with `/usr/lib/ignity/bin` first in PATH) - `/etc/s6/boot/stage{1,2,3}` — Core boot logic - `/etc/s6/boot/stage1-envs`, `/etc/s6/boot/stage2-{copy,init,services,cmd}` — Boot sub-scripts - `/etc/s6/services/.s6-svscan/{crash,finish}` — s6 supervision control @@ -87,7 +87,8 @@ Containers of a pod share files through a user-mounted volume. No env is handed **Installation:** - `/usr/src/install-ignity.sh` — Compiles and installs s6, execline, skalibs from source (Debian/Ubuntu). Its `readonly *_VERSION`/`*_URL` are the single source of truth for the skarnet versions, also read by `distributions/build/build-ignity.sh` and bumped by `poe project:upgrade` -- Linux packages (deb/rpm/apk, amd64/arm64): `distributions/build/` builds the bundle (static binaries in `/bin`, s6 helpers in `/usr/libexec`), `distributions/packages/nfpm..yaml` packages it. The rpm leaves out execline's `cd`, `umask` and `wait` (owned by bash there), so ignity scripts must not use them (`execline-cd`, `execline-umask`, `/usr/libexec/ignity/wait`, which install-ignity.sh links too) +- execline, s6 and s6-portable-utils live in `/usr/lib/ignity/{bin,libexec}` (both installs, built with `--enable-absolute-paths`), never in `/bin`. `/init` puts `/usr/lib/ignity/bin` first in PATH via `/usr/lib/ignity/libexec/with-ignity-path`; entry points that can run outside /init (`preboot`, `fix-perms`, `gen-accounts`, `load-envfile`, `with-retries`) start with it and find the other tools through the caller's PATH. Ignity's own scripts use `#!/usr/lib/ignity/bin/execlineb`; `/bin/execlineb` is only a compat link for user scripts, created when absent +- Linux packages (deb/rpm/apk, amd64/arm64): `distributions/build/` builds the bundle (static binaries), `distributions/packages/nfpm..yaml` packages it, `postinstall.sh`/`preremove.sh` manage the `/bin/execlineb` link. No Conflicts/Provides with the distribution's execline/s6 ## Container Configuration (User-Provided) diff --git a/README.md b/README.md index a0b2844..8803948 100644 --- a/README.md +++ b/README.md @@ -132,7 +132,7 @@ IGNITY_INSTALL=package DOCKER_BASE_IMAGE=alpine:3 uv run poe test - Each [release](https://github.com/techcode-io/ignity/releases) ships `.deb`, `.rpm` and `.apk` packages for `amd64` and `arm64`. - They bundle static builds of `execline`, `s6` and `s6-portable-utils`: nothing is compiled in your image. -- They conflict with the `execline`, `s6` and `s6-portable-utils` packages of the distribution. +- They install them in `/usr/lib/ignity` (see "Where ignity installs its binaries"): nothing conflicts with the packages of the distribution. ```Dockerfile # Debian / Ubuntu @@ -162,7 +162,6 @@ RUN apk add --no-cache --allow-untrusted /tmp/ignity.apk && rm -f /tmp/ignity.ap - `TARGETARCH` is set by `docker buildx` and `podman build`: otherwise, set it to `amd64` or `arm64`. - The provenance of a package can be checked with `gh attestation verify --repo techcode-io/ignity`. -- On rpm distributions, `bash` already ships `cd`, `umask` and `wait`: the rpm doesn't ship the `execline` ones, use `execline-cd`, `execline-umask` and `/usr/libexec/ignity/wait` in your scripts. ### How to integrate ignity in your project from source @@ -175,6 +174,17 @@ COPY ignity/src / RUN bash /usr/src/install-ignity.sh \ ``` +### Where ignity installs its binaries + +- `execline`, `s6` and `s6-portable-utils` are installed in `/usr/lib/ignity/bin`, out of the root filesystem, whether ignity comes from a package or from source. +- `/init` puts `/usr/lib/ignity/bin` first in `PATH`: `init`, `services`, `finalize` scripts and the `CMD` find them, before any command of the same name of the distribution (`cd`, `wait`, `umask` are shipped by `bash` on rpm distributions). +- `/bin/execlineb` links to `/usr/lib/ignity/bin/execlineb` when the distribution has no `execline` of its own, so scripts with a `#!/bin/execlineb` shebang keep working. +- Outside `/init` (`docker exec`, Kubernetes exec probes, `RUN` instructions calling your own scripts), `PATH` is the one of the image: add the directory to it. + +```Dockerfile +ENV PATH="/usr/lib/ignity/bin:${PATH}" +``` + ### How to use ignity as `ENTRYPOINT` - By default, `ignity` isn't set as entrypoint. diff --git a/distributions/build/build-ignity.sh b/distributions/build/build-ignity.sh index d743ce4..109698b 100644 --- a/distributions/build/build-ignity.sh +++ b/distributions/build/build-ignity.sh @@ -21,6 +21,8 @@ trap runtime::stacktrace ERR readonly INSTALL_SCRIPT="src/usr/src/install-ignity.sh" readonly OUTPUT_DIR="${1:?usage: build-ignity.sh }" readonly BUILD_DIR="/tmp/build" +# Same layout as the source install: out of the root filesystem, /init puts it first in PATH +readonly IGNITY_LIB_DIR="/usr/lib/ignity" ####################################### # Log a failure. @@ -106,20 +108,23 @@ OUTPUT_ROOT="$(cd "${OUTPUT_DIR}" && pwd)" # skalibs is only needed at build time: it goes in the throwaway container, not in the bundle pkg::install "skalibs" "$(install::get SKALIBS_VERSION)" "$(install::get SKALIBS_URL)" "" \ --disable-shared +readonly CONFIGURE=( + --enable-static-libc --enable-absolute-paths + --bindir="${IGNITY_LIB_DIR}/bin" --libexecdir="${IGNITY_LIB_DIR}/libexec" +) pkg::install "execline" "$(install::get EXECLINE_VERSION)" "$(install::get EXECLINE_URL)" "${OUTPUT_ROOT}" \ - --enable-static-libc -# s6 hardcodes the path of its helpers (s6-ftrigrd): keep them out of the root directory + "${CONFIGURE[@]}" pkg::install "s6" "$(install::get S6_VERSION)" "$(install::get S6_URL)" "${OUTPUT_ROOT}" \ - --enable-static-libc --libexecdir=/usr/libexec + "${CONFIGURE[@]}" pkg::install "s6-portable-utils" "$(install::get S6_PORTABLE_UTILS_VERSION)" "$(install::get S6_PORTABLE_UTILS_URL)" "${OUTPUT_ROOT}" \ - --enable-static-libc + "${CONFIGURE[@]}" # Only binaries are shipped: drop headers and static libraries -rm -rf "${OUTPUT_ROOT}/usr/include" "${OUTPUT_ROOT}/usr/lib" -runtime::try "strip binaries" strip "${OUTPUT_ROOT}"/bin/* "${OUTPUT_ROOT}"/usr/libexec/s6-* +rm -rf "${OUTPUT_ROOT}/usr/include" "${OUTPUT_ROOT}"/usr/lib/*.a +runtime::try "strip binaries" strip "${OUTPUT_ROOT}${IGNITY_LIB_DIR}"/bin/* "${OUTPUT_ROOT}${IGNITY_LIB_DIR}"/libexec/* # Every binary must be static, the bundle runs on any distribution -for bin in "${OUTPUT_ROOT}"/bin/* "${OUTPUT_ROOT}"/usr/libexec/s6-*; do +for bin in "${OUTPUT_ROOT}${IGNITY_LIB_DIR}"/bin/* "${OUTPUT_ROOT}${IGNITY_LIB_DIR}"/libexec/*; do if [ -n "$(readelf -l "${bin}" | grep INTERP || true)" ]; then log::failure "check ${bin} is static" exit 1 @@ -127,12 +132,6 @@ for bin in "${OUTPUT_ROOT}"/bin/* "${OUTPUT_ROOT}"/usr/libexec/s6-*; do done log::success "check binaries are static" -# rpm distributions ship their own cd, umask and wait (bash): set them apart, only deb and apk ship them. -# stage3 calls execline's wait by a path of its own, shipped by every package -mkdir -p "${OUTPUT_ROOT}/posix" "${OUTPUT_ROOT}/usr/libexec/ignity" -cp "${OUTPUT_ROOT}/bin/wait" "${OUTPUT_ROOT}/usr/libexec/ignity/wait" -mv "${OUTPUT_ROOT}"/bin/{cd,umask,wait} "${OUTPUT_ROOT}/posix/" - # Ignity itself, without the source install cp -a src/. "${OUTPUT_ROOT}/" rm -rf "${OUTPUT_ROOT}/usr/src" "${OUTPUT_ROOT}/run" diff --git a/distributions/packages/nfpm.amd64.yaml b/distributions/packages/nfpm.amd64.yaml index 85bd76d..0a169f1 100644 --- a/distributions/packages/nfpm.amd64.yaml +++ b/distributions/packages/nfpm.amd64.yaml @@ -10,19 +10,6 @@ description: | vendor: "techcode.io" homepage: "https://github.com/techcode-io/ignity" license: "Apache-2.0" -# The bundled binaries are the ones of execline, s6 and s6-portable-utils -provides: - - "execline" - - "s6" - - "s6-portable-utils" -conflicts: - - "execline" - - "s6" - - "s6-portable-utils" -replaces: - - "execline" - - "s6" - - "s6-portable-utils" overrides: # useradd, groupadd and getent (preboot, gen-accounts), GNU find (fix-perms) and grep (gen-accounts) deb: @@ -40,21 +27,14 @@ overrides: - "shadow" - "findutils" - "grep" +# execline, s6 and s6-portable-utils are installed in /usr/lib/ignity: nothing conflicts with the +# distribution, /init puts them first in PATH +scripts: + postinstall: ./distributions/packages/postinstall.sh + preremove: ./distributions/packages/preremove.sh contents: - - src: ./dist/ignity/bin/* - dst: /bin/ - - # cd, umask and wait: rpm distributions ship their own (bash) - - src: ./dist/ignity/posix/* - dst: /bin/ - packager: deb - - - src: ./dist/ignity/posix/* - dst: /bin/ - packager: apk - - - src: ./dist/ignity/usr/libexec - dst: /usr/libexec + - src: ./dist/ignity/usr/lib/ignity + dst: /usr/lib/ignity type: tree - src: ./dist/ignity/init @@ -67,13 +47,12 @@ contents: - src: ./dist/ignity/usr/bin/* dst: /usr/bin/ - - src: ./dist/ignity/etc/ignity/init/00-container-discover - dst: /etc/ignity/init/00-container-discover - type: config|noreplace - - dst: /etc/ignity/envs type: dir + - dst: /etc/ignity/init + type: dir + - dst: /etc/ignity/services type: dir diff --git a/distributions/packages/nfpm.arm64.yaml b/distributions/packages/nfpm.arm64.yaml index ab98157..f4206a7 100644 --- a/distributions/packages/nfpm.arm64.yaml +++ b/distributions/packages/nfpm.arm64.yaml @@ -10,19 +10,6 @@ description: | vendor: "techcode.io" homepage: "https://github.com/techcode-io/ignity" license: "Apache-2.0" -# The bundled binaries are the ones of execline, s6 and s6-portable-utils -provides: - - "execline" - - "s6" - - "s6-portable-utils" -conflicts: - - "execline" - - "s6" - - "s6-portable-utils" -replaces: - - "execline" - - "s6" - - "s6-portable-utils" overrides: # useradd, groupadd and getent (preboot, gen-accounts), GNU find (fix-perms) and grep (gen-accounts) deb: @@ -40,21 +27,14 @@ overrides: - "shadow" - "findutils" - "grep" +# execline, s6 and s6-portable-utils are installed in /usr/lib/ignity: nothing conflicts with the +# distribution, /init puts them first in PATH +scripts: + postinstall: ./distributions/packages/postinstall.sh + preremove: ./distributions/packages/preremove.sh contents: - - src: ./dist/ignity/bin/* - dst: /bin/ - - # cd, umask and wait: rpm distributions ship their own (bash) - - src: ./dist/ignity/posix/* - dst: /bin/ - packager: deb - - - src: ./dist/ignity/posix/* - dst: /bin/ - packager: apk - - - src: ./dist/ignity/usr/libexec - dst: /usr/libexec + - src: ./dist/ignity/usr/lib/ignity + dst: /usr/lib/ignity type: tree - src: ./dist/ignity/init @@ -67,13 +47,12 @@ contents: - src: ./dist/ignity/usr/bin/* dst: /usr/bin/ - - src: ./dist/ignity/etc/ignity/init/00-container-discover - dst: /etc/ignity/init/00-container-discover - type: config|noreplace - - dst: /etc/ignity/envs type: dir + - dst: /etc/ignity/init + type: dir + - dst: /etc/ignity/services type: dir diff --git a/distributions/packages/postinstall.sh b/distributions/packages/postinstall.sh new file mode 100644 index 0000000..a8a8a8c --- /dev/null +++ b/distributions/packages/postinstall.sh @@ -0,0 +1,5 @@ +#!/bin/sh +# For user scripts with a #!/bin/execlineb shebang, unless another execline provides one +if [ ! -e /bin/execlineb ] && [ ! -L /bin/execlineb ]; then + ln -s /usr/lib/ignity/bin/execlineb /bin/execlineb +fi diff --git a/distributions/packages/preremove.sh b/distributions/packages/preremove.sh new file mode 100644 index 0000000..b0ff01d --- /dev/null +++ b/distributions/packages/preremove.sh @@ -0,0 +1,8 @@ +#!/bin/sh +# Keep the link on upgrade: rpm passes the number of versions left (1), deb `upgrade` +case "${1}" in + 1 | upgrade | failed-upgrade) exit 0 ;; +esac +if [ "$(readlink /bin/execlineb)" = /usr/lib/ignity/bin/execlineb ]; then + rm -f /bin/execlineb +fi diff --git a/src/etc/ignity/init/00-container-discover b/src/etc/ignity/init/00-container-discover deleted file mode 100755 index 0cbe5b7..0000000 --- a/src/etc/ignity/init/00-container-discover +++ /dev/null @@ -1,3 +0,0 @@ -#!/bin/execlineb -P -foreground { s6-echo "Operating system info" } -foreground { redirfd -r 0 /proc/version s6-cat } diff --git a/src/etc/s6/boot/stage1 b/src/etc/s6/boot/stage1 index 4895c97..df70807 100755 --- a/src/etc/s6/boot/stage1 +++ b/src/etc/s6/boot/stage1 @@ -1,4 +1,4 @@ -#!/bin/execlineb -S0 +#!/usr/lib/ignity/bin/execlineb -S0 # Use preboot to setup user preboot diff --git a/src/etc/s6/boot/stage1-envs b/src/etc/s6/boot/stage1-envs index dfe99bf..591df7f 100755 --- a/src/etc/s6/boot/stage1-envs +++ b/src/etc/s6/boot/stage1-envs @@ -1,4 +1,4 @@ -#!/bin/execlineb -S0 +#!/usr/lib/ignity/bin/execlineb -S0 # Build /run/ignity/envs-defaults from the /etc/ignity/envs files, in order (later files win). # Only the variables defined by those files are stored. The container environment is never diff --git a/src/etc/s6/boot/stage2 b/src/etc/s6/boot/stage2 index 83a5044..aad061d 100755 --- a/src/etc/s6/boot/stage2 +++ b/src/etc/s6/boot/stage2 @@ -1,4 +1,4 @@ -#!/bin/execlineb -S0 +#!/usr/lib/ignity/bin/execlineb -S0 # This file is executed (not as process 1!) as soon as s6-svscan # starts, with the original stdin/out/err and the environment of stage 1. diff --git a/src/etc/s6/boot/stage2-cmd b/src/etc/s6/boot/stage2-cmd index 1781715..e36f70e 100755 --- a/src/etc/s6/boot/stage2-cmd +++ b/src/etc/s6/boot/stage2-cmd @@ -1,4 +1,4 @@ -#!/bin/execlineb -S0 +#!/usr/lib/ignity/bin/execlineb -S0 # Run the CMD, dropping privileges to USERMAP_UID:USERMAP_GID when it makes sense: # - they are both 0 (default): nothing to drop, the CMD runs as root like before diff --git a/src/etc/s6/boot/stage2-copy b/src/etc/s6/boot/stage2-copy index 1772cb2..3dd8bb2 100755 --- a/src/etc/s6/boot/stage2-copy +++ b/src/etc/s6/boot/stage2-copy @@ -1,4 +1,4 @@ -#!/bin/execlineb -S1 +#!/usr/lib/ignity/bin/execlineb -S1 # Copy user provided files of /etc/ignity/ to /run/ignity/. # Although s6-hiercopy is prefered, and until it doesn't support 'follow symlinks' diff --git a/src/etc/s6/boot/stage2-init b/src/etc/s6/boot/stage2-init index 574d9da..5cc3a14 100755 --- a/src/etc/s6/boot/stage2-init +++ b/src/etc/s6/boot/stage2-init @@ -1,4 +1,4 @@ -#!/bin/execlineb -S0 +#!/usr/lib/ignity/bin/execlineb -S0 if { if -t { eltest -d /run/ignity/init } diff --git a/src/etc/s6/boot/stage2-services b/src/etc/s6/boot/stage2-services index 387dfdc..a4dd472 100755 --- a/src/etc/s6/boot/stage2-services +++ b/src/etc/s6/boot/stage2-services @@ -1,4 +1,4 @@ -#!/bin/execlineb -S0 +#!/usr/lib/ignity/bin/execlineb -S0 if { if -t { eltest -d /run/ignity/services } diff --git a/src/etc/s6/boot/stage3 b/src/etc/s6/boot/stage3 index 12848ef..44d9701 100755 --- a/src/etc/s6/boot/stage3 +++ b/src/etc/s6/boot/stage3 @@ -1,8 +1,7 @@ -#!/bin/execlineb -S0 +#!/usr/lib/ignity/bin/execlineb -S0 # This is the shutdown script, running as process 1. -# execline-cd, not cd: rpm distributions ship a cd of their own (bash), which the ignity rpm leaves in place -execline-cd / +cd / # Use CMD exit code (written by stage2) defaulting to zero if not present. backtick -D 0 STAGE2_EXITED { redirfd -r 0 /run/ignity/stage2-exited s6-cat } @@ -50,8 +49,7 @@ s6-sleep -m 200 foreground { redirfd -a 2 /dev/null redirfd -a 1 /dev/null s6-nuke -k } # foreground is process 1: it survives again # Reap all the zombies then sync, and we're done. -# execline's wait by its own path: rpm distributions ship a wait of their own (bash), which the ignity rpm leaves in place -/usr/libexec/ignity/wait { } +wait { } foreground { if { eltest ${IGNITY_SYNC_DISKS} -eq 1 } s6-sync diff --git a/src/etc/s6/boot/stage3-finalize b/src/etc/s6/boot/stage3-finalize index 3b10cd6..96c58f9 100755 --- a/src/etc/s6/boot/stage3-finalize +++ b/src/etc/s6/boot/stage3-finalize @@ -1,4 +1,4 @@ -#!/bin/execlineb -S0 +#!/usr/lib/ignity/bin/execlineb -S0 importas -D 5000 -u IGNITY_KILL_FINALIZE_MAXTIME IGNITY_KILL_FINALIZE_MAXTIME if { diff --git a/src/etc/s6/services/.s6-svscan/crash b/src/etc/s6/services/.s6-svscan/crash index 82558d2..423c4a4 100755 --- a/src/etc/s6/services/.s6-svscan/crash +++ b/src/etc/s6/services/.s6-svscan/crash @@ -1,4 +1,4 @@ -#!/bin/execlineb -P +#!/usr/lib/ignity/bin/execlineb -P # This file is run when an unrecoverable error happens # to s6-svscan. Edit it to suit your needs. diff --git a/src/etc/s6/services/.s6-svscan/finish b/src/etc/s6/services/.s6-svscan/finish index fac9c07..b62984a 100755 --- a/src/etc/s6/services/.s6-svscan/finish +++ b/src/etc/s6/services/.s6-svscan/finish @@ -1,2 +1,2 @@ -#!/bin/execlineb -S0 +#!/usr/lib/ignity/bin/execlineb -S0 /etc/s6/boot/stage3 $@ \ No newline at end of file diff --git a/src/init b/src/init index eda2572..9969712 100755 --- a/src/init +++ b/src/init @@ -1,5 +1,5 @@ -#!/bin/execlineb -S0 +#!/usr/lib/ignity/bin/execlineb -S0 -/bin/importas -D /usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin PATH PATH -export PATH ${PATH} +# execline, s6 and s6-portable-utils come first in PATH for the whole supervision tree +/usr/lib/ignity/libexec/with-ignity-path /etc/s6/boot/stage1 $@ \ No newline at end of file diff --git a/src/usr/bin/fix-perms b/src/usr/bin/fix-perms index a7926da..9e79d38 100755 --- a/src/usr/bin/fix-perms +++ b/src/usr/bin/fix-perms @@ -1,6 +1,7 @@ -#!/bin/execlineb -P +#!/usr/lib/ignity/bin/execlineb -P -/bin/export PATH /usr/bin:/bin +# Can run outside /init +/usr/lib/ignity/libexec/with-ignity-path # Import user uid:gid importas -D 0 -u USERMAP_UID USERMAP_UID diff --git a/src/usr/bin/gen-accounts b/src/usr/bin/gen-accounts index 7193d7e..308485b 100755 --- a/src/usr/bin/gen-accounts +++ b/src/usr/bin/gen-accounts @@ -1,4 +1,4 @@ -#!/bin/execlineb -P +#!/usr/lib/ignity/bin/execlineb -P # # gen-accounts @@ -11,7 +11,8 @@ # Called by preboot when IGNITY_ACCOUNTS_DIR is set and USER doesn't exist. # -/bin/export PATH /usr/bin:/bin +# Can run outside /init +/usr/lib/ignity/libexec/with-ignity-path importas -u IGNITY_ACCOUNTS_DIR IGNITY_ACCOUNTS_DIR importas -D root -u USER USER diff --git a/src/usr/bin/load-envfile b/src/usr/bin/load-envfile index 338b7f0..5411813 100755 --- a/src/usr/bin/load-envfile +++ b/src/usr/bin/load-envfile @@ -1,3 +1,4 @@ -#!/bin/execlineb -S0 -/bin/export PATH /usr/bin:/bin +#!/usr/lib/ignity/bin/execlineb -S0 +# Can run outside /init +/usr/lib/ignity/libexec/with-ignity-path envfile - $@ \ No newline at end of file diff --git a/src/usr/bin/preboot b/src/usr/bin/preboot index 0237f64..dee9f25 100755 --- a/src/usr/bin/preboot +++ b/src/usr/bin/preboot @@ -1,7 +1,7 @@ -#!/bin/execlineb -S0 +#!/usr/lib/ignity/bin/execlineb -S0 -/bin/importas -D /usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin PATH PATH -export PATH ${PATH} +# Can run outside /init (build time) +/usr/lib/ignity/libexec/with-ignity-path importas -D root USER USER importas -D 0 USERMAP_UID USERMAP_UID diff --git a/src/usr/bin/with-retries b/src/usr/bin/with-retries index 2dea61b..6b59e33 100755 --- a/src/usr/bin/with-retries +++ b/src/usr/bin/with-retries @@ -1,8 +1,10 @@ -#!/bin/execlineb +#!/usr/lib/ignity/bin/execlineb # # with-retries -n numretries -t millisecs prog... # +# Can run outside /init +/usr/lib/ignity/libexec/with-ignity-path elgetopt "n:t:" multisubstitute { diff --git a/src/usr/lib/ignity/libexec/with-ignity-path b/src/usr/lib/ignity/libexec/with-ignity-path new file mode 100755 index 0000000..41a2ebe --- /dev/null +++ b/src/usr/lib/ignity/libexec/with-ignity-path @@ -0,0 +1,18 @@ +#!/usr/lib/ignity/bin/execlineb -S0 + +# +# with-ignity-path prog... +# +# Run prog with /usr/lib/ignity/bin first in PATH, where ignity installs execline, s6 and +# s6-portable-utils: for the entry points that can run outside /init (build time, exec sessions). +# Commands are called by their path here, PATH may not have them yet. +# + +/usr/lib/ignity/bin/importas -D /usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin PATH PATH +/usr/lib/ignity/bin/case -s -- ${PATH} { + /usr/lib/ignity/bin { $@ } + /usr/lib/ignity/bin:* { $@ } +} +# export looks prog up with the PATH it was given, exec with the new one +/usr/lib/ignity/bin/export PATH /usr/lib/ignity/bin:${PATH} +/usr/lib/ignity/bin/exec $@ diff --git a/src/usr/src/install-ignity.sh b/src/usr/src/install-ignity.sh index 3a0fe9f..960e033 100644 --- a/src/usr/src/install-ignity.sh +++ b/src/usr/src/install-ignity.sh @@ -20,6 +20,10 @@ readonly SKALIBS_VERSION="2.15.1.0" readonly EXECLINE_VERSION="2.9.9.2" readonly S6_VERSION="2.15.1.0" readonly S6_PORTABLE_UTILS_VERSION="2.3.1.2" +# execline, s6 and s6-portable-utils stay out of the root filesystem: /init puts them first in PATH. +# They call each other by their path, PATH or not +readonly IGNITY_LIB_DIR="/usr/lib/ignity" +readonly IGNITY_CONFIGURE="--bindir=${IGNITY_LIB_DIR}/bin --libexecdir=${IGNITY_LIB_DIR}/libexec --enable-absolute-paths" BUILD_DEPENDENCIES="ca-certificates build-essential git" # Fine tuning @@ -172,13 +176,14 @@ apt-get install --no-install-recommends -y ${BUILD_DEPENDENCIES} # Shallow clone skalibs, execline, s6, s6-portable-utils pkg::install "skalibs" "${SKALIBS_VERSION}" "${SKALIBS_URL}" "--enable-shared" -pkg::install "execline" "${EXECLINE_VERSION}" "${EXECLINE_URL}" "--enable-shared" -pkg::install "s6" "${S6_VERSION}" "${S6_URL}" -pkg::install "s6-portable-utils" "${S6_PORTABLE_UTILS_VERSION}" "${S6_PORTABLE_UTILS_URL}" - -# execline's wait, by the path stage3 calls it with (see distributions/build/build-ignity.sh) -mkdir -p /usr/libexec/ignity -ln -sf /bin/wait /usr/libexec/ignity/wait +pkg::install "execline" "${EXECLINE_VERSION}" "${EXECLINE_URL}" "--enable-shared ${IGNITY_CONFIGURE}" +pkg::install "s6" "${S6_VERSION}" "${S6_URL}" "${IGNITY_CONFIGURE}" +pkg::install "s6-portable-utils" "${S6_PORTABLE_UTILS_VERSION}" "${S6_PORTABLE_UTILS_URL}" "${IGNITY_CONFIGURE}" + +# For user scripts with a #!/bin/execlineb shebang, unless another execline provides one +if [ ! -e /bin/execlineb ] && [ ! -L /bin/execlineb ]; then + ln -s "${IGNITY_LIB_DIR}/bin/execlineb" /bin/execlineb +fi # Diff to do iso uninstall dpkg -l | awk '{print $2;}' > /tmp/after diff --git a/tests/test_boot.py b/tests/test_boot.py index 51a1888..f1254c1 100644 --- a/tests/test_boot.py +++ b/tests/test_boot.py @@ -9,6 +9,21 @@ def test_execlineb_installed(image): assert run_in_container(image, "command -v execlineb").returncode == 0 +def test_binaries_out_of_root_filesystem(image): + # execline's wait, not the one of the distribution (bash on rpm distributions) + cmd = ( + 'test "$(type -P s6-svscan)" = /usr/lib/ignity/bin/s6-svscan' + ' && test "$(type -P wait)" = /usr/lib/ignity/bin/wait' + " && test ! -e /usr/bin/s6-svscan" + ) + assert run_in_container(image, cmd).returncode == 0 + + +def test_execlineb_compat_link(image): + # For user scripts with a #!/bin/execlineb shebang + assert run_in_container(image, "test -x /bin/execlineb").returncode == 0 + + def test_fix_perms_installed(image): assert run_in_container(image, "command -v fix-perms").returncode == 0 From 374a092b2dc115b2b2028835df994a7d3b59c4e3 Mon Sep 17 00:00:00 2001 From: Adrien Mannocci Date: Thu, 22 Oct 2026 16:00:47 +0200 Subject: [PATCH 3/3] fix: create /etc/ignity directories in the source install Git doesn't keep empty directories: with 00-container-discover removed, nothing under src/etc/ignity is tracked, so a fresh checkout installed from source has no /etc/ignity. The packages already create them. --- src/usr/src/install-ignity.sh | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/usr/src/install-ignity.sh b/src/usr/src/install-ignity.sh index 960e033..3af4190 100644 --- a/src/usr/src/install-ignity.sh +++ b/src/usr/src/install-ignity.sh @@ -185,6 +185,9 @@ if [ ! -e /bin/execlineb ] && [ ! -L /bin/execlineb ]; then ln -s "${IGNITY_LIB_DIR}/bin/execlineb" /bin/execlineb fi +# User provided directories, git doesn't keep them when empty (the packages create them too) +mkdir -p /etc/ignity/envs /etc/ignity/init /etc/ignity/services /etc/ignity/perms /etc/ignity/finalize + # Diff to do iso uninstall dpkg -l | awk '{print $2;}' > /tmp/after BUILD_DEPENDENCIES=$(diff /tmp/before /tmp/after | grep ">" | awk '{print $2;}' || true)