diff --git a/catalog/README.md b/catalog/README.md index dd9b796..b5f674c 100644 --- a/catalog/README.md +++ b/catalog/README.md @@ -61,6 +61,39 @@ real NetBox platform slug — so these entries are *only* reachable via the override and never hijack a plain `almalinux9` host. To add another model, copy one of these files, change the `name`/slug and `storage_mode`. +## Image (liveimg) installs + +`image.ks.tmpl` unpacks a prebuilt rootfs tarball with Anaconda `liveimg` instead +of resolving packages — faster and reproducible. An image catalog entry looks +like a normal one but sets `kickstart: image` and a `rootfs_tarball` var: + +```yaml +name: almalinux9-image +match: {platforms: [almalinux9-image]} +kickstart: image +kernel_url: "{{.ArtifactBase}}/almalinux/{{.Version}}/BaseOS/{{.Arch}}/os/images/pxeboot/vmlinuz" # the Anaconda installer kernel, unchanged +initrd_url: "...initrd.img" +vars: + rootfs_tarball: "https://artifactapi.k8s.syd1.au.unkin.net/api/v2/remotes/rootfs-images/files/almalinux9-node-.tar.zst" + # storage_mode / vg_grow work exactly as for package installs (omit for VM autopart). +``` + +- **Boot** is still the AlmaLinux installer kernel/initrd; `liveimg` only changes + the payload. **Storage** reuses the shared `kickstart/_storage.ks.tmpl` partials + (`storage_mode` / `vg_grow`), so image and package installs lay disks out + identically — including the OptiPlex NVMe modes (`optiplex-7080-image` shows an + image + `auto-nvme` + `vg_grow` combination). +- **Networking is templated per-host in `%post`** (NetworkManager keyfiles from + the same NetBox interface data), because the generic image has no per-host + identity and the `liveimg` unpack overwrites `/etc`. +- The tarball is built by the separate + [bootapi-images](https://git.unkin.net/unkin/bootapi-images) repo (a `v*` tag + builds and uploads `almalinux9-node-.tar.zst` to the artifactapi + `rootfs-images` local repo). Baked into the image = everything the `%post` + assumes present (kernel/grub/dracut, NetworkManager, openssh, chrony, + kexec-tools, curl, puppet-agent). Bump an image = new bootapi-images release + + a one-line `rootfs_tarball` edit here. + ## Adding another distro (the intended path) Add `catalog/.yaml` + `kickstart/.ks.tmpl`. If the OS lives on a diff --git a/catalog/almalinux9-image.yaml b/catalog/almalinux9-image.yaml new file mode 100644 index 0000000..38575a6 --- /dev/null +++ b/catalog/almalinux9-image.yaml @@ -0,0 +1,22 @@ +# Distro catalog entry: AlmaLinux 9, IMAGE install (liveimg). +# +# Boots the same AlmaLinux installer kernel/initrd as almalinux9, but the +# kickstart (image.ks.tmpl) uses liveimg to unpack the prebuilt node rootfs +# tarball instead of resolving packages -- much faster, reproducible. Generic +# storage (autopart on sda). Select via a device's provision_template custom +# field = "almalinux9-image". +name: almalinux9-image +match: + platforms: [almalinux9-image] +kickstart: image +version_default: "9" +kernel_url: "{{.ArtifactBase}}/almalinux/{{.Version}}/BaseOS/{{.Arch}}/os/images/pxeboot/vmlinuz" +initrd_url: "{{.ArtifactBase}}/almalinux/{{.Version}}/BaseOS/{{.Arch}}/os/images/pxeboot/initrd.img" +kernel_args: + - inst.text + - net.ifnames=0 +vars: + # Prebuilt node rootfs on the artifactapi rootfs-images local repo, built and + # published by the bootapi-images repo (v* tag). Immutable, semver-versioned; + # bump this one line to roll the fleet forward (overwrites are 409-rejected). + rootfs_tarball: "https://artifactapi.k8s.syd1.au.unkin.net/api/v2/remotes/rootfs-images/files/almalinux9-node-1.0.0.tar.zst" diff --git a/catalog/optiplex-7080-image.yaml b/catalog/optiplex-7080-image.yaml new file mode 100644 index 0000000..492a3d3 --- /dev/null +++ b/catalog/optiplex-7080-image.yaml @@ -0,0 +1,21 @@ +# Distro catalog entry: OptiPlex 7080, IMAGE install (liveimg). +# +# The image counterpart of optiplex-7080: same liveimg payload as +# almalinux9-image, but with the 7080's auto-NVMe / grow-to-fill storage +# (storage_mode auto-nvme + vg_grow). Proves the image kickstart reuses the +# OptiPlex model storage exactly as the package install does. Select via a +# device's provision_template custom field = "optiplex-7080-image". +name: optiplex-7080-image +match: + platforms: [optiplex-7080-image] +kickstart: image +version_default: "9" +kernel_url: "{{.ArtifactBase}}/almalinux/{{.Version}}/BaseOS/{{.Arch}}/os/images/pxeboot/vmlinuz" +initrd_url: "{{.ArtifactBase}}/almalinux/{{.Version}}/BaseOS/{{.Arch}}/os/images/pxeboot/initrd.img" +kernel_args: + - inst.text + - net.ifnames=0 +vars: + rootfs_tarball: "https://artifactapi.k8s.syd1.au.unkin.net/api/v2/remotes/rootfs-images/files/almalinux9-node-1.0.0.tar.zst" + storage_mode: auto-nvme + vg_grow: "true" diff --git a/kickstart/_storage.ks.tmpl b/kickstart/_storage.ks.tmpl new file mode 100644 index 0000000..e1f1985 --- /dev/null +++ b/kickstart/_storage.ks.tmpl @@ -0,0 +1,90 @@ +{{- /* +Shared model-aware storage partials, used by both almalinux9.ks.tmpl (package +install) and image.ks.tmpl (liveimg). One place for the storage layout so the +classic and image installs stay identical on disk. + +Driven by the distro catalog vars: + storage_mode "" generic/VM -> autopart on sda + "fixed-nvme" OptiPlex 3050 -> static nvme0n1, BIOS/MBR, fixed VG + "auto-nvme" OptiPlex 3060/7080 -> %pre picks the NVMe, EFI-aware + vg_grow "true" grow the LVM PV to fill the disk (OptiPlex 7080) + +liveimg works with this unchanged: Anaconda creates these filesystems, untars the +rootfs into them, writes fstab, installs grub and regenerates the initramfs. +*/ -}} +{{- define "storage-block" -}} +{{- $storage := index .DistroVars "storage_mode" -}} +{{- $grow := eq (index .DistroVars "vg_grow") "true" -}} +{{- if eq $storage "fixed-nvme" }} +# OptiPlex 3050: single known NVMe, legacy BIOS boot (no EFI, no boot-drive). +ignoredisk --only-use=nvme0n1 +clearpart --all --initlabel --drives=nvme0n1 +part /boot --fstype="xfs" --ondisk=nvme0n1 --size=1024 +part pv.01 --fstype="lvmpv" --ondisk=nvme0n1 --size=31743 +volgroup rootvg --pesize=4096 pv.01 +logvol / --fstype="xfs" --size=10240 --name=root --vgname=rootvg +logvol swap --fstype="swap" --size=2048 --name=swap --vgname=rootvg +logvol /home --fstype="xfs" --size=9207 --name=home --vgname=rootvg +logvol /var/log --fstype="xfs" --size=10240 --name=varlog --vgname=rootvg +bootloader --location=mbr +{{- else if eq $storage "auto-nvme" }} +# OptiPlex 3060 / 7080: partition table generated in %pre (see storage-pre) +# into /tmp/bootapi-partitions, so the OS lands on whichever internal NVMe is +# present and an EFI partition is added only when booted UEFI. +%include /tmp/bootapi-partitions +{{- else }} +# Generic / VM default: single virtual disk, autopart. +ignoredisk --only-use=sda +clearpart --all --initlabel --drives=sda +bootloader --location=mbr --boot-drive=sda --append="crashkernel=auto" +autopart --type=lvm --nohome +{{- end }} +{{- end -}} +{{- define "storage-pre" -}} +{{- $storage := index .DistroVars "storage_mode" -}} +{{- $grow := eq (index .DistroVars "vg_grow") "true" -}} +{{- if eq $storage "auto-nvme" }} +# --- %pre: pick the internal NVMe and emit the partition table ----------------- +# Ported from Cobbler almalinux9_dell3060 / almalinux9_dell7080. Anaconda reads +# every section before executing, so /tmp/bootapi-partitions (pulled in by the +# %include above) is populated in time. Picks the first NVMe under 512GB, wipes +# any prior rootvg so re-provisioning is idempotent, and adds an EFI System +# Partition only when the host actually booted UEFI. +%pre --interpreter=/usr/bin/bash --log=/root/bootapi-pre.log +set -x +lsblk -d -b -n -o NAME,SIZE + +# First internal NVMe under 512GB (skips large data disks and USB installers). +OSDISK=$(lsblk -d -b -n -o NAME,SIZE | awk '$1 ~ /^nvme/ && $2 < 549755813888 { print $1; exit }') + +# Tear down a pre-existing rootvg so a re-install starts from a clean disk. +if vgs rootvg >/dev/null 2>&1; then + lvchange -an rootvg || true + vgchange -an rootvg || true + vgremove -y rootvg || true + for pv in $(pvs --noheadings -o pv_name,vg_name | awk '$2 == "rootvg" { print $1 }'); do + pvremove "$pv" --force --force -y || true + done +fi + +wipefs -a "/dev/${OSDISK}" +dd if=/dev/zero of="/dev/${OSDISK}" bs=512 count=100 + +{ + echo "ignoredisk --only-use=${OSDISK}" + echo "clearpart --all --initlabel --drives=${OSDISK}" + if [ -d /sys/firmware/efi ]; then + echo "part /boot/efi --fstype=vfat --ondisk=${OSDISK} --size=200" + fi + echo "part /boot --fstype=xfs --ondisk=${OSDISK} --size=1024" + echo "part pv.01 --fstype=lvmpv --ondisk=${OSDISK} --size=31743{{ if $grow }} --grow{{ end }}" + echo "volgroup rootvg --pesize=4096 pv.01" + echo "logvol / --fstype=xfs --size=10240 --name=root --vgname=rootvg" + echo "logvol swap --fstype=swap --size=2048 --name=swap --vgname=rootvg" + echo "logvol /home --fstype=xfs --size=9207 --name=home --vgname=rootvg" + echo "logvol /var/log --fstype=xfs --size=10240 --name=varlog --vgname=rootvg" + echo "bootloader --location=mbr --boot-drive=${OSDISK}" +} > /tmp/bootapi-partitions +%end +{{- end }} +{{- end -}} diff --git a/kickstart/almalinux9.ks.tmpl b/kickstart/almalinux9.ks.tmpl index 1316654..921941b 100644 --- a/kickstart/almalinux9.ks.tmpl +++ b/kickstart/almalinux9.ks.tmpl @@ -1,19 +1,15 @@ {{- /* -AlmaLinux 9 kickstart, ported from the Cobbler default.ks contract. +AlmaLinux 9 kickstart (package install), ported from the Cobbler default.ks +contract. Install source is the artifactapi almalinux remote (catalog mirror +var). The %post installs the Puppet agent, points it at the k8s puppetserver, +writes the puppet-initial PUPPETCA_URL env file, then posts back to bootapi so +pxe_enabled flips off (Cobbler's netboot_enabled flow). -Rendered by bootapi from NetBox data + render-time secrets + the distro catalog. -Install source comes from the artifactapi almalinux remote (via the catalog -mirror var). The %post installs the Puppet agent and points it at the k8s -puppetserver (puppet.k8s.syd1.au.unkin.net / puppetca.k8s...), writes the -puppet-initial PUPPETCA_URL env file, then posts back to bootapi so pxe_enabled -flips off (Cobbler's netboot_enabled flow). +Storage is model-aware via the shared _storage.ks.tmpl partials (storage_mode / +vg_grow catalog vars) so this and image.ks.tmpl lay disks out identically. -Storage is model-aware: the catalog entry selects a `storage_mode` (and, for -grow-to-fill, `vg_grow`) so one template serves VMs and every Dell OptiPlex -model. See the "storage" section and catalog/optiplex-*.yaml. - -Data model: see docs/data-model.md. `.RootPasswordHash` and `.ProvisionToken` -come from Vault/env at render time, never from NetBox. +Data model: docs/data-model.md. `.RootPasswordHash` / `.ProvisionToken` come +from Vault/env at render time, never from NetBox. */ -}} {{- $mirror := .DistroVars.mirror -}} #version=RHEL9 @@ -50,90 +46,13 @@ network --bootproto=static --device={{ .MAC }} --ip={{ .IP }} --netmask={{ .Netm {{- end }} {{- end }} -# --- storage (model-aware; selected by the distro catalog's storage_mode var) --- -# storage_mode is set per-model by the catalog entry (catalog/optiplex-*.yaml): -# "" generic/VM default -> autopart on sda (unchanged legacy path) -# "fixed-nvme" OptiPlex 3050 -> static nvme0n1, BIOS/MBR, fixed 31G VG -# "auto-nvme" OptiPlex 3060 / 7080 -> %pre picks the internal NVMe, EFI-aware -# vg_grow ("true") grows the LVM PV to fill the disk (OptiPlex 7080 only). -# Layout (explicit LVM: /boot, root, swap, /home, /var/log) mirrors the Cobbler -# profiles almalinux9-dell_3050 / _3060 / _7080. Ported from the real templates -# almalinux9_dell3050 / almalinux9_dell3060 / almalinux9_dell7080. -{{- $storage := index .DistroVars "storage_mode" }} -{{- $grow := eq (index .DistroVars "vg_grow") "true" }} -{{- if eq $storage "fixed-nvme" }} -# OptiPlex 3050: single known NVMe, legacy BIOS boot (no EFI, no boot-drive). -ignoredisk --only-use=nvme0n1 -clearpart --all --initlabel --drives=nvme0n1 -part /boot --fstype="xfs" --ondisk=nvme0n1 --size=1024 -part pv.01 --fstype="lvmpv" --ondisk=nvme0n1 --size=31743 -volgroup rootvg --pesize=4096 pv.01 -logvol / --fstype="xfs" --size=10240 --name=root --vgname=rootvg -logvol swap --fstype="swap" --size=2048 --name=swap --vgname=rootvg -logvol /home --fstype="xfs" --size=9207 --name=home --vgname=rootvg -logvol /var/log --fstype="xfs" --size=10240 --name=varlog --vgname=rootvg -bootloader --location=mbr -{{- else if eq $storage "auto-nvme" }} -# OptiPlex 3060 / 7080: partition table is generated in %pre (below) into -# /tmp/bootapi-partitions and pulled in here, so the OS lands on whichever -# internal NVMe is present and the EFI partition is added only when booted UEFI. -%include /tmp/bootapi-partitions -{{- else }} -# Generic / VM default: single virtual disk, autopart. -ignoredisk --only-use=sda -clearpart --all --initlabel --drives=sda -bootloader --location=mbr --boot-drive=sda --append="crashkernel=auto" -autopart --type=lvm --nohome -{{- end }} +# --- storage (model-aware; shared with image.ks.tmpl via _storage.ks.tmpl) --- +{{ template "storage-block" . }} -# kdump: reserve crash memory + install kexec-tools (Cobbler com_redhat_kdump). +# kdump: reserve crash memory (Cobbler com_redhat_kdump; kexec-tools baked below). %addon com_redhat_kdump --enable --reserve-mb='auto' %end -{{- if eq $storage "auto-nvme" }} - -# --- %pre: pick the internal NVMe and emit the partition table ----------------- -# Ported from Cobbler almalinux9_dell3060 / almalinux9_dell7080. Anaconda runs -# %pre before partitioning, so /tmp/bootapi-partitions (pulled in by the -# %include above) is populated in time. Picks the first NVMe under 512GB, wipes -# any prior rootvg so re-provisioning is idempotent, and adds an EFI System -# Partition only when the host actually booted UEFI. -%pre --interpreter=/usr/bin/bash --log=/root/bootapi-pre.log -set -x -lsblk -d -b -n -o NAME,SIZE - -# First internal NVMe under 512GB (skips large data disks and USB installers). -OSDISK=$(lsblk -d -b -n -o NAME,SIZE | awk '$1 ~ /^nvme/ && $2 < 549755813888 { print $1; exit }') - -# Tear down a pre-existing rootvg so a re-install starts from a clean disk. -if vgs rootvg >/dev/null 2>&1; then - lvchange -an rootvg || true - vgchange -an rootvg || true - vgremove -y rootvg || true - for pv in $(pvs --noheadings -o pv_name,vg_name | awk '$2 == "rootvg" { print $1 }'); do - pvremove "$pv" --force --force -y || true - done -fi - -wipefs -a "/dev/${OSDISK}" -dd if=/dev/zero of="/dev/${OSDISK}" bs=512 count=100 - -{ - echo "ignoredisk --only-use=${OSDISK}" - echo "clearpart --all --initlabel --drives=${OSDISK}" - if [ -d /sys/firmware/efi ]; then - echo "part /boot/efi --fstype=vfat --ondisk=${OSDISK} --size=200" - fi - echo "part /boot --fstype=xfs --ondisk=${OSDISK} --size=1024" - echo "part pv.01 --fstype=lvmpv --ondisk=${OSDISK} --size=31743{{ if $grow }} --grow{{ end }}" - echo "volgroup rootvg --pesize=4096 pv.01" - echo "logvol / --fstype=xfs --size=10240 --name=root --vgname=rootvg" - echo "logvol swap --fstype=swap --size=2048 --name=swap --vgname=rootvg" - echo "logvol /home --fstype=xfs --size=9207 --name=home --vgname=rootvg" - echo "logvol /var/log --fstype=xfs --size=10240 --name=varlog --vgname=rootvg" - echo "bootloader --location=mbr --boot-drive=${OSDISK}" -} > /tmp/bootapi-partitions -%end -{{- end }} +{{ template "storage-pre" . }} # --- packages --- %packages --ignoremissing --excludedocs diff --git a/kickstart/image.ks.tmpl b/kickstart/image.ks.tmpl new file mode 100644 index 0000000..7076228 --- /dev/null +++ b/kickstart/image.ks.tmpl @@ -0,0 +1,131 @@ +{{- /* +Image-based AlmaLinux 9 kickstart. Anaconda `liveimg` unpacks a prebuilt rootfs +tarball (catalog var rootfs_tarball, on the artifactapi rootfs-images local repo) +onto the disk instead of resolving+installing packages: it formats the KS-defined +filesystems, untars the rootfs, installs grub and regenerates the initramfs. The +package set (kernel, grub2, openssh, chrony, puppet-agent, ...) is baked into the +tarball by .woodpecker/build-image.yaml. + +The image is GENERIC (no per-host identity). liveimg overwrites /etc with the +tarball, so all per-host config is applied in %post AFTER the unpack: static +NetworkManager keyfiles from the SAME NetBox interface data the classic template +uses, the hostname, the puppet-initial env, and the provisioned callback. + +Storage reuses the shared _storage.ks.tmpl partials (storage_mode / vg_grow), so +image and package installs land on disk identically (VM autopart, OptiPlex NVMe). +Select via a device's provision_template custom field (e.g. almalinux9-image). +*/ -}} +#version=RHEL9 +# Rendered by bootapi (image install) for {{ .FQDN }} (platform {{ .Platform }}) +text +firstboot --disable +reboot + +# --- payload: unpack the prebuilt rootfs tarball --- +liveimg --url={{ .DistroVars.rootfs_tarball }} + +# --- localization --- +keyboard --xlayouts='au' +lang en_AU.UTF-8 +timezone Australia/Sydney --utc + +# --- security --- +{{ if .RootPasswordHash -}} +rootpw --iscrypted {{ .RootPasswordHash }} +{{- else -}} +rootpw --lock +{{- end }} +selinux --enforcing +firewall --enabled --service=ssh + +# --- install-time networking (primary NIC only, so Anaconda can fetch the +# tarball). The installed system's network is written authoritatively in %post +# below, because the liveimg unpack overwrites whatever Anaconda configures. --- +{{- $primary := .PrimaryInterface }} +{{- if and $primary $primary.IP }} +network --bootproto=static --device={{ $primary.MAC }} --ip={{ $primary.IP }} --netmask={{ $primary.Netmask }}{{ if $primary.Gateway }} --gateway={{ $primary.Gateway }}{{ end }}{{ range .Nameservers }} --nameserver={{ . }}{{ end }} --hostname={{ $.FQDN }} --activate --onboot=on --noipv6 +{{- else }} +network --bootproto=dhcp --hostname={{ .FQDN }} --activate +{{- end }} + +# --- storage (model-aware; shared with almalinux9.ks.tmpl via _storage.ks.tmpl) --- +{{ template "storage-block" . }} + +# kdump: reserve crash memory (kexec-tools is baked into the image). +%addon com_redhat_kdump --enable --reserve-mb='auto' +%end +{{ template "storage-pre" . }} + +# NOTE: no %packages section - liveimg provides the package set from the tarball. + +# --- %post: apply per-host config AFTER the generic image is unpacked --- +%post --log=/root/bootapi-post.log +set -x + +# Hostname (the image is generic). +echo "{{ .FQDN }}" > /etc/hostname + +# Static per-host networking as NetworkManager keyfiles, from NetBox interface +# data. Written here (not via `network`) because the liveimg unpack clobbers +# /etc. Match on MAC so NIC renaming can't misapply an address. +install -d -m0755 /etc/NetworkManager/system-connections +rm -f /etc/NetworkManager/system-connections/*.nmconnection +{{- range .Interfaces }} +{{- if .IP }} +cat > "/etc/NetworkManager/system-connections/{{ .Name }}.nmconnection" <<'EOF' +[connection] +id={{ .Name }} +type=ethernet +interface-name={{ .Name }} +autoconnect=true + +[ethernet] +mac-address={{ .MAC }} + +[ipv4] +method=manual +address1={{ .IP }}/{{ .PrefixLen }}{{ if .Gateway }},{{ .Gateway }}{{ end }} +dns={{ range $.Nameservers }}{{ . }};{{ end }} +may-fail=false + +[ipv6] +method=disabled +EOF +{{- end }} +{{- end }} +chmod 600 /etc/NetworkManager/system-connections/*.nmconnection + +# chrony + ssh + NetworkManager come from the image; ensure they're enabled. +systemctl enable chronyd sshd NetworkManager + +{{ if .SSHAuthorizedKeys -}} +install -d -m0700 /root/.ssh +cat > /root/.ssh/authorized_keys <<'EOF' +{{ range .SSHAuthorizedKeys }}{{ . }} +{{ end }}EOF +chmod 0600 /root/.ssh/authorized_keys +{{- end }} + +# Puppet agent is baked into the image; just point it at the k8s server/CA. +PUPPET_BIN=/opt/puppetlabs/bin/puppet +"$PUPPET_BIN" config set --section main certname "{{ .FQDN }}" +"$PUPPET_BIN" config set --section main server "{{ .PuppetServer }}" +"$PUPPET_BIN" config set --section main ca_server "{{ .PuppetCAServer }}" +"$PUPPET_BIN" config set --section main report_server "{{ .PuppetServer }}" +"$PUPPET_BIN" config set --section main environment production + +install -d -m0755 /etc/sysconfig +cat > /etc/sysconfig/puppet-initial <<'EOF' +PUPPETCA_URL={{ .PuppetCAURL }} +EOF + +systemctl enable puppet + +{{ if and .ProvisionToken .CallbackURL -}} +# Clear pxe_enabled in NetBox so the next PXE boots local disk (plain HTTP; the +# token authenticates). Non-fatal on failure. +curl -fsS -m 15 -X POST \ + -H "Authorization: Bearer {{ .ProvisionToken }}" \ + "{{ .CallbackURL }}" || echo "bootapi: provisioned callback failed (non-fatal)" +{{- end }} +%end