Files
maas-proxmox/README.md
ilkermanap 77bf86a1b8 Separate what was actually verified from what was only written
The previous "Verified status" section listed the test environment and a
one-line result, which let readers assume far more coverage than the testing
justified. Options that had never run once were presented alongside ones that
were exercised repeatedly.

Every configuration option in the reference tables that was not exercised is now
marked "Untested" inline — 25 of them, including every PVE_NET_* value other than
the tested defaults, PVE_CLUSTER_LINK0/LINK1, fingerprint discovery, the join
retry path, and every PVE_THINPOOL_* value beyond auto with the defaults.

"Verified status" is now two lists. The first gives, per area, the observation
that supports the claim rather than the claim alone: which command was run, what
it printed, which file was found where. The second is everything that was never
exercised, with no implication that it is broken.

Three claims elsewhere were overstated and are corrected:

  * the ~700 MB saving from a local APT cache is an estimate from package sizes;
    that path has never been run
  * MAAS 3.2+ is the documented minimum, but only 3.7.2 with snap packaging was
    tested; the deb packaging's preseed path is untested
  * the build timings are measured, but the contribution of each individual
    optimisation is not

Both cases in "Moving to a new Proxmox release" are also marked: the minor-version
path is verified only as a mechanism (no package versions are pinned), never as an
actual version step, and the claim about upstream breaking on an unknown Debian
release is read from upstream's source rather than observed.

Verified while writing this: the iSCSI initiator names really do differ between
the two nodes, PVE_CLUSTER_WIPE_SECRETS really does scrub both password fields
after a join, and "make preseed" and "make install-preseed" work as targets on the
MAAS region controller rather than only as the commands they wrap.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-04 22:03:19 +02:00

929 lines
39 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# maas-proxmox
Build **Proxmox VE** images that [MAAS](https://maas.io) can deploy to bare metal, with
first-boot automation that configures the node and joins it to a Proxmox cluster —
without anyone logging in.
Target: **Proxmox VE 9.x** on Debian 13 "Trixie". Older and newer releases are a
variable change away (see [Moving to a new Proxmox release](#moving-to-a-new-proxmox-release)).
*Türkçe dokümantasyon: [README.tr.md](README.tr.md)*
---
## Table of contents
- [What this does](#what-this-does)
- [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)
- [Quick start](#quick-start)
- [Build configuration](#build-configuration)
- [First-boot behaviour](#first-boot-behaviour)
- [Configuration reference](#configuration-reference)
- [Deploying a cluster](#deploying-a-cluster)
- [Networking](#networking)
- [Storage](#storage)
- [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)
- [Repository layout](#repository-layout)
- [Troubleshooting](#troubleshooting)
- [Verified status](#verified-status)
- [Licensing](#licensing)
- [References](#references)
---
## What this does
MAAS provisions bare-metal machines from images. It ships images for Ubuntu and a few
other distributions, but not for Proxmox VE. This repository produces one.
`make image` builds a tarball that you upload to MAAS as a custom image. After that,
deploying a machine in MAAS gives you a fully configured Proxmox VE node:
| Step | Who does it |
|---|---|
| Partition the disk, write the image, configure networking, inject SSH keys | MAAS / curtin |
| Set hostname, regenerate node-unique identifiers | cloud-init + this image |
| Convert the management interface into a `vmbr0` bridge | this image |
| Set the `root@pam` password | this image |
| Create an LVM-thin pool and register it as `local-lvm` | this image |
| Create a Proxmox cluster, or join an existing one | this image |
Everything after the MAAS handoff is driven by a single systemd service,
`pve-maas-init`, configured through cloud-init user-data supplied at deploy time.
The image itself carries no cluster identity, no hostname and no credentials, so the
same image can build any number of independent clusters.
---
## Why Debian + the Proxmox repository, and not the Proxmox ISO
There are two plausible ways to get Proxmox VE into MAAS, and the choice shapes
everything else.
**Option A — install the Proxmox ISO under automation, capture the raw disk (`.dd.gz`).**
You get Proxmox's native layout: ZFS-on-root if you want it, `local-lvm` preconfigured.
But MAAS writes a `dd` image to the disk verbatim. Its storage layouts, its partitioning
UI and much of the curtin flow stop applying. Proxmox does not ship cloud-init on the
host, so MAAS's metadata — hostname, network configuration, SSH keys, user-data —
has to be bolted on afterwards. Growing the root filesystem becomes your problem.
**Option B — start from the Debian cloud image and install `proxmox-ve` on top of it.**
This is a
[configuration Proxmox supports and documents](https://pve.proxmox.com/wiki/Install_Proxmox_VE_on_Debian_13_Trixie).
The result is an ordinary Debian root filesystem, so MAAS's normal `tgz` custom-image
path applies unchanged: MAAS partitions the disk according to whatever storage layout
you configured, curtin installs the bootloader, and cloud-init consumes MAAS metadata
the way it does for Ubuntu.
**This repository takes option B.** The cost is that `local-lvm` is not preconfigured —
the image creates the thin pool on first boot from whatever free space MAAS left in the
volume group — and ZFS-on-root is not available. In exchange, MAAS stays in control of
the parts MAAS is good at, and moving between Proxmox releases is a variable change
rather than a rewrite.
---
## How it works
### Build pipeline
```
Debian 13 cloud image (qcow2, official)
├─ canonical/packer-maas, "debian" template (QEMU + KVM)
│ ├─ cloud-init / netplan / curtin compatibility [upstream]
│ ├─ disk, cpu and memory patch (4G -> 16G) [this repo]
│ └─ customize-proxmox.sh [this repo]
│ ├─ add the Proxmox APT repository and keyring
│ ├─ install proxmox-default-kernel and proxmox-ve
│ ├─ remove the Debian kernel and os-prober
│ ├─ wipe the pmxcfs node identity
│ ├─ disable networking.service, clear interfaces{,.new}
│ └─ install the overlay:
│ /usr/local/sbin/pve-maas-init
│ /etc/pve-maas/pve-maas.conf
│ /etc/systemd/system/pve-maas-init.service
│ /curtin/curtin-hooks
└─ proxmox-ve-9.tar.gz ──► maas boot-resources create
```
The upstream [`canonical/packer-maas`](https://github.com/canonical/packer-maas)
repository is cloned at build time and pinned to a tested commit. It is *not* vendored;
this repository applies a small, explicit patch to its Packer template and supplies the
customization script through the template's `customize_script` variable.
### Deployment flow
```
MAAS: Deploy (osystem=custom, distro_series=proxmox-ve-9, user_data=…)
├─ PXE boot into the ephemeral environment
├─ curtin: partition, extract the image
├─ curtin: /curtin/curtin-hooks ── skip kernel install
│ └─ pin interface names by MAC
├─ reboot into Proxmox VE
└─ first boot
├─ cloud-init: hostname, network (netplan), SSH keys,
│ write /etc/pve-maas/conf.d/*.conf from user-data
└─ pve-maas-init.service
hosts → identity → rootpw → network → cluster → storage
```
`pve-maas-init` is a state machine. Each stage records completion in
`/var/lib/pve-maas/<stage>.done`, so a stage that reboots the node (the network stage
does, by default) resumes at the next stage on the following boot, and a stage that
fails is retried on the next boot rather than leaving the node half-configured.
---
## Requirements
**Build host** — where `make image` runs:
- Ubuntu 22.04 or newer (24.04 LTS is what this was developed and tested on), x86_64
- **Access to `/dev/kvm`.** If the build host is itself a virtual machine, nested
virtualization must be enabled and the CPU type must pass the virtualization flags
through (on Proxmox: `--cpu host`). Without KVM the build falls back to full
emulation and is not practical.
- 4+ vCPU, 8+ GB RAM, 25+ GB free disk
- `sudo` (the build runs as root — it uses `qemu-nbd`, FUSE mounts and `tar --xattrs`)
**Deployment side:**
- MAAS 3.2 or newer is the documented minimum for custom images. **Only 3.7.2 was
tested**, with the snap packaging; the deb packaging's preseed path is untested.
- curtin 21.0 or newer
- The curtin preseed from this repository installed on the MAAS region controller
---
## Quick start
```bash
# 1. Install build dependencies (packer, qemu, ovmf, nbdkit, fuse2fs, …)
sudo ./scripts/install-deps.sh
# 2. Build the image (~11 minutes)
sudo make image
# -> build/proxmox-ve-9.tar.gz
# 3. Check the result
make verify
# 4. Generate the MAAS curtin preseed
make preseed
# -> build/curtin_userdata_custom_amd64_generic_proxmox-ve-9
# 5. Install the preseed on the MAAS region controller
sudo make install-preseed
# default: /var/snap/maas/current/preseeds/
# for a deb-packaged MAAS: MAAS_PRESEED_DIR=/etc/maas/preseeds
# 6. Upload the image to MAAS
make upload MAAS_PROFILE=admin
```
Steps 5 and 6 run against the MAAS region controller, so either run them there or copy
the two artifacts across.
**The preseed is not optional.** Without it, curtin tries to install a kernel over APT
during deployment and the deployment fails. See
[Four traps this image works around](#four-traps-this-image-works-around).
Then deploy a machine:
```bash
maas $PROFILE machine deploy $SYSTEM_ID \
osystem=custom distro_series=proxmox-ve-9 \
user_data="$(base64 -w0 maas/examples/01-first-node.yaml)"
```
…or pick **Custom → Proxmox VE 9** in the MAAS web UI. For a whole cluster, use
[`scripts/deploy-cluster.sh`](scripts/deploy-cluster.sh).
---
## Build configuration
All of these are `make` variables — `sudo make image DISK_SIZE=24G`, and so on.
### Version selection
| Variable | Default | Meaning |
|---|---|---|
| `PVE_VERSION` | `9` | Proxmox VE major version; used in the image name |
| `DEBIAN_SERIES` | `trixie` | Debian codename that Proxmox release is built on |
| `DEBIAN_VERSION` | `13` | Debian major version number |
| `PVE_REPO` | `pve-no-subscription` | `pve-no-subscription`, `pve-enterprise` or `pve-test`. *Only `pve-no-subscription` was tested.* |
| `PVE_REPO_URI` | `http://download.proxmox.com/debian/pve` | APT repository URI |
| `PVE_KEYRING_URL` | derived from `DEBIAN_SERIES` | Proxmox archive keyring |
| `PVE_EXTRA_PACKAGES` | `ifupdown2 open-iscsi chrony …` | Extra packages to bake in |
> `vlan` and `vzdump` **conflict** with `pve-manager` — do not add them.
> ifupdown2 provides VLAN support natively.
### Image and build
| Variable | Default | Meaning |
|---|---|---|
| `IMAGE_NAME` | `proxmox-ve-9` | MAAS name (`custom/<name>`) and preseed filename |
| `ARCH` / `SUBARCH` | `amd64` / `generic` | Target architecture. *Only amd64 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 |
| `BUILD_CPUS` / `BUILD_MEM` | `4` / `4096` | Build VM resources |
| `TIMEOUT` | `3h` | Packer build timeout |
| `PM_REF` | pinned SHA | `canonical/packer-maas` revision |
### Speed
| Variable | Default | Meaning |
|---|---|---|
| `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 |
| `APT_PROXY` | *(empty)* | Local APT cache, e.g. `http://10.0.2.2:3142` — see `make deps-cache`. *Untested.* |
### MAAS
| Variable | Default | Meaning |
|---|---|---|
| `MAAS_PROFILE` | `admin` | `maas` CLI profile name |
| `MAAS_PRESEED_DIR` | `/var/snap/maas/current/preseeds` | Preseed directory |
### Targets
```
make deps install build dependencies (root)
make deps-cache install a local APT cache to speed up rebuilds (root)
make image build the image (root)
make verify check the built image's contents
make check-upstream compare repository versions against the built image
make preseed generate the MAAS curtin preseed
make install-preseed install the preseed on this host (root)
make upload upload the image to MAAS
make lint syntax-check the scripts
make clean remove intermediates
make distclean remove build/ entirely
```
---
## First-boot behaviour
`pve-maas-init.service` is enabled in the image and runs the following stages in order,
each exactly once:
| Stage | What it does |
|---|---|
| `hosts` | Writes `<management-ip> <fqdn> <hostname>` into `/etc/hosts``pvecm` requires the node name to resolve to a real address. Disables cloud-init's `manage_etc_hosts` so it is not reverted on later boots, fixes postfix's `myhostname`, and restarts the Proxmox services so they pick up the correct identity. |
| `identity` | Regenerates node-unique identifiers that must not be shared between nodes cloned from one image — currently the iSCSI initiator name. |
| `rootpw` | Sets the `root@pam` password. Without it the Proxmox web UI cannot be used and the node cannot be a join target for other nodes. |
| `network` | Converts the interface MAAS configured into a `vmbr0` bridge, disables the netplan/systemd-networkd configuration, and enables ifupdown2. |
| `cluster` | Creates a cluster or joins an existing one. |
| `storage` | Creates an LVM-thin pool in the free space of the volume group and registers it as `local-lvm`. |
Progress and failures:
```bash
systemctl status pve-maas-init
journalctl -u pve-maas-init -b
ls -l /var/lib/pve-maas/ # <stage>.done files; "complete" when finished
cat /etc/pve-maas/image-info # which image this node was built from
```
If a stage fails, the service exits non-zero and the remaining stages are retried on the
next boot. To force a stage to run again, delete its `.done` file along with `complete`
and restart the service.
---
## Configuration reference
Defaults live in `/etc/pve-maas/pve-maas.conf` (do not edit it). Per-node settings go
into `/etc/pve-maas/conf.d/*.conf`, which cloud-init writes from the user-data you pass
at deploy time. Files in `conf.d` override the defaults.
```yaml
#cloud-config
write_files:
- path: /etc/pve-maas/conf.d/50-pve.conf
permissions: "0600"
owner: root:root
content: |
PVE_ROOT_PASSWORD_HASH='$6$...'
PVE_CLUSTER_MODE=join
PVE_CLUSTER_PEER=192.0.2.11
PVE_CLUSTER_PEER_PASSWORD='...'
```
### General
| Option | Default | Meaning |
|---|---|---|
| `PVE_ENABLED` | `true` | Set to `false` to disable all first-boot automation. *Untested.* |
| `PVE_FQDN` | *(empty)* | Override the detected FQDN. *Untested — detection was used.* |
### Credentials
| Option | Default | Meaning |
|---|---|---|
| `PVE_ROOT_PASSWORD_HASH` | *(empty)* | `root@pam` password hash — generate with `openssl passwd -6` |
| `PVE_ROOT_PASSWORD` | *(empty)* | Plaintext alternative; prefer the hash. *Untested — only the hash was exercised.* |
### Networking
| Option | Default | Meaning |
|---|---|---|
| `PVE_NET_MANAGE` | `true` | Set to `false` to configure `/etc/network/interfaces` yourself. *`false` untested.* |
| `PVE_NET_BRIDGE` | `vmbr0` | Bridge name. *Only the default was tested.* |
| `PVE_NET_UPLINK` | *(auto)* | Bridge port; defaults to the interface holding the default route. *Only auto-detection was tested.* |
| `PVE_NET_MODE` | `auto` | `auto`, `static` or `dhcp`. *Only `auto` resolving to static was tested; the DHCP path is untested.* |
| `PVE_NET_APPLY` | `reboot` | `reboot`, `reload` (`ifreload -a`) or `none`. *Only `reboot` was tested.* |
| `PVE_NET_VLAN_AWARE` | `false` | Make the bridge VLAN-aware (`bridge-vids 2-4094`). *Untested.* |
| `PVE_NET_EXTRA` | *(empty)* | Raw text appended to `/etc/network/interfaces`. *Untested.* |
### Cluster
| Option | Default | Meaning |
|---|---|---|
| `PVE_CLUSTER_MODE` | `none` | `none`, `create` or `join` |
| `PVE_CLUSTER_NAME` | *(empty)* | Cluster name, for `create` |
| `PVE_CLUSTER_PEER` | *(empty)* | Address of an existing member, for `join` |
| `PVE_CLUSTER_PEER_PASSWORD` | *(empty)* | That node's `root@pam` password |
| `PVE_CLUSTER_PEER_PASSWORD_FILE` | *(empty)* | Read the password from a file instead. *Untested.* |
| `PVE_CLUSTER_FINGERPRINT` | *(empty)* | The peer's certificate SHA-256 fingerprint |
| `PVE_CLUSTER_FINGERPRINT_DISCOVER` | `true` | Read the fingerprint from the peer if not supplied (trust on first use). *Untested — the fingerprint was always supplied.* |
| `PVE_CLUSTER_LINK0` / `LINK1` | *(empty)* | This node's corosync link addresses. *Untested.* |
| `PVE_CLUSTER_NODEID` / `VOTES` | *(empty)* | Passed through to Proxmox. *Untested.* |
| `PVE_CLUSTER_WAIT` | `900` | Seconds to wait for the peer's API to answer |
| `PVE_CLUSTER_RETRIES` | `5` | Join attempts, 30 s apart. *The retry path is untested — the first attempt succeeded.* |
| `PVE_CLUSTER_WIPE_SECRETS` | `true` | Scrub passwords from `conf.d` after joining |
### Storage
| Option | Default | Meaning |
|---|---|---|
| `PVE_THINPOOL` | `auto` | `auto` (the VG with the most free space), `off`, or a VG name. *Only `auto` was tested.* |
| `PVE_THINPOOL_NAME` | `data` | Thin pool logical volume name. *Only the default was tested.* |
| `PVE_THINPOOL_STORAGE` | `local-lvm` | Proxmox storage id. *Only the default was tested.* |
| `PVE_THINPOOL_MIN_GB` | `16` | Skip the stage below this much free space |
| `PVE_THINPOOL_DISK` | *(empty)* | Build a new VG from this whole disk instead. *Untested.* |
| `PVE_THINPOOL_VG` | `pve` | VG name used with `PVE_THINPOOL_DISK`. *Untested.* |
Worked examples: [`maas/examples/`](maas/examples/).
---
## Deploying a cluster
`pvecm add` prompts for a password interactively, which makes it unusable from a script.
This image uses the API endpoint that does the same job non-interactively:
```bash
pvesh create /cluster/config/join --hostname <peer> --password <pw> --fingerprint <fp>
```
### By hand
Deploy the first node with `PVE_CLUSTER_MODE=create`
([example](maas/examples/01-first-node.yaml)), then read its certificate fingerprint:
```bash
openssl s_client -connect <first-node>:8006 </dev/null 2>/dev/null \
| openssl x509 -noout -fingerprint -sha256 | cut -d= -f2
```
and deploy the remaining nodes with `PVE_CLUSTER_MODE=join` plus that fingerprint
([example](maas/examples/02-join-node.yaml)).
A joining node waits for the peer's port 8006 to answer (`PVE_CLUSTER_WAIT`, 15 minutes
by default) and retries a failed join five times, so you can start several deployments at
once without ordering them carefully.
### With the helper script
[`scripts/deploy-cluster.sh`](scripts/deploy-cluster.sh) automates the whole flow: it
deploys the first node, waits for it, reads the fingerprint, and deploys the rest.
Run it **on the MAAS region controller** — it needs the `maas` CLI profile and network
access to the nodes' port 8006.
```bash
# Show what it would deploy, without deploying anything
./deploy-cluster.sh --name pve-prod --nodes pve1,pve2,pve3 --dry-run
# Deploy
./deploy-cluster.sh --name pve-prod --nodes pve1,pve2,pve3
```
The first host in `--nodes` creates the cluster; the rest join it.
### Several independent clusters
The image carries no cluster identity — it comes entirely from deploy-time user-data.
The same image and the same preseed can build any number of unrelated clusters, with no
rebuild:
```bash
./deploy-cluster.sh --name pve-prod --nodes pve1,pve2,pve3
./deploy-cluster.sh --name pve-dr --nodes dr1,dr2,dr3
```
Each cluster gets its own root password (generated and printed if you do not supply one).
MAAS tags are a convenient way to keep the groups apart:
```bash
maas $PROFILE tag create name=pve-prod
maas $PROFILE tag update-nodes pve-prod add=$SYSTEM_ID
maas $PROFILE machines read tags=pve-prod | jq -r '.[].hostname'
```
### Security notes
- The peer's `root@pam` password **must be plaintext** — the join API does not accept a
hash. It is stored in MAAS user-data, where anyone with MAAS access can read it. Use a
short-lived password and change it after the cluster is up.
- Without `PVE_CLUSTER_FINGERPRINT` the fingerprint is read from the peer on first
contact, which is trust-on-first-use and open to interception. Supply it explicitly in
production; `deploy-cluster.sh` always does.
- Prefer `PVE_ROOT_PASSWORD_HASH` over `PVE_ROOT_PASSWORD` for the node's own password.
- With no `PVE_ROOT_PASSWORD*` at all, root stays locked: no `root@pam` web login, and
the node cannot be a join target.
- A two-node cluster loses quorum when either node goes down. Use three nodes or a
QDevice in production — that is a Proxmox property, not a limitation of this image.
---
## Networking
### Who owns the network, and when
The image ships with `networking.service` (ifupdown2) **disabled** and an
`/etc/network/interfaces` containing nothing but loopback. On the first boot MAAS owns
the network entirely, through the netplan configuration curtin wrote and
systemd-networkd. Only once `pve-maas-init` reaches its network stage does it write the
`vmbr0` configuration, disable netplan and networkd, and enable ifupdown2.
That order matters. If ifupdown2 were enabled in the image it would start with the stale
interface definition baked in at build time and take the real interface down before the
automation ever ran.
### The bridge
The network stage reads the live configuration — the interface holding the default
route, its address, gateway and DNS — and writes a Proxmox-style bridge:
```
auto lo
iface lo inet loopback
iface enp6s18 inet manual
auto vmbr0
iface vmbr0 inet static
address 192.0.2.20/24
gateway 192.0.2.1
bridge-ports enp6s18
bridge-stp off
bridge-fd 0
source /etc/network/interfaces.d/*
```
`PVE_NET_APPLY=reboot` (the default) reboots the node once to apply this, after
cloud-init has already told MAAS the deployment succeeded. `reload` uses ifupdown2's
`ifreload -a` and avoids the reboot at the cost of a brief interruption.
### Complex topologies
For bonds, VLANs or multiple bridges, set `PVE_NET_MANAGE=false` and write
`/etc/network/interfaces` yourself through cloud-init — see
[`maas/examples/04-advanced-network.yaml`](maas/examples/04-advanced-network.yaml).
---
## Storage
`local` (directory storage on `/var/lib/vz`) works out of the box. `local-lvm` is
created on first boot from free space in the volume group, which means **MAAS has to
leave some**.
Choose the LVM storage layout in MAAS and make the root logical volume smaller than the
disk:
```bash
maas $PROFILE machine set-storage-layout $SYSTEM_ID \
storage_layout=lvm lv_size=12884901888 # 12 GiB root
```
The rest of the volume group is then turned into a thin pool named `data` and registered
as `local-lvm`. With less than `PVE_THINPOOL_MIN_GB` free the stage is skipped silently
and the node runs with directory storage only.
To use a whole separate disk instead:
```
PVE_THINPOOL_DISK=/dev/sdb
PVE_THINPOOL_VG=pve
```
`storage.cfg` is cluster-wide, so a node joining an existing cluster does not overwrite a
`local-lvm` that is already defined — it adds itself to that storage's node list instead.
---
## Four traps this image works around
Each of these fails **silently**: no error, no failed unit, just a node that does not
work. They are documented here because anyone building a similar image will hit them.
### 1. `kernel: null` in the curtin preseed
The image already contains the Proxmox kernel, so curtin should not install one. Recent
curtin supports `kernel: null` for exactly this. The curtin that ships with MAAS may not:
```
install_kernel -> config.merge_config(mapping, kernel_cfg.get('mapping', {}))
AttributeError: 'NoneType' object has no attribute 'get'
```
Instead, the image ships `/curtin/curtin-hooks`. When that file exists, curtin runs it in
place of its built-in hooks; the hook neutralises the kernel-installation step and calls
the built-in hooks itself. This works regardless of curtin version and removes the
deployment's dependency on reaching `download.proxmox.com`.
### 2. `pvenetcommit.service`
Proxmox stages network changes in `/etc/network/interfaces.new`, and `pvenetcommit`
**moves that file over `interfaces` on every boot**, before `sysinit.target`:
```
ExecStart=sh -c 'if [ -f ${FN}.new ]; then mv ${FN}.new ${FN}; fi'
```
A `.new` file created during the build and left in the image silently overwrites the
node's configuration on its first boot. It is removed at build time, and again after the
network stage writes the real configuration.
### 3. Interface names differ between commissioning and deployment
MAAS commissions machines in an Ubuntu ephemeral environment and records the interface
name it sees there — for example `enp6s18`. The deployed Debian 13 system may use a
different udev naming scheme and call the same card `ens18`. cloud-init then tries to
rename it, fails, and **leaves the interface down**:
```
Failed to rename devices: [busy] Error renaming mac=… from ens18 to enp6s18
```
The node loses its network moments after cloud-init fetches its metadata — late enough
that MAAS still reports a successful deployment.
`curtin-hooks` writes the MAC-to-name mapping from MAAS's own network configuration into
`/etc/systemd/network/10-maas-<name>.link`, so udev names the card correctly from the
start and no rename is attempted.
### 4. systemd ordering — both a cycle and a deadlock
Ordering `pve-maas-init.service` `After=cloud-final.service` while it is
`WantedBy=multi-user.target` creates a dependency cycle. systemd resolves it by
**deleting our job**:
```
multi-user.target: Found ordering cycle on pve-maas-init.service/start
multi-user.target: Found dependency on cloud-final.service/start
multi-user.target: Found dependency on multi-user.target/start
Job pve-maas-init.service/start deleted to break ordering cycle
```
The service never runs, and nothing reports an error.
Waiting for cloud-init inside the script instead removes the cycle but introduces a
runtime deadlock if the unit is `Type=oneshot`, because such a unit blocks
`multi-user.target`, which `cloud-final.service` is ordered after:
```
cloud-final.service waiting
pve-maas-init.service running
multi-user.target waiting
```
The unit is therefore `Type=simple`, with no cloud-init ordering at all, and the script
calls `cloud-init status --wait` itself. Stage tracking uses `/var/lib/pve-maas/*.done`
rather than systemd state, so nothing is lost by dropping `RemainAfterExit`.
---
## Moving to a new Proxmox release
Check what is available without building anything:
```bash
make check-upstream
```
It prints the current `proxmox-ve` / `pve-manager` versions in the configured repository
next to the metadata of the image you have (`/etc/pve-maas/image-info`).
### Within the same Debian base (9.2 → 9.3 → …)
Nothing to change. No package versions are pinned; every build takes the current
`proxmox-ve`.
*Partly verified:* the mechanism is — five builds were made and each installed
whatever the repository offered at the time. An actual minor-version step (a rebuild
that picks up a newer Proxmox than the previous image) has not happened yet.*
```bash
sudo make image && make verify && make upload
```
The image name and preseed filename stay the same, so the MAAS record is updated in
place.
### A major release that changes the Debian base (e.g. PVE 10 on Debian 14)
**This is not automatic**, and changing the variables in this repository is not enough.
The upstream packer-maas template contains hard-coded conditions on the Debian version:
```
debian/scripts/networking.sh: if [ ${DEBIAN_VERSION} == '12' ] || [ ${DEBIAN_VERSION} == '13' ]
debian/scripts/setup-boot.sh: if [ ${DEBIAN_VERSION} == '13' ]
```
An unknown version falls through to the `else` branch, which installs a cloud-init
package from 2020 — the image breaks silently.
*This is read from the upstream source, not observed: no build against an unsupported
Debian version was attempted.* A major jump therefore waits on upstream support.
In order:
1. Does `canonical/packer-maas` handle the new Debian? (look at the `DEBIAN_VERSION`
conditions in `debian/scripts/`)
2. Has Proxmox published the keyring?
`https://enterprise.proxmox.com/debian/proxmox-archive-keyring-<suite>.gpg`
3. Are the packages there?
`curl -s http://download.proxmox.com/debian/pve/dists/<suite>/Release | grep Components`
4. Then:
```bash
sudo make image PVE_VERSION=10 DEBIAN_SERIES=forky DEBIAN_VERSION=14 PM_REF=main
make preseed PVE_VERSION=10
```
The preseed filename changes too (`…_proxmox-ve-10`) — install it on the region
controller.
### Upstream pinning
`PM_REF` points at a tested `canonical/packer-maas` commit. Leaving it at `main` means an
upstream change can break your build without warning. To move it forward: build with
`PM_REF=main`, **deploy the result and verify it**, then record the new SHA in the
Makefile.
---
## Build performance
A build takes about 11 minutes on a 4-vCPU builder, most of it installing packages
inside the build VM. Measured on the host described under
[Verified status](#verified-status): 15 min 55 s before these optimisations,
10 min 36 s after. The individual contributions were not measured separately.
All of them are on by default:
- **`eatmydata`** — drops dpkg's per-package `fsync` calls. Safe here: the build VM's
disk is thrown away.
- **Deferred initramfs** — `update-initramfs` is diverted during installation and run
exactly once at the end, instead of being triggered by the kernel, firmware and dkms
packages in turn.
- **Stable cloud image** — a fixed URL means Packer's cache actually hits; upstream's
`daily` image changes every day, costing a ~350 MB download per build and making
builds non-reproducible.
- **`GZIP_LEVEL=6`** — upstream uses `--best` (9). With `pigz` this is noticeably faster
for a few percent more size.
For repeated builds, a local APT cache should remove roughly 700 MB of downloads.
*This path is untested and the figure is an estimate from package sizes, not a
measurement:*
```bash
sudo make deps-cache # installs apt-cacher-ng
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
configured, Debian repositories are rewritten from `https` to `http` so the cache can
serve them; package signatures are still verified.
---
## Repository layout
```
Makefile build / preseed / upload targets
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
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
etc/systemd/system/… pve-maas-init.service
curtin/curtin-hooks skips kernel install, pins interface names
maas/curtin_userdata_custom.in MAAS curtin preseed template
maas/examples/*.yaml cloud-init user-data examples
build/ generated artifacts (git-ignored)
```
The overlay is embedded into the generated customization script as a base64 payload
rather than served over Packer's HTTP server, which keeps the build self-contained.
---
## Troubleshooting
**Deployment ends in "Failed deployment".**
Read the installation log:
```bash
maas $PROFILE node-script-result download $SYSTEM_ID current-installation \
filetype=txt filters=/tmp/install.log output=all | tail -60
```
The most common cause is a missing or misnamed preseed. The filename must match the
uploaded image exactly: `curtin_userdata_custom_<arch>_<subarch>_<image-name>`. Use what
`make preseed` produces.
**MAAS says "Deployed" but the node is unreachable.**
Look at the console (`qm terminal <vmid>` on Proxmox, or your BMC). A login prompt means
the system booted and the problem is networking. Inspect the disk from rescue mode:
```bash
maas $PROFILE machine rescue-mode $SYSTEM_ID
# then, over SSH to the ephemeral environment:
mount /dev/vgroot/lvroot /mnt/t
cat /mnt/t/etc/network/interfaces # was vmbr0 written?
ls -l /mnt/t/var/lib/pve-maas/ # which stages completed?
cat /mnt/t/var/log/cloud-init-output.log # did user-data arrive?
journalctl -D /mnt/t/var/log/journal -u pve-maas-init
```
An empty `/var/lib/pve-maas/` means the service never ran — check for the systemd
ordering cycle described above.
**Deployed but the web UI rejects the login.**
Was a root password supplied? `journalctl -u pve-maas-init -b | grep rootpw`. Check the
bridge with `ip -br addr`.
**A node did not join the cluster.**
`journalctl -u pve-maas-init -b`. Common causes: the peer is unreachable on 8006, the
root password is wrong, the fingerprint does not match, or the peer's own `/etc/hosts`
is wrong so `pvecm` fails there. To retry without redeploying:
```bash
rm -f /var/lib/pve-maas/cluster.done /var/lib/pve-maas/complete
systemctl start pve-maas-init
```
**The node reports the build hostname.**
It should not — the pmxcfs database is removed from the image and regenerated from the
current hostname on first boot. If it happens, `/var/lib/pve-cluster/config.db` survived
the build; check the "resetting node identity" step in the build log.
**The build reports that `proxmox-ve` cannot be found.**
`DEBIAN_SERIES` and `PVE_REPO` disagree. Verify:
`curl -s http://download.proxmox.com/debian/pve/dists/<suite>/Release | grep Components`
**Do not delete or replace a boot resource while a machine is using it.**
MAAS loses the machine's boot files and it can end up stuck in "Failed to exit rescue
mode", with GRUB reporting `invalid magic number`.
---
## Verified status
Be sceptical of anything not listed under **Verified**. Individual options are also
marked *Untested* in the tables above where that applies.
### Test environment
| | |
|---|---|
| Build host | Ubuntu 24.04.1, x86_64, 8 vCPU / 31 GB, nested KVM |
| MAAS | 3.7.2 (snap), region + rack on Ubuntu 24.04 |
| Nodes | 2 × (2 vCPU, 6 GB RAM, 32 GiB disk), UEFI, Secure Boot off, virtio |
| Network | Single flat isolated subnet; MAAS acting as gateway and DHCP |
| Image | proxmox-ve 9.2.0 / pve-manager 9.2.11 / kernel 7.0.14-15-pve |
| Storage layout | MAAS `lvm`, 12 GiB root LV, ~19 GiB left free in the VG |
### Verified
Each of these was observed working on the running system, not merely assumed:
| Area | Evidence |
|---|---|
| Build | Completes on a KVM-capable Ubuntu 24.04 host; 10 min 36 s with the speed options on, 15 min 55 s without |
| Image contents | `make verify` — 22 checks, all passing |
| Preseed install | `make preseed` and `make install-preseed` run on the MAAS region controller |
| Upload | Image accepted by MAAS as `custom/proxmox-ve-9`, one complete resource set, SHA-256 matching the built file |
| Deployment | `custom/proxmox-ve-9`, amd64/generic, UEFI, LVM layout — reaches `Deployed` |
| `curtin-hooks` — kernel | Deployment no longer fails in "Configuring OS"; no kernel installed over APT |
| `curtin-hooks` — interface naming | `/etc/systemd/network/10-maas-enp6s18.link` written; the interface comes up as `enp6s18` and cloud-init attempts no rename |
| Node identity reset | Both nodes report their MAAS hostname; `/etc/pve/nodes/` holds `maas-node8` and `maas-node9`, not the build hostname |
| `hosts` stage | `/etc/hosts` maps the management IP to the FQDN; the Proxmox banner shows the correct address |
| `identity` stage | iSCSI initiator names regenerated and **different on each node** (`…:01:bfeec443aec7` vs `…:01:4b22246737a`) |
| `rootpw` stage | `PVE_ROOT_PASSWORD_HASH` lands in `/etc/shadow`; the web UI answers on 8006 |
| `network` stage | `vmbr0` up with the management address, `bridge-ports` set to the real interface, node reachable afterwards |
| `cluster` stage — create | `pvecm status`: cluster formed, `Quorate: Yes` |
| `cluster` stage — join | Second node joined on the first attempt in 36 s; both nodes independently report `Nodes: 2`, `Quorate: Yes` |
| `PVE_CLUSTER_WIPE_SECRETS` | Both password fields in `conf.d` scrubbed after joining |
| `storage` stage | `data` thin pool created from free VG space, registered as `local-lvm`, active on both nodes |
| Cluster-wide storage safety | The joining node detected the existing `local-lvm` and added itself to its node list instead of overwriting it |
| Stage resumption | The network stage reboots the node; remaining stages continue on the next boot and completed stages are skipped |
| `deploy-cluster.sh` | `--help` and `--dry-run` against the live MAAS; user-data rendered correctly for a three-node cluster |
| `make check-upstream` | Repository side — reports current `proxmox-ve` / `pve-manager` / `proxmox-default-kernel` versions |
### Not verified
Not known to be broken — simply never exercised. Treat as untested code.
**Options** — see the *Untested* markers in the tables above. In short: every
`PVE_NET_*` value other than the tested defaults, `PVE_CLUSTER_LINK0`/`LINK1`,
fingerprint discovery (TOFU), `PVE_CLUSTER_PEER_PASSWORD_FILE`, the join retry path,
plaintext `PVE_ROOT_PASSWORD`, `PVE_ENABLED=false`, `PVE_FQDN`, and every
`PVE_THINPOOL_*` value other than `auto` with the defaults.
**Build variants**
- `pve-enterprise` and `pve-test` repositories
- arm64
- BIOS boot (`BOOT=bios`)
- `DEBIAN_IMAGE_CHANNEL=daily`
- `APT_PROXY` and `make deps-cache`. The ~700 MB figure quoted under
[Build performance](#build-performance) is an estimate from download sizes, not a
measurement.
- A non-default `IMAGE_NAME`
**Make targets**`make upload` (the image was uploaded with the equivalent `maas`
command, not through the target), `make deps-cache`, `make clean`, `make distclean`, and
the image side of `make check-upstream`.
**Features added after the last build**`/etc/pve-maas/image-info` and therefore the
image side of `make check-upstream`. The code is written and syntax-checked; no built
image contains the file yet.
**`deploy-cluster.sh` beyond `--dry-run`.** The underlying mechanism (create, read
fingerprint, join) is verified, but the script's own deploy path has never run.
**Environments** — MAAS releases other than 3.7.2, a deb-packaged MAAS (only the snap
preseed path was used), MAAS storage layouts other than `lvm`, bonded or VLAN-tagged
networking, IPv6 (the network stage writes an `inet6` stanza when a global address
exists; that branch never ran), and real bare metal — the nodes tested were virtual
machines.
**Scale** — two nodes. Concurrent joins by several nodes at once, and clusters larger
than two, are untested.
## Licensing
**AGPL-3.0-or-later.** See [LICENSE](LICENSE).
This is not a free choice. The project builds on
[`canonical/packer-maas`](https://github.com/canonical/packer-maas), which Canonical
distributes under the AGPLv3, and parts of this repository are derived from it:
- `maas/curtin_userdata_custom.in` is adapted from upstream's
`debian/curtin_userdata_custom_amd64`. Several `late_commands` are copied verbatim —
the PXE-disable call, the `mount --bind` of the target, the cloud.cfg rewrite and the
`zz-update-grub` fix.
- `overlay/curtin/curtin-hooks` follows upstream's `debian/scripts/curtin-hooks`:
the same imports, the same
`load_command_environment``load_command_config``builtin_curthooks``cleanup`
structure, and a near-identical `cleanup()`. The kernel-disabling and
interface-pinning functions are original.
Because those are derivative works, the AGPLv3's copyleft carries over and the project
cannot be released under a permissive licence, or under the plain GPL.
The upstream template itself is **not vendored**. It is cloned at build time and pinned
by `PM_REF`; the Makefile applies a small, explicit patch to it.
In practice AGPLv3 asks very little of anyone using this. It is build tooling, not a
network service: building images, uploading them to MAAS and running the resulting
Proxmox nodes triggers no obligation. Section 13 — the clause AGPL is known for — only
applies to someone who offers a *modified version of this software* to others over a
network.
## References
- [MAAS — Build custom images](https://canonical.com/maas/docs/how-to-build-custom-images)
- [Proxmox VE — Install on Debian 13](https://pve.proxmox.com/wiki/Install_Proxmox_VE_on_Debian_13_Trixie)
- [Proxmox VE — Cluster Manager](https://pve.proxmox.com/wiki/Cluster_Manager)
- [`pvecm(1)`](https://pve.proxmox.com/pve-docs/pvecm.1.html)
- [`canonical/packer-maas`](https://github.com/canonical/packer-maas)