diff --git a/.gitea/workflows/build-image.yml b/.gitea/workflows/build-image.yml index 502c233..549d32e 100644 --- a/.gitea/workflows/build-image.yml +++ b/.gitea/workflows/build-image.yml @@ -37,6 +37,9 @@ jobs: GITEA_API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }} GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }} KEEP_RELEASES: '3' + # Rebuild even without a Proxmox version change once an image reaches this + # age, so Debian base security updates make it into the image. + MAX_AGE_DAYS: '30' steps: - name: Check out @@ -58,29 +61,12 @@ jobs: run: | set -eu cd "$GITHUB_WORKSPACE" - - UPSTREAM=$(./scripts/ci/upstream-version.sh) - TAG="pve-${UPSTREAM}" - echo "upstream pve-manager: ${UPSTREAM}" - - PUBLISHED=$(curl -fsS -H "Authorization: token ${GITEA_TOKEN}" \ - "${GITEA_API}/releases?limit=1" \ - | python3 -c 'import json,sys; r=json.load(sys.stdin); print(r[0]["tag_name"] if r else "")') - echo "latest release: ${PUBLISHED:-}" - - echo "tag=${TAG}" >> "$GITHUB_OUTPUT" - echo "version=${UPSTREAM}" >> "$GITHUB_OUTPUT" - + ./scripts/ci/decide-build.sh "$MAX_AGE_DAYS" > "$GITHUB_OUTPUT" if [ "${{ inputs.force }}" = "true" ]; then echo "forced by manual trigger" - echo "build=yes" >> "$GITHUB_OUTPUT" - elif [ "$PUBLISHED" = "$TAG" ]; then - echo "${TAG} is already published — nothing to do" - echo "build=no" >> "$GITHUB_OUTPUT" - else - echo "new version — building" - echo "build=yes" >> "$GITHUB_OUTPUT" + sed -i 's/^build=no$/build=yes/' "$GITHUB_OUTPUT" fi + cat "$GITHUB_OUTPUT" - name: Build if: steps.decide.outputs.build == 'yes' diff --git a/README.md b/README.md index 52d8564..057aaba 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,7 @@ variable change away (see [Moving to a new Proxmox release](#moving-to-a-new-pro ## Table of contents - [What this does](#what-this-does) +- [Prebuilt images](#prebuilt-images) - [Why Debian + the Proxmox repository, and not the Proxmox ISO](#why-debian--the-proxmox-repository-and-not-the-proxmox-iso) - [How it works](#how-it-works) - [Requirements](#requirements) @@ -27,6 +28,7 @@ variable change away (see [Moving to a new Proxmox release](#moving-to-a-new-pro - [Four traps this image works around](#four-traps-this-image-works-around) - [Moving to a new Proxmox release](#moving-to-a-new-proxmox-release) - [Build performance](#build-performance) +- [Release automation](#release-automation) - [Repository layout](#repository-layout) - [Troubleshooting](#troubleshooting) - [Verified status](#verified-status) @@ -163,6 +165,33 @@ fails is retried on the next boot rather than leaving the node half-configured. --- +## Prebuilt images + +Images are built and published automatically: + +**→ [Download the latest image](https://gitea.mynodes.xyz/ilker/maas-proxmox/releases)** + +Each release carries the image, its SHA-256 checksum, the build's metadata +(`image-info.txt`) and `SOURCES.md`, the corresponding-source offer required by the +licences of the packages inside it. The builds are **unofficial** and not affiliated +with Proxmox Server Solutions GmbH. + +```bash +curl -LO https://gitea.mynodes.xyz/ilker/maas-proxmox/releases/download//maas-image-pve--amd64.tar.gz +curl -LO https://gitea.mynodes.xyz/ilker/maas-proxmox/releases/download//maas-image-pve--amd64.tar.gz.sha256 +sha256sum -c maas-image-pve--amd64.tar.gz.sha256 + +maas $PROFILE boot-resources create name='custom/proxmox-ve-9' \ + title='Proxmox VE 9' architecture='amd64/generic' \ + filetype='tgz' content@=maas-image-pve--amd64.tar.gz +``` + +You still need the curtin preseed on your MAAS region controller — see +[Quick start](#quick-start) step 5. Downloading an image skips only steps 1-3. + +Prefer building it yourself if you would rather not trust someone else's binary; +that is what the rest of this document is about. + ## Quick start ```bash @@ -722,6 +751,45 @@ serve them; package signatures are still verified. --- +## Release automation + +[`.gitea/workflows/build-image.yml`](.gitea/workflows/build-image.yml) builds the image +on a self-hosted runner and publishes it as a release. + +It runs **daily**, but rebuilds only when there is a reason to. Proxmox publishes +roughly weekly — the trixie repository currently holds 56 `pve-manager` and 28 +`proxmox-kernel` versions — so an unconditional daily build would produce about +45 GB a month of near-identical artifacts. A check that finds nothing costs about +30 seconds. + +A rebuild is triggered when any of these holds: + +| Trigger | Why | +|---|---| +| `pve-manager` differs from the published image | The obvious one | +| `proxmox-default-kernel` differs | Kernel security fixes do not bump `pve-manager`, and those matter most | +| The newest release is older than `MAX_AGE_DAYS` (30) | Debian base security updates bump neither of the above; without a floor an image could sit unchanged for months | +| Manual dispatch with `force` | Escape hatch | + +The comparison reads `image-info.txt` from the last release rather than guessing from +tag names, so it reflects what is actually inside the published image. +[`scripts/ci/decide-build.sh`](scripts/ci/decide-build.sh) can be run by hand to see the +decision without triggering anything. + +Releases are tagged `pve--` and pruned to the newest +`KEEP_RELEASES` (3) — at ~1.5 GB each, unbounded retention fills the server. + +### Runner + +The runner is registered in **host mode**: steps run directly on the build machine as +root, because the build needs `/dev/kvm`, `qemu-nbd`, FUSE and root privileges, all of +which a container would have to be granted anyway. The consequence is worth stating +plainly: anything able to dispatch a workflow in this repository gets root on the build +machine. Do not attach this runner to a repository that accepts outside contributions. + +Checkout is a plain `git clone`, not `actions/checkout` — the latter is a JavaScript +action and a host-mode runner has no Node.js runtime. + ## Repository layout ``` @@ -730,6 +798,8 @@ scripts/install-deps.sh build host dependencies scripts/customize-proxmox.sh.in template for the script that runs inside the build VM scripts/deploy-cluster.sh deploy a whole cluster through MAAS scripts/verify-image.sh check a built image's contents +scripts/ci/ release pipeline helpers (decision, artifacts, publish, prune) +.gitea/workflows/ Gitea Actions pipeline overlay/ files baked into the image usr/local/sbin/pve-maas-init first-boot state machine etc/pve-maas/pve-maas.conf defaults, with every option documented diff --git a/scripts/ci/decide-build.sh b/scripts/ci/decide-build.sh new file mode 100755 index 0000000..07a20ad --- /dev/null +++ b/scripts/ci/decide-build.sh @@ -0,0 +1,96 @@ +#!/bin/bash +# Copyright (C) 2026 Ilker Manap +# SPDX-License-Identifier: AGPL-3.0-or-later +# +# Decides whether a rebuild is worth doing, and prints the decision as +# key=value lines suitable for $GITHUB_OUTPUT. +# +# decide-build.sh [max-age-days] (default 30) +# +# A rebuild happens when any of these is true: +# +# 1. pve-manager in the repository differs from the published image +# 2. proxmox-default-kernel differs — kernel security fixes do not bump +# pve-manager, and those are the ones that matter most +# 3. the newest release is older than max-age-days — Debian base security +# updates bump neither of the above, so without a floor an image could +# sit unchanged for months +# +# Requires GITEA_API and GITEA_TOKEN in the environment. +set -euo pipefail +cd "$(dirname "$0")/../.." + +MAX_AGE_DAYS="${1:-30}" +: "${GITEA_API:?GITEA_API required}" +: "${GITEA_TOKEN:?GITEA_TOKEN required}" + +SUITE=$(make -s print-var VAR=DEBIAN_SERIES) +COMP=$(make -s print-var VAR=PVE_REPO) +URI=$(make -s print-var VAR=PVE_REPO_URI) +ARCH=$(make -s print-var VAR=ARCH) + +# Newest version of each package we care about, in one pass over the index. +PKGS=$(curl -fsS "${URI}/dists/${SUITE}/${COMP}/binary-${ARCH}/Packages.gz" | gunzip) +newest() { + printf '%s\n' "$PKGS" \ + | awk -v want="$1" '$1=="Package:" && $2==want {p=1; next} p && $1=="Version:" {print $2; p=0}' \ + | sort -V | tail -1 +} +UP_PVE=$(newest pve-manager) +UP_KERNEL=$(newest proxmox-default-kernel) +[ -n "$UP_PVE" ] || { echo "pve-manager not found in ${URI} ${SUITE}/${COMP}" >&2; exit 1; } +[ -n "$UP_KERNEL" ] || { echo "proxmox-default-kernel not found" >&2; exit 1; } + +echo "upstream: pve-manager=${UP_PVE} proxmox-default-kernel=${UP_KERNEL}" >&2 + +# What the newest published release actually contains. image-info.txt is a few +# hundred bytes and is published with every release, so this needs no guessing +# from tag names. +LATEST=$(curl -fsS -H "Authorization: token ${GITEA_TOKEN}" "${GITEA_API}/releases?limit=1") +INFO_URL=$(printf '%s' "$LATEST" | python3 -c ' +import json, sys +r = json.load(sys.stdin) +if r: + for a in r[0].get("assets", []): + if a["name"] == "image-info.txt": + print(a["browser_download_url"]); break +') +CREATED=$(printf '%s' "$LATEST" | python3 -c ' +import json, sys +r = json.load(sys.stdin); print(r[0]["created_at"] if r else "")') + +PREV_PVE="" PREV_KERNEL="" +if [ -n "$INFO_URL" ]; then + INFO=$(curl -fsSL "$INFO_URL" || true) + PREV_PVE=$(printf '%s' "$INFO" | awk -F= '$1=="pve-manager"{print $2}') + PREV_KERNEL=$(printf '%s' "$INFO" | awk -F= '$1=="proxmox-default-kernel"{print $2}') +fi +echo "published: pve-manager=${PREV_PVE:-} proxmox-default-kernel=${PREV_KERNEL:-}" >&2 + +AGE_DAYS=99999 +if [ -n "$CREATED" ]; then + AGE_DAYS=$(CREATED="$CREATED" python3 -c ' +import datetime, os +c = datetime.datetime.fromisoformat(os.environ["CREATED"].replace("Z", "+00:00")) +print((datetime.datetime.now(datetime.timezone.utc) - c).days)') + echo "newest release is ${AGE_DAYS} day(s) old" >&2 +fi + +BUILD=no +REASON="up to date" +if [ -z "$PREV_PVE" ]; then + BUILD=yes; REASON="no published image yet" +elif [ "$UP_PVE" != "$PREV_PVE" ]; then + BUILD=yes; REASON="pve-manager ${PREV_PVE} -> ${UP_PVE}" +elif [ "$UP_KERNEL" != "$PREV_KERNEL" ]; then + BUILD=yes; REASON="kernel ${PREV_KERNEL} -> ${UP_KERNEL}" +elif [ "$AGE_DAYS" -ge "$MAX_AGE_DAYS" ]; then + BUILD=yes; REASON="image is ${AGE_DAYS} days old (limit ${MAX_AGE_DAYS}) — picking up Debian updates" +fi +echo "decision: ${BUILD} (${REASON})" >&2 + +printf 'build=%s\n' "$BUILD" +printf 'tag=%s\n' "pve-${UP_PVE}-$(date -u +%Y%m%d)" +printf 'version=%s\n' "$UP_PVE" +printf 'kernel=%s\n' "$UP_KERNEL" +printf 'reason=%s\n' "$REASON"