diff --git a/README.md b/README.md index 5af73a4..f6047b4 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/mdb/admin.py b/mdb/admin.py index cc8083a..2afbdb8 100644 --- a/mdb/admin.py +++ b/mdb/admin.py @@ -322,9 +322,6 @@ class OperatingSystemAdmin(admin.ModelAdmin): "name", "version", "arch", - "pxe_kernel", - "pxe_initrd", - "pxe_preseed_config_url", ] diff --git a/mdb/fixtures/test_data.json b/mdb/fixtures/test_data.json index 525ff9e..413ec63 100644 --- a/mdb/fixtures/test_data.json +++ b/mdb/fixtures/test_data.json @@ -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" diff --git a/mdb/migrations/0015_drop_os_pxe_fields.py b/mdb/migrations/0015_drop_os_pxe_fields.py new file mode 100644 index 0000000..90c6d6e --- /dev/null +++ b/mdb/migrations/0015_drop_os_pxe_fields.py @@ -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', + ), + ] diff --git a/mdb/models.py b/mdb/models.py index cd207df..f8d53f0 100644 --- a/mdb/models.py +++ b/mdb/models.py @@ -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) diff --git a/mdb/pxe.py b/mdb/pxe.py deleted file mode 100644 index c51843b..0000000 --- a/mdb/pxe.py +++ /dev/null @@ -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 diff --git a/mdb/signals.py b/mdb/signals.py index 057c996..8314a73 100644 --- a/mdb/signals.py +++ b/mdb/signals.py @@ -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) diff --git a/mdb/templates/pxelinux.cfg b/mdb/templates/pxelinux.cfg deleted file mode 100644 index 603d353..0000000 --- a/mdb/templates/pxelinux.cfg +++ /dev/null @@ -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 diff --git a/mdbsite/settings.py b/mdbsite/settings.py index 1c2a556..27ea3f9 100644 --- a/mdbsite/settings.py +++ b/mdbsite/settings.py @@ -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") diff --git a/tests/test_pxe.py b/tests/test_pxe.py index d0339fd..8933b30 100644 --- a/tests/test_pxe.py +++ b/tests/test_pxe.py @@ -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