5 Commits

Author SHA1 Message Date
e1d2c00a97 Force IPv4 in the build VM and fix the APT cache path
All checks were successful
build-image / build (push) Successful in 13m15s
The build logs here show the same stall measured on maas-samba-ad: a 28.5 MB
apt fetch taking 31s and a 12.0 MB one taking 30s, while a 14.1 MB fetch
completed in under a second. A fixed cost that ignores size is a timeout, not
a bandwidth limit — QEMU's user-mode network advertises IPv6 that does not
work, so apt's parallel connections black-hole on it and fall back to IPv4
only when the 30-second timeout expires.

Patch the build VM's cloud-init seed from bootcmd, which runs before SSH is
up and therefore covers upstream's apt calls too. On maas-samba-ad this took
the same 28.5 MB fetch from 31s to 3s; no build has been run here since, and
the README says so.

APT_PROXY also never worked as documented: a cache cannot see inside a CONNECT
tunnel, so repositories must be rewritten to plain http, and Debian 13 keeps
the real mirror URLs in /etc/apt/mirrors/*.list behind the mirror+file:
method, which the old sed missed.

Drop the invented "roughly 700 MB" saving from the README. On maas-samba-ad a
fully warm cache was worth about three seconds of a 4m40s build. This image
pulls far more from the Proxmox repository, so the cache may matter more here,
but that is unmeasured and is now listed as such.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-05 23:59:48 +02:00
85c47eddf8 Do not fail publishing when the tag already exists
A forced rebuild on the same day, of the same upstream version, produces a tag
that is already published, and the release API answers 409 Conflict. The build
itself had succeeded and passed all its checks; only publishing failed, so the
run reported failure after doing all the work.

The tag now gets the run number appended when one with that name is already
there. Overwriting was the alternative and is worse: someone may already have
downloaded the artifact behind that tag.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-05 15:34:30 +02:00
612887bf6b Resolve the dist path before changing directory
All checks were successful
build-image / build (push) Successful in 1s
publish-release.sh gained make print-var calls, which need the repository root,
but its dist argument is relative to wherever the caller stood. The path is now
made absolute first and the cd happens after, so the script works both from the
workflow (which already cds) and by hand.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-04 23:17:20 +02:00
5865198240 Wire up arm64 without claiming it works
Proxmox VE 9.2 made arm64 official — same code base, repositories and release
lifecycle as x86-64 — and the pve-no-subscription repository carries proxmox-ve,
pve-manager, proxmox-default-kernel and pve-qemu-kvm for it. The build was still
hard-wired to amd64 in four places, which is now fixed:

  * firmware is chosen from the target architecture (AAVMF for arm64, OVMF for
    amd64) and padded to 64 MiB as QEMU's arm64 virt machine requires. Upstream
    packer-maas keys this on the host architecture, which only works when host
    and target match.
  * host_is_arm is derived from uname rather than hard-coded false, so KVM is
    used exactly when host and target architectures agree
  * the release body builds its MAAS upload command from MAAS_ARCH, PVE_VERSION
    and IMAGE_NAME instead of repeating amd64
  * install-deps.sh installs qemu-system-arm and AAVMF under WITH_ARM64=1,
    off by default since they are useless on an amd64-only builder

verify-image.sh asserted no kernel matching *-amd64, which would have passed
silently on an arm64 image carrying a Debian arm64 kernel. It now rejects any
/boot/vmlinuz-* not ending in -pve, which holds for both architectures. Checking
that with synthetic file lists caught a first attempt that returned "pass" for an
image containing both a PVE and a Debian kernel, so the expression is now a single
pipeline verified against GNU grep on the build host — the macOS grep this was
first tried on disagrees, and only the Linux behaviour matters here.

No arm64 image has been built and none deployed. The README gains an arm64
section saying so plainly, listing the two real obstacles — TCG emulation on an
x86_64 builder, and having no arm64 hardware to deploy to — and the entry stays
under "Not verified".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-04 23:16:21 +02:00
4719d30359 Rebuild on kernel changes and on age, not just on pve-manager
The build condition only compared pve-manager, which left two gaps. Kernel
security fixes ship in proxmox-default-kernel and do not bump pve-manager, so the
updates that matter most would not have triggered a rebuild. Debian base security
updates bump neither, so an image could have sat unchanged indefinitely while its
openssl and glibc went stale.

scripts/ci/decide-build.sh now rebuilds when pve-manager changes, when
proxmox-default-kernel changes, or when the newest release passes MAX_AGE_DAYS
(30). It compares against image-info.txt from the last release rather than
inferring from tag names, so the comparison reflects what is actually inside the
published image, and it can be run by hand to see the decision without triggering
anything.

The daily schedule stays. Measured from the trixie repository, Proxmox publishes
about weekly — 56 pve-manager and 28 proxmox-kernel versions since 9.0 — so daily
checking costs about 30 seconds on the days nothing changed and cuts worst-case
staleness from a week to a day. Tags carry the date now (pve-<version>-<date>)
because an age-triggered rebuild can repeat a version.

The README gains a "Prebuilt images" section linking the releases page, with the
checksum and MAAS upload commands, and a "Release automation" section explaining
the trigger table and stating plainly that the host-mode runner gives root on the
build machine to anything that can dispatch a workflow.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-04 22:59:10 +02:00
8 changed files with 316 additions and 41 deletions

View File

@@ -37,6 +37,9 @@ jobs:
GITEA_API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }} GITEA_API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }}
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }} GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
KEEP_RELEASES: '3' 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: steps:
- name: Check out - name: Check out
@@ -58,29 +61,12 @@ jobs:
run: | run: |
set -eu set -eu
cd "$GITHUB_WORKSPACE" cd "$GITHUB_WORKSPACE"
./scripts/ci/decide-build.sh "$MAX_AGE_DAYS" > "$GITHUB_OUTPUT"
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:-<none>}"
echo "tag=${TAG}" >> "$GITHUB_OUTPUT"
echo "version=${UPSTREAM}" >> "$GITHUB_OUTPUT"
if [ "${{ inputs.force }}" = "true" ]; then if [ "${{ inputs.force }}" = "true" ]; then
echo "forced by manual trigger" echo "forced by manual trigger"
echo "build=yes" >> "$GITHUB_OUTPUT" sed -i 's/^build=no$/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"
fi fi
cat "$GITHUB_OUTPUT"
- name: Build - name: Build
if: steps.decide.outputs.build == 'yes' if: steps.decide.outputs.build == 'yes'

View File

@@ -31,6 +31,9 @@ PVE_EXTRA_PACKAGES ?= ifupdown2 open-iscsi chrony postfix lvm2 thin-provisioning
ethtool bridge-utils ipmitool nvme-cli lsscsi sudo ethtool bridge-utils ipmitool nvme-cli lsscsi sudo
# ---------------------------------------------------------------- imaj ayarlari # ---------------------------------------------------------------- imaj ayarlari
# amd64 | arm64. arm64 is wired up but has never been built or deployed -
# see "Verified status" in the README. Building arm64 on an x86_64 host means
# TCG emulation with no KVM, which is very slow; prefer a native arm64 builder.
ARCH ?= amd64 ARCH ?= amd64
SUBARCH ?= generic SUBARCH ?= generic
BOOT ?= uefi BOOT ?= uefi
@@ -85,8 +88,21 @@ MAAS_IMAGE_NAME ?= custom/$(IMAGE_NAME)
# MAAS snap kurulumu icin: /var/snap/maas/current/preseeds # MAAS snap kurulumu icin: /var/snap/maas/current/preseeds
MAAS_PRESEED_DIR ?= /var/snap/maas/current/preseeds MAAS_PRESEED_DIR ?= /var/snap/maas/current/preseeds
OVMF_DIR ?= /usr/share/OVMF # UEFI firmware, keyed on the TARGET architecture - an aarch64 guest needs
OVMF_SFX ?= $(shell test -f $(OVMF_DIR)/OVMF_CODE.fd && echo "" || echo "_4M") # AAVMF whatever the build host is. (Upstream packer-maas keys this on the host
# architecture instead, which only works when host and target match.)
ifeq ($(strip $(ARCH)),arm64)
FW_DIR ?= /usr/share/AAVMF
FW ?= AAVMF
else
FW_DIR ?= /usr/share/OVMF
FW ?= OVMF
endif
FW_SFX ?= $(shell test -f $(FW_DIR)/$(FW)_CODE.fd && echo "" || echo "_4M")
# KVM is only usable when the host and the guest share an architecture.
# Otherwise QEMU falls back to TCG emulation, which is very slow.
HOST_IS_ARM := $(shell test "$$(uname -m)" = aarch64 && echo true || echo false)
# ---------------------------------------------------------------- hedefler # ---------------------------------------------------------------- hedefler
.PHONY: help deps deps-cache check-upstream print-var checkout overlay customize image verify preseed install-preseed upload clean distclean lint .PHONY: help deps deps-cache check-upstream print-var checkout overlay customize image verify preseed install-preseed upload clean distclean lint
@@ -158,6 +174,17 @@ image: checkout $(CUSTOMIZE)
sed -i -E 's|^([[:space:]]*disk_size[[:space:]]*=[[:space:]]*).*|\1"$(DISK_SIZE)"|' $(TPL)/debian-cloudimg.pkr.hcl sed -i -E 's|^([[:space:]]*disk_size[[:space:]]*=[[:space:]]*).*|\1"$(DISK_SIZE)"|' $(TPL)/debian-cloudimg.pkr.hcl
sed -i -E 's|^([[:space:]]*cpus[[:space:]]*=[[:space:]]*).*|\1$(BUILD_CPUS)|' $(TPL)/debian-cloudimg.pkr.hcl sed -i -E 's|^([[:space:]]*cpus[[:space:]]*=[[:space:]]*).*|\1$(BUILD_CPUS)|' $(TPL)/debian-cloudimg.pkr.hcl
sed -i -E 's|^([[:space:]]*memory[[:space:]]*=[[:space:]]*).*|\1$(BUILD_MEM)|' $(TPL)/debian-cloudimg.pkr.hcl sed -i -E 's|^([[:space:]]*memory[[:space:]]*=[[:space:]]*).*|\1$(BUILD_MEM)|' $(TPL)/debian-cloudimg.pkr.hcl
@echo "==> Build VM apt ayari yamalaniyor (IPv4 zorlama + timeout)"
# Every apt fetch over roughly 15 MB inside the build VM used to stall for
# exactly 31 seconds regardless of its size, which is a connection timeout
# rather than a bandwidth limit: QEMU's user-mode network offers IPv6 that
# does not actually work, so apt's parallel connections black-hole on it and
# only fall back to IPv4 when the timeout expires. Forcing IPv4 took the
# 28.5 MB upstream fetch from 31s to 3s. bootcmd runs in cloud-init's
# init-local stage, before SSH is up, so this is in place for the upstream
# provisioners too, not just ours.
sed -i "/^bootcmd:/a\\ - echo 'Acquire::ForceIPv4 \"true\"; Acquire::http::Timeout \"20\"; Acquire::Retries \"3\";' > /etc/apt/apt.conf.d/99-build-speed" $(TPL)/user-data-cloudimg
@sed -n '/^bootcmd:/,+1p' $(TPL)/user-data-cloudimg
ifeq ($(strip $(DEBIAN_IMAGE_CHANNEL)),stable) ifeq ($(strip $(DEBIAN_IMAGE_CHANNEL)),stable)
@echo "==> Kararli Debian cloud image kullanilacak (packer onbellegi isabet eder)" @echo "==> Kararli Debian cloud image kullanilacak (packer onbellegi isabet eder)"
sed -i -E 's|/daily/latest/|/latest/|g; s|-daily\.qcow2|.qcow2|g' $(TPL)/debian-cloudimg.pkr.hcl sed -i -E 's|/daily/latest/|/latest/|g; s|-daily\.qcow2|.qcow2|g' $(TPL)/debian-cloudimg.pkr.hcl
@@ -165,8 +192,13 @@ ifeq ($(strip $(DEBIAN_IMAGE_CHANNEL)),stable)
endif endif
sed -i -E 's|--best --force|-$(GZIP_LEVEL) --force|' $(PM)/scripts/fuse-tar-root sed -i -E 's|--best --force|-$(GZIP_LEVEL) --force|' $(PM)/scripts/fuse-tar-root
@grep -nE 'disk_size|^ cpus|^ memory' $(TPL)/debian-cloudimg.pkr.hcl @grep -nE 'disk_size|^ cpus|^ memory' $(TPL)/debian-cloudimg.pkr.hcl
cp -v $(OVMF_DIR)/OVMF_CODE$(OVMF_SFX).fd $(TPL)/OVMF_CODE.fd cp -v $(FW_DIR)/$(FW)_CODE$(FW_SFX).fd $(TPL)/OVMF_CODE.fd
cp -v $(OVMF_DIR)/OVMF_VARS$(OVMF_SFX).fd $(TPL)/OVMF_VARS.fd cp -v $(FW_DIR)/$(FW)_VARS$(FW_SFX).fd $(TPL)/OVMF_VARS.fd
ifeq ($(strip $(ARCH)),arm64)
# AAVMF images must be padded to 64 MiB for QEMU's arm64 "virt" machine.
truncate -s 64m $(TPL)/OVMF_CODE.fd
truncate -s 64m $(TPL)/OVMF_VARS.fd
endif
rm -rf $(TPL)/output-cloudimg $(TPL)/seeds-cloudimg.iso rm -rf $(TPL)/output-cloudimg $(TPL)/seeds-cloudimg.iso
cd $(TPL) && PACKER_LOG=$(PACKER_LOG) packer init . cd $(TPL) && PACKER_LOG=$(PACKER_LOG) packer init .
cd $(TPL) && PACKER_LOG=$(PACKER_LOG) packer build \ cd $(TPL) && PACKER_LOG=$(PACKER_LOG) packer build \
@@ -174,8 +206,8 @@ endif
-var debian_version=$(DEBIAN_VERSION) \ -var debian_version=$(DEBIAN_VERSION) \
-var architecture=$(ARCH) \ -var architecture=$(ARCH) \
-var boot_mode=$(BOOT) \ -var boot_mode=$(BOOT) \
-var ovmf_suffix=$(OVMF_SFX) \ -var ovmf_suffix=$(FW_SFX) \
-var host_is_arm=false \ -var host_is_arm=$(HOST_IS_ARM) \
-var timeout=$(TIMEOUT) \ -var timeout=$(TIMEOUT) \
-var customize_script=$(CUSTOMIZE) \ -var customize_script=$(CUSTOMIZE) \
-var filename=$(OUTPUT) \ -var filename=$(OUTPUT) \

149
README.md
View File

@@ -14,6 +14,7 @@ variable change away (see [Moving to a new Proxmox release](#moving-to-a-new-pro
## Table of contents ## Table of contents
- [What this does](#what-this-does) - [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) - [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) - [How it works](#how-it-works)
- [Requirements](#requirements) - [Requirements](#requirements)
@@ -27,6 +28,8 @@ 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) - [Four traps this image works around](#four-traps-this-image-works-around)
- [Moving to a new Proxmox release](#moving-to-a-new-proxmox-release) - [Moving to a new Proxmox release](#moving-to-a-new-proxmox-release)
- [Build performance](#build-performance) - [Build performance](#build-performance)
- [arm64](#arm64)
- [Release automation](#release-automation)
- [Repository layout](#repository-layout) - [Repository layout](#repository-layout)
- [Troubleshooting](#troubleshooting) - [Troubleshooting](#troubleshooting)
- [Verified status](#verified-status) - [Verified status](#verified-status)
@@ -163,6 +166,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/<tag>/maas-image-pve-<version>-amd64.tar.gz
curl -LO https://gitea.mynodes.xyz/ilker/maas-proxmox/releases/download/<tag>/maas-image-pve-<version>-amd64.tar.gz.sha256
sha256sum -c maas-image-pve-<version>-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-<version>-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 ## Quick start
```bash ```bash
@@ -233,7 +263,7 @@ All of these are `make` variables — `sudo make image DISK_SIZE=24G`, and so on
| Variable | Default | Meaning | | Variable | Default | Meaning |
|---|---|---| |---|---|---|
| `IMAGE_NAME` | `proxmox-ve-9` | MAAS name (`custom/<name>`) and preseed filename | | `IMAGE_NAME` | `proxmox-ve-9` | MAAS name (`custom/<name>`) and preseed filename |
| `ARCH` / `SUBARCH` | `amd64` / `generic` | Target architecture. *Only amd64 was tested.* | | `ARCH` / `SUBARCH` | `amd64` / `generic` | Target architecture. `arm64` is wired up but **never built or deployed** — see [arm64](#arm64). |
| `BOOT` | `uefi` | Boot mode baked into the image. *Only UEFI was tested.* | | `BOOT` | `uefi` | Boot mode baked into the image. *Only UEFI was tested.* |
| `DISK_SIZE` | `16G` | Build VM disk. Upstream's 4G cannot fit Debian + Proxmox | | `DISK_SIZE` | `16G` | Build VM disk. Upstream's 4G cannot fit Debian + Proxmox |
| `BUILD_CPUS` / `BUILD_MEM` | `4` / `4096` | Build VM resources | | `BUILD_CPUS` / `BUILD_MEM` | `4` / `4096` | Build VM resources |
@@ -246,7 +276,7 @@ All of these are `make` variables — `sudo make image DISK_SIZE=24G`, and so on
|---|---|---| |---|---|---|
| `DEBIAN_IMAGE_CHANNEL` | `stable` | `stable` uses a fixed URL so Packer's cache works; `daily` is upstream's default and changes every day. *Only `stable` was tested.* | | `DEBIAN_IMAGE_CHANNEL` | `stable` | `stable` uses a fixed URL so Packer's cache works; `daily` is upstream's default and changes every day. *Only `stable` was tested.* |
| `GZIP_LEVEL` | `6` | Tarball compression. Upstream uses 9 | | `GZIP_LEVEL` | `6` | Tarball compression. Upstream uses 9 |
| `APT_PROXY` | *(empty)* | Local APT cache, e.g. `http://10.0.2.2:3142` — see `make deps-cache`. *Untested.* | | `APT_PROXY` | *(empty)* | Local APT cache, e.g. `http://10.0.2.2:3142` — see `make deps-cache`. *Untested on this image; measured on maas-samba-ad.* |
### MAAS ### MAAS
@@ -707,9 +737,17 @@ All of them are on by default:
- **`GZIP_LEVEL=6`** — upstream uses `--best` (9). With `pigz` this is noticeably faster - **`GZIP_LEVEL=6`** — upstream uses `--best` (9). With `pigz` this is noticeably faster
for a few percent more size. for a few percent more size.
For repeated builds, a local APT cache should remove roughly 700 MB of downloads. - **Forcing IPv4** — the Makefile patches the build VM's cloud-init seed to write
*This path is untested and the figure is an estimate from package sizes, not a `Acquire::ForceIPv4 "true"` before SSH comes up. Without it, every apt download over
measurement:* roughly 15 MB stalled for *exactly* 31 seconds regardless of its size: QEMU's
user-mode network offers IPv6 that does not actually work, so apt's parallel
connections black-holed on it and only fell back to IPv4 once the timeout expired.
Measured on the sibling [maas-samba-ad](https://github.com/ilkermanap/maas-samba-ad)
build, the same 28.5 MB fetch went from 31s (914 kB/s) to 3s (9.2 MB/s). The 31s
stall is visible in this project's build logs too, but the fix has not yet been
measured here.
For repeated builds you can also put a local APT cache in front:
```bash ```bash
sudo make deps-cache # installs apt-cacher-ng sudo make deps-cache # installs apt-cacher-ng
@@ -718,10 +756,94 @@ sudo make image APT_PROXY=http://10.0.2.2:3142
`10.0.2.2` is the build host as seen from Packer's user-mode network. When a proxy is `10.0.2.2` is the build host as seen from Packer's user-mode network. When a proxy is
configured, Debian repositories are rewritten from `https` to `http` so the cache can configured, Debian repositories are rewritten from `https` to `http` so the cache can
serve them; package signatures are still verified. serve them — a cache cannot see inside a `CONNECT` tunnel — and package signatures are
still verified. Note that Debian 13 keeps the real mirror URLs in
`/etc/apt/mirrors/*.list` behind the `mirror+file:` method, so rewriting `sources.list`
alone is not enough.
*Do not expect much from the cache on its own.* On the samba-ad build a fully warm cache
turned a 26.2 MB fetch from 2s into 0s and a 17.9 MB fetch from 1s into 0s — about three
seconds off a 4m40s build. Almost all of the time this project's builds spend on the
network is the ~800 MB pulled from the Proxmox repository, which the cache *would* serve
on a repeat build; that has not been measured.
--- ---
## arm64
Proxmox VE 9.2 added **official** arm64 support — same code base, same
repositories, same release lifecycle as x86-64, with full support on NVIDIA Grace
and Vera platforms and best-effort on other UEFI Armv8-A/Armv9-A hardware. The
`pve-no-subscription` repository carries `proxmox-ve`, `pve-manager`,
`proxmox-default-kernel` and `pve-qemu-kvm` for arm64.
This repository is wired for it:
```bash
sudo WITH_ARM64=1 ./scripts/install-deps.sh # adds qemu-system-arm, AAVMF
sudo make image ARCH=arm64
make preseed ARCH=arm64 # -> ..._arm64_generic_proxmox-ve-9
make upload ARCH=arm64
```
The firmware is selected from the **target** architecture (`AAVMF` for arm64,
`OVMF` for amd64) and padded to 64 MiB as QEMU's arm64 `virt` machine requires;
KVM is used only when the host and target architectures match.
**This has never been run.** Nothing here has been built for arm64, let alone
deployed, and it is listed under [Not verified](#not-verified) for that reason.
Two things stand between the code and a usable image:
- **Build speed.** On an x86_64 builder an arm64 build runs under TCG emulation
with no KVM. Expect it to be several times slower than the ~11 minutes an amd64
build takes; how much slower has not been measured. The I/O optimisations
(`eatmydata`, deferred initramfs) help less when the bottleneck is CPU.
- **Somewhere to deploy it.** An arm64 image needs arm64 machines behind MAAS to
be worth anything, and none were available to test against.
If you want arm64 seriously, put a native arm64 builder behind a second runner
rather than emulating. That removes the speed problem entirely and lets the same
pipeline build both architectures.
## 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-<version>-<date>` 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 ## Repository layout
``` ```
@@ -730,6 +852,8 @@ scripts/install-deps.sh build host dependencies
scripts/customize-proxmox.sh.in template for the script that runs inside the build VM 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/deploy-cluster.sh deploy a whole cluster through MAAS
scripts/verify-image.sh check a built image's contents 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 overlay/ files baked into the image
usr/local/sbin/pve-maas-init first-boot state machine usr/local/sbin/pve-maas-init first-boot state machine
etc/pve-maas/pve-maas.conf defaults, with every option documented etc/pve-maas/pve-maas.conf defaults, with every option documented
@@ -861,12 +985,17 @@ plaintext `PVE_ROOT_PASSWORD`, `PVE_ENABLED=false`, `PVE_FQDN`, and every
**Build variants** **Build variants**
- `pve-enterprise` and `pve-test` repositories - `pve-enterprise` and `pve-test` repositories
- arm64 - **arm64** — the Makefile and scripts handle it (firmware selection, MAAS
architecture, the Debian-kernel check), but no arm64 image has been built and
none deployed. See [arm64](#arm64).
- BIOS boot (`BOOT=bios`) - BIOS boot (`BOOT=bios`)
- `DEBIAN_IMAGE_CHANNEL=daily` - `DEBIAN_IMAGE_CHANNEL=daily`
- `APT_PROXY` and `make deps-cache`. The ~700 MB figure quoted under - `APT_PROXY` and `make deps-cache` *for this image*. The cache was measured end to end
[Build performance](#build-performance) is an estimate from download sizes, not a on the sibling maas-samba-ad build, where it saved about three seconds; this project
measurement. pulls far more from the Proxmox repository, and that case has not been measured.
- The `Acquire::ForceIPv4` seed patch *for this image*. It was measured on maas-samba-ad
(31s to 3s on the same 28.5 MB fetch); the same 31s stall appears in this project's
logs, but no build has been run here since the patch was added.
- A non-default `IMAGE_NAME` - A non-default `IMAGE_NAME`
**Make targets**`make upload` (the image was uploaded with the equivalent `maas` **Make targets**`make upload` (the image was uploaded with the equivalent `maas`

96
scripts/ci/decide-build.sh Executable file
View File

@@ -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:-<none>} proxmox-default-kernel=${PREV_KERNEL:-<none>}" >&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"

View File

@@ -13,6 +13,11 @@ set -euo pipefail
TAG="${1:?tag required}" TAG="${1:?tag required}"
VERSION="${2:?version required}" VERSION="${2:?version required}"
DIST="${3:?dist directory required}" DIST="${3:?dist directory required}"
# DIST cagiranin dizinine goreli olabilir; repo kokune gecmeden once mutlaklastir.
DIST="$(cd "$DIST" && pwd)"
# make degiskenlerini okuyabilmek icin depo koku gerekiyor.
cd "$(dirname "$0")/../.."
: "${GITEA_API:?GITEA_API required}" : "${GITEA_API:?GITEA_API required}"
: "${GITEA_TOKEN:?GITEA_TOKEN required}" : "${GITEA_TOKEN:?GITEA_TOKEN required}"
@@ -20,6 +25,9 @@ REPO_URL="${GITHUB_SERVER_URL:-}/${GITHUB_REPOSITORY:-}"
RUN="${GITHUB_RUN_NUMBER:-manual}" RUN="${GITHUB_RUN_NUMBER:-manual}"
SHA="${GITHUB_SHA:-}" SHA="${GITHUB_SHA:-}"
IMAGE=$(basename "$(ls "$DIST"/maas-image-*.tar.gz)") IMAGE=$(basename "$(ls "$DIST"/maas-image-*.tar.gz)")
MAAS_ARCH=$(make -s print-var VAR=MAAS_ARCH)
PVE_MAJOR=$(make -s print-var VAR=PVE_VERSION)
IMAGE_NAME=$(make -s print-var VAR=IMAGE_NAME)
BODY=$(cat <<BODYEOF BODY=$(cat <<BODYEOF
Unofficial build of a MAAS-deployable Proxmox VE image. Unofficial build of a MAAS-deployable Proxmox VE image.
@@ -32,8 +40,8 @@ Unofficial build of a MAAS-deployable Proxmox VE image.
Upload to MAAS: Upload to MAAS:
\`\`\`bash \`\`\`bash
maas \$PROFILE boot-resources create name='custom/proxmox-ve-9' \\ maas \$PROFILE boot-resources create name='custom/${IMAGE_NAME}' \\
title='Proxmox VE 9' architecture='amd64/generic' \\ title='Proxmox VE ${PVE_MAJOR}' architecture='${MAAS_ARCH}' \\
filetype='tgz' content@=${IMAGE} filetype='tgz' content@=${IMAGE}
\`\`\` \`\`\`
@@ -45,6 +53,15 @@ the GPL/AGPL licences of the packages inside the image.
BODYEOF BODYEOF
) )
# A forced rebuild on the same day, of the same upstream version, produces the
# tag that is already published and the API answers 409. Disambiguate with the
# run number rather than overwriting a release someone may already have used.
if curl -fsS -o /dev/null -H "Authorization: token ${GITEA_TOKEN}" \
"${GITEA_API}/releases/tags/${TAG}" 2>/dev/null; then
TAG="${TAG}-r${RUN}"
echo "tag already exists; publishing as ${TAG} instead"
fi
PAYLOAD=$(TAG="$TAG" VERSION="$VERSION" BODY="$BODY" SHA="$SHA" python3 -c ' PAYLOAD=$(TAG="$TAG" VERSION="$VERSION" BODY="$BODY" SHA="$SHA" python3 -c '
import json, os import json, os
print(json.dumps({ print(json.dumps({

View File

@@ -74,8 +74,11 @@ apt-mark hold cloud-init || true
# Paket imzalari yine dogrulandigi icin guvenlik kaybi yok. # Paket imzalari yine dogrulandigi icin guvenlik kaybi yok.
if [ -n "${http_proxy:-}" ]; then if [ -n "${http_proxy:-}" ]; then
log "APT onbellegi kullaniliyor (${http_proxy}); Debian depolari http'ye cevriliyor" log "APT onbellegi kullaniliyor (${http_proxy}); Debian depolari http'ye cevriliyor"
sed -i 's|https://deb.debian.org|http://deb.debian.org|g; s|https://security.debian.org|http://security.debian.org|g' \ # Debian 13 keeps the actual mirror URLs in /etc/apt/mirrors/*.list, reached
/etc/apt/sources.list /etc/apt/sources.list.d/*.sources /etc/apt/sources.list.d/*.list 2>/dev/null || true # through the "mirror+file:" method, so rewriting sources.list alone misses them.
sed -i 's|https://|http://|g' \
/etc/apt/sources.list /etc/apt/sources.list.d/*.sources \
/etc/apt/sources.list.d/*.list /etc/apt/mirrors/*.list 2>/dev/null || true
fi fi
log "eatmydata kuruluyor" log "eatmydata kuruluyor"

View File

@@ -27,6 +27,14 @@ apt-get install -y --no-install-recommends \
qemu-system-x86 qemu-utils ovmf cloud-image-utils \ qemu-system-x86 qemu-utils ovmf cloud-image-utils \
libnbd-bin nbdkit fuse2fs cpu-checker libnbd-bin nbdkit fuse2fs cpu-checker
# arm64 hedefi icin ek paketler. Varsayilan olarak kurulmaz: x86_64 host'ta
# arm64 derlemek TCG emulasyonu demektir (KVM yok) ve cok yavastir. Bu yol
# hic denenmedi - README'deki "Verified status" bolumune bakin.
if [ "${WITH_ARM64:-0}" = "1" ]; then
echo "==> arm64 hedefi icin ek paketler (WITH_ARM64=1)"
apt-get install -y --no-install-recommends qemu-system-arm qemu-efi-aarch64
fi
echo "==> HashiCorp APT deposu ekleniyor (packer)" echo "==> HashiCorp APT deposu ekleniyor (packer)"
install -d -m 0755 /etc/apt/keyrings install -d -m 0755 /etc/apt/keyrings
curl -fsSL https://apt.releases.hashicorp.com/gpg \ curl -fsSL https://apt.releases.hashicorp.com/gpg \

View File

@@ -55,7 +55,11 @@ check "iSCSI initiator adi yok (dugumde uretilir)" '! present "etc/iscsi/initia
check "SSH host anahtarlari yok" '! grep -qE "^\./etc/ssh/ssh_host_.*_key$" "$TMP/list"' check "SSH host anahtarlari yok" '! grep -qE "^\./etc/ssh/ssh_host_.*_key$" "$TMP/list"'
check "networking.service etkin DEGIL" '! present "etc/systemd/system/multi-user.target.wants/networking.service"' check "networking.service etkin DEGIL" '! present "etc/systemd/system/multi-user.target.wants/networking.service"'
check "interfaces.new yok (pvenetcommit ezmesin)" '! present "etc/network/interfaces.new"' check "interfaces.new yok (pvenetcommit ezmesin)" '! present "etc/network/interfaces.new"'
check "Debian cekirdegi yok" '! grep -qE "^\./boot/vmlinuz-.*[^e]-(cloud-)?amd64$" "$TMP/list"' # Mimariden bagimsiz: /boot altinda -pve ile bitmeyen hicbir cekirdek olmamali.
# Debian'inki amd64'te *-amd64, arm64'te *-arm64 diye adlandirilir; Proxmox'unki
# her zaman -pve ile biter. Hic cekirdek yoksa da gecer - PVE cekirdeginin
# varligini asagidaki ayri kontrol dogruluyor.
check "Debian cekirdegi yok" '! grep -E "^\./boot/vmlinuz-" "$TMP/list" | grep -qv -- "-pve$"'
echo echo
echo "==> Proxmox cekirdegi" echo "==> Proxmox cekirdegi"