feat(pxe): one menu for every installable host, no per-host pxelinux configs
CI / prek (push) Successful in 12s
CI / tests (push) Successful in 45s
CI / deploy-sega (push) Successful in 12s

This commit is contained in:
ponas committed 2026-11-04 21:37:14 +01:00
1 parent ff27b75101
commit 8f7e34a762
10 files changed
+69 -183

No files matched your search

+1 -1
View File
@@ -42,5 +42,5 @@ Dump test data:
- Creates DHCPD configuration files
- Creates DNS zone files for BIND
- Creates PXE config files (with random secret for use with Puppet autosign)
- Netboots hosts marked PXE installable, by setting their DHCP boot filename
- Partial IPv6 support
-3
View File
@@ -322,9 +322,6 @@ class OperatingSystemAdmin(admin.ModelAdmin):
"name",
"version",
"arch",
"pxe_kernel",
"pxe_initrd",
"pxe_preseed_config_url",
]
-3
View File
@@ -2740,9 +2740,6 @@
},
{
"fields": {
"pxe_initrd": "path-to-initrd-for-os",
"pxe_kernel": "path-to-kernel-for-os",
"pxe_preseed_config_url": "http://url-to-preseed.config",
"arch": "x86_64",
"name": "Ubuntu",
"version": "16.04-beta-final"
+25
View File
@@ -0,0 +1,25 @@
# Generated by Django 6.1.2 on 2026-11-04 20:26
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('mdb', '0014_classless_reverse_zone_names'),
]
operations = [
migrations.RemoveField(
model_name='operatingsystem',
name='pxe_initrd',
),
migrations.RemoveField(
model_name='operatingsystem',
name='pxe_kernel',
),
migrations.RemoveField(
model_name='operatingsystem',
name='pxe_preseed_config_url',
),
]
-8
View File
@@ -4,7 +4,6 @@ import ipaddress
from django.db import models
from django.utils.translation import gettext_lazy as _
from mdb.pxe import host_as_pxe_files
from mdb.validators import validate_hostname, validate_macaddr
@@ -500,10 +499,6 @@ class OperatingSystem(models.Model):
max_length=255, choices=OS_ARCHITECTURES, blank=True, null=True
)
pxe_kernel = models.CharField(max_length=255, blank=True, null=True)
pxe_initrd = models.CharField(max_length=255, blank=True, null=True)
pxe_preseed_config_url = models.URLField(blank=True, null=True)
def __str__(self):
return self.name + " " + self.version + " (" + self.arch + ")"
@@ -540,9 +535,6 @@ class Host(models.Model):
def __str__(self):
return self.hostname
def as_pxe_files(self):
return host_as_pxe_files(self)
class Interface(models.Model):
name = models.CharField(max_length=128)
-60
View File
@@ -1,60 +0,0 @@
import ipaddress
from django.conf import settings
from django.template.loader import render_to_string
def render_pxelinux_cfg(_if, host):
"""Renders pxelinux.cfg based on a host and the set of host interfaces.
If the host is tied to an OS with PXE fields set, it will use these instead of defaults."""
# Default config
kernel = settings.MDB_PXE_KERNEL
initrd = settings.MDB_PXE_INITRD
preseed_config_url = settings.MDB_PXE_PRESEED_URL
# Per host config (via OS model)
_os = host.operating_system
pxe_attrs = [_os.pxe_initrd, _os.pxe_kernel, _os.pxe_preseed_config_url]
if None not in pxe_attrs and "" not in pxe_attrs:
kernel = _os.pxe_kernel
initrd = _os.pxe_initrd
preseed_config_url = _os.pxe_preseed_config_url
context = {
"kernel": kernel,
"initrd": initrd,
"preseed_config_url": preseed_config_url,
"host": host,
"interface": _if,
}
return render_to_string("pxelinux.cfg", context=context)
def get_pxe_filename(interface, filename_format="mac_addr"):
"""
Mac or IP address based PXE filename
Ref: http://www.syslinux.org/wiki/index.php/PXELINUX#Examples
"""
ip_addr_in_hex = f"{int(ipaddress.IPv4Address(interface.ip4address.address)):08X}"
formats = {
"ip_addr": ip_addr_in_hex,
"mac_addr": "01-{}".format(interface.macaddr.lower().replace(":", "-")),
}
return formats.get(filename_format, formats["mac_addr"])
def host_as_pxe_files(host):
"""Returns a list of filename,content for each host interface with an IPv4 address."""
from mdb.models import Host
assert isinstance(host, Host)
pxe_files = []
for _if in host.interface_set.exclude(ip4address__isnull=True):
filename = get_pxe_filename(_if)
content = render_pxelinux_cfg(_if, host)
pxe_files.append((filename, content))
return pxe_files
+20 -33
View File
@@ -1,9 +1,7 @@
import ipaddress
import logging
import os
import uuid
from django.conf import settings
from django.db.models.signals import post_save, pre_delete, pre_save
from django.dispatch import receiver
@@ -89,10 +87,14 @@ def update_domain_serial_when_change_to_host(sender, instance, created, **kwargs
subnet.save()
# The netboot host serves one menu to every machine DHCP sends to pxelinux.
PXE_FILENAME = "pxelinux.0"
@receiver(post_save, sender=Host)
def create_pxe_key_and_write_pxe_files_when_host_changes(
sender, instance, created, **kwargs
):
def set_pxe_boot_when_host_changes(sender, instance, created, **kwargs):
"""An installable host netboots into the menu; unticking it stops that.
A filename other than pxelinux's is left alone either way."""
host = instance
if not host.pxe_key:
@@ -101,35 +103,20 @@ def create_pxe_key_and_write_pxe_files_when_host_changes(
Host.objects.filter(pk=host.pk).update(pxe_key=pxe_key)
host.pxe_key = pxe_key
for pxe_file_name, pxe_file in host.as_pxe_files():
path = os.path.join(settings.MDB_PXE_TFTP_ROOT, pxe_file_name)
for _if in host.interface_set.exclude(ip4address__isnull=True):
changed = False
if host.pxe_installable:
# Write pxeconfig file to disk
with open(path, "w+") as f:
f.write(pxe_file)
logger.info(f"Created or updated {path}")
os.chmod(path, 0o644) # Not writeable for all
# Also set necessary fields for pxe installation
interfaces = host.interface_set.exclude(ip4address__isnull=True)
for _if in interfaces:
changed = False
if not _if.pxe_filename:
_if.pxe_filename = "pxelinux.0" # don't overwrite
changed = True
if not _if.dhcp_client:
_if.dhcp_client = True
changed = True
if changed:
_if.save()
else:
if os.path.exists(path):
# Delete pxeconfig file from disk
os.unlink(path)
logger.info(f"deleted {path}")
if not _if.pxe_filename:
_if.pxe_filename = PXE_FILENAME
changed = True
if not _if.dhcp_client:
_if.dhcp_client = True
changed = True
elif _if.pxe_filename == PXE_FILENAME:
_if.pxe_filename = ""
changed = True
if changed:
_if.save()
@receiver(pre_delete, sender=Interface)
-7
View File
@@ -1,7 +0,0 @@
default ninja
prompt 0
timeout 0
label ninja
kernel {{kernel}}
append ramdisk_size=14984 locale=en_US console-setup/ask_detect=false keyboard-configuration/layoutcode=no netcfg/wireless_wep= netcfg_choose_interface=eth0 netcfg/get_hostname= preseed/url={{preseed_config_url}} vga=normal snop={{host.pxe_key}} initrd={{initrd}} -- quiet
-6
View File
@@ -123,12 +123,6 @@ REST_FRAMEWORK = {
"DEFAULT_PERMISSION_CLASSES": ("rest_framework.permissions.IsAuthenticated",),
}
# PXE
MDB_PXE_TFTP_ROOT = "/var/lib/tftpboot/pxelinux/pxelinux.cfg/"
MDB_PXE_KERNEL = "ubuntu/14_04/amd64/alternate/linux"
MDB_PXE_INITRD = "ubuntu/14_04/amd64/alternate/initrd.gz"
MDB_PXE_PRESEED_URL = "http://158.36.190.194/ubuntu/preseed_1404.cfg"
# Sentry
SENTRY_DSN = os.getenv("SENTRY_DSN")
SENTRY_ENVIRONMENT = os.getenv("SENTRY_ENVIRONMENT", "dev")
+23 -62
View File
@@ -1,11 +1,9 @@
import pytest
from django.conf import settings
from django.contrib import messages
from django.core.management import call_command
from django.urls import reverse
from mdb.models import Host, Interface, OperatingSystem
from mdb.pxe import get_pxe_filename, render_pxelinux_cfg
from mdb.models import Host, Interface
@pytest.fixture
@@ -18,85 +16,48 @@ def host(db_with_fixtures):
return Host.objects.first()
@pytest.mark.django_db
def test_render_pxeconfig(host):
interface = host.interface_set.exclude(ip4address__isnull=True).first()
config = render_pxelinux_cfg(interface, host)
assert settings.MDB_PXE_INITRD in config
assert settings.MDB_PXE_KERNEL in config
assert settings.MDB_PXE_PRESEED_URL in config
@pytest.mark.django_db
def test_render_pxeconfig_with_os_pxe_config(host):
_os = OperatingSystem.objects.get(pk=2) # OS with all pxe vars set
Host.objects.filter(pk=host.pk).update(operating_system=_os)
host = Host.objects.first()
interface = host.interface_set.exclude(ip4address__isnull=True).first()
config = render_pxelinux_cfg(interface, host)
assert _os.pxe_initrd in config
assert _os.pxe_kernel in config
assert _os.pxe_preseed_config_url in config
@pytest.mark.django_db
def test_host_as_pxe_files(host):
assert len(host.as_pxe_files()) == 1
PXE_FILE = "01-f0-de-f1-58-be-1a" # from the fixture interface's MAC address
@pytest.fixture
def tftp_root(settings, tmp_path):
settings.MDB_PXE_TFTP_ROOT = str(tmp_path)
return tmp_path
def test_saving_installable_host_writes_pxe_file(host, tftp_root):
def test_saving_installable_host_boots_it_from_the_network(host):
Interface.objects.update(pxe_filename="", dhcp_client=False)
host.save()
path = tftp_root / PXE_FILE
assert host.pxe_key in path.read_text()
assert path.stat().st_mode & 0o777 == 0o644
# The interface is set up to boot from the network.
interface = host.interface_set.get()
assert interface.pxe_filename == "pxelinux.0"
assert interface.dhcp_client
def test_saving_host_keeps_custom_pxe_filename(host, tftp_root):
def test_saving_host_keeps_custom_pxe_filename(host):
Interface.objects.update(pxe_filename="custom.efi")
host.save()
assert host.interface_set.get().pxe_filename == "custom.efi"
def test_saving_non_installable_host_deletes_pxe_file(host, tftp_root):
(tftp_root / PXE_FILE).write_text("old")
def test_saving_non_installable_host_stops_netboot(host):
Interface.objects.update(pxe_filename="pxelinux.0", dhcp_client=True)
host.pxe_installable = False
host.save()
assert not (tftp_root / PXE_FILE).exists()
interface = host.interface_set.get()
assert interface.pxe_filename == ""
# The host keeps its address.
assert interface.dhcp_client
def test_saving_host_without_key_generates_one(host, tftp_root):
def test_saving_non_installable_host_keeps_custom_pxe_filename(host):
Interface.objects.update(pxe_filename="custom.efi")
host.pxe_installable = False
host.save()
assert host.interface_set.get().pxe_filename == "custom.efi"
def test_saving_host_without_key_generates_one(host):
host.pxe_key = ""
host.save()
key = Host.objects.get(pk=host.pk).pxe_key
assert len(key) == 32
assert key in (tftp_root / PXE_FILE).read_text()
assert len(Host.objects.get(pk=host.pk).pxe_key) == 32
def test_pxe_filename_by_ip_address(host):
interface = host.interface_set.get() # 10.0.3.2
assert get_pxe_filename(interface, "ip_addr") == "0A000302"
def test_admin_marks_hosts_installable(host, tftp_root, admin_client):
def test_admin_marks_hosts_installable(host, admin_client):
Host.objects.update(pxe_installable=False)
Interface.objects.update(pxe_filename="")
res = admin_client.post(
reverse("admin:mdb_host_changelist"),
@@ -106,11 +67,11 @@ def test_admin_marks_hosts_installable(host, tftp_root, admin_client):
assert res.status_code == 200
assert Host.objects.get(pk=host.pk).pxe_installable
assert (tftp_root / PXE_FILE).exists()
assert host.interface_set.get().pxe_filename == "pxelinux.0"
assert not [m for m in res.context["messages"] if m.level == messages.WARNING]
def test_admin_warns_about_interfaces_without_ip(host, tftp_root, admin_client):
def test_admin_warns_about_interfaces_without_ip(host, admin_client):
Host.objects.update(pxe_installable=False)
Interface.objects.create(
name="eth1", macaddr="00:11:22:33:44:55", host=host, domain_id=1