feat(pxe): one menu for every installable host, no per-host pxelinux configs
This commit is contained in:
10 files changed
+69
-183
No files matched your search
@@ -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
|
||||
@@ -322,9 +322,6 @@ class OperatingSystemAdmin(admin.ModelAdmin):
|
||||
"name",
|
||||
"version",
|
||||
"arch",
|
||||
"pxe_kernel",
|
||||
"pxe_initrd",
|
||||
"pxe_preseed_config_url",
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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',
|
||||
),
|
||||
]
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in new issue
Block a user