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 DHCPD configuration files
|
||||||
- Creates DNS zone files for BIND
|
- 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
|
- Partial IPv6 support
|
||||||
@@ -322,9 +322,6 @@ class OperatingSystemAdmin(admin.ModelAdmin):
|
|||||||
"name",
|
"name",
|
||||||
"version",
|
"version",
|
||||||
"arch",
|
"arch",
|
||||||
"pxe_kernel",
|
|
||||||
"pxe_initrd",
|
|
||||||
"pxe_preseed_config_url",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -2740,9 +2740,6 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"fields": {
|
"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",
|
"arch": "x86_64",
|
||||||
"name": "Ubuntu",
|
"name": "Ubuntu",
|
||||||
"version": "16.04-beta-final"
|
"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.db import models
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
from mdb.pxe import host_as_pxe_files
|
|
||||||
from mdb.validators import validate_hostname, validate_macaddr
|
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
|
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):
|
def __str__(self):
|
||||||
return self.name + " " + self.version + " (" + self.arch + ")"
|
return self.name + " " + self.version + " (" + self.arch + ")"
|
||||||
|
|
||||||
@@ -540,9 +535,6 @@ class Host(models.Model):
|
|||||||
def __str__(self):
|
def __str__(self):
|
||||||
return self.hostname
|
return self.hostname
|
||||||
|
|
||||||
def as_pxe_files(self):
|
|
||||||
return host_as_pxe_files(self)
|
|
||||||
|
|
||||||
|
|
||||||
class Interface(models.Model):
|
class Interface(models.Model):
|
||||||
name = models.CharField(max_length=128)
|
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 ipaddress
|
||||||
import logging
|
import logging
|
||||||
import os
|
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
from django.conf import settings
|
|
||||||
from django.db.models.signals import post_save, pre_delete, pre_save
|
from django.db.models.signals import post_save, pre_delete, pre_save
|
||||||
from django.dispatch import receiver
|
from django.dispatch import receiver
|
||||||
|
|
||||||
@@ -89,10 +87,14 @@ def update_domain_serial_when_change_to_host(sender, instance, created, **kwargs
|
|||||||
subnet.save()
|
subnet.save()
|
||||||
|
|
||||||
|
|
||||||
|
# The netboot host serves one menu to every machine DHCP sends to pxelinux.
|
||||||
|
PXE_FILENAME = "pxelinux.0"
|
||||||
|
|
||||||
|
|
||||||
@receiver(post_save, sender=Host)
|
@receiver(post_save, sender=Host)
|
||||||
def create_pxe_key_and_write_pxe_files_when_host_changes(
|
def set_pxe_boot_when_host_changes(sender, instance, created, **kwargs):
|
||||||
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
|
host = instance
|
||||||
|
|
||||||
if not host.pxe_key:
|
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.objects.filter(pk=host.pk).update(pxe_key=pxe_key)
|
||||||
host.pxe_key = pxe_key
|
host.pxe_key = pxe_key
|
||||||
|
|
||||||
for pxe_file_name, pxe_file in host.as_pxe_files():
|
for _if in host.interface_set.exclude(ip4address__isnull=True):
|
||||||
path = os.path.join(settings.MDB_PXE_TFTP_ROOT, pxe_file_name)
|
changed = False
|
||||||
if host.pxe_installable:
|
if host.pxe_installable:
|
||||||
# Write pxeconfig file to disk
|
if not _if.pxe_filename:
|
||||||
with open(path, "w+") as f:
|
_if.pxe_filename = PXE_FILENAME
|
||||||
f.write(pxe_file)
|
changed = True
|
||||||
logger.info(f"Created or updated {path}")
|
if not _if.dhcp_client:
|
||||||
os.chmod(path, 0o644) # Not writeable for all
|
_if.dhcp_client = True
|
||||||
|
changed = True
|
||||||
# Also set necessary fields for pxe installation
|
elif _if.pxe_filename == PXE_FILENAME:
|
||||||
interfaces = host.interface_set.exclude(ip4address__isnull=True)
|
_if.pxe_filename = ""
|
||||||
|
changed = True
|
||||||
for _if in interfaces:
|
if changed:
|
||||||
changed = False
|
_if.save()
|
||||||
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}")
|
|
||||||
|
|
||||||
|
|
||||||
@receiver(pre_delete, sender=Interface)
|
@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",),
|
"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
|
||||||
SENTRY_DSN = os.getenv("SENTRY_DSN")
|
SENTRY_DSN = os.getenv("SENTRY_DSN")
|
||||||
SENTRY_ENVIRONMENT = os.getenv("SENTRY_ENVIRONMENT", "dev")
|
SENTRY_ENVIRONMENT = os.getenv("SENTRY_ENVIRONMENT", "dev")
|
||||||
|
|||||||
+23
-62
@@ -1,11 +1,9 @@
|
|||||||
import pytest
|
import pytest
|
||||||
from django.conf import settings
|
|
||||||
from django.contrib import messages
|
from django.contrib import messages
|
||||||
from django.core.management import call_command
|
from django.core.management import call_command
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
|
|
||||||
from mdb.models import Host, Interface, OperatingSystem
|
from mdb.models import Host, Interface
|
||||||
from mdb.pxe import get_pxe_filename, render_pxelinux_cfg
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
@@ -18,85 +16,48 @@ def host(db_with_fixtures):
|
|||||||
return Host.objects.first()
|
return Host.objects.first()
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
def test_saving_installable_host_boots_it_from_the_network(host):
|
||||||
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):
|
|
||||||
Interface.objects.update(pxe_filename="", dhcp_client=False)
|
Interface.objects.update(pxe_filename="", dhcp_client=False)
|
||||||
host.save()
|
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()
|
interface = host.interface_set.get()
|
||||||
assert interface.pxe_filename == "pxelinux.0"
|
assert interface.pxe_filename == "pxelinux.0"
|
||||||
assert interface.dhcp_client
|
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")
|
Interface.objects.update(pxe_filename="custom.efi")
|
||||||
host.save()
|
host.save()
|
||||||
assert host.interface_set.get().pxe_filename == "custom.efi"
|
assert host.interface_set.get().pxe_filename == "custom.efi"
|
||||||
|
|
||||||
|
|
||||||
def test_saving_non_installable_host_deletes_pxe_file(host, tftp_root):
|
def test_saving_non_installable_host_stops_netboot(host):
|
||||||
(tftp_root / PXE_FILE).write_text("old")
|
Interface.objects.update(pxe_filename="pxelinux.0", dhcp_client=True)
|
||||||
host.pxe_installable = False
|
host.pxe_installable = False
|
||||||
host.save()
|
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.pxe_key = ""
|
||||||
host.save()
|
host.save()
|
||||||
key = Host.objects.get(pk=host.pk).pxe_key
|
assert len(Host.objects.get(pk=host.pk).pxe_key) == 32
|
||||||
assert len(key) == 32
|
|
||||||
assert key in (tftp_root / PXE_FILE).read_text()
|
|
||||||
|
|
||||||
|
|
||||||
def test_pxe_filename_by_ip_address(host):
|
def test_admin_marks_hosts_installable(host, admin_client):
|
||||||
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):
|
|
||||||
Host.objects.update(pxe_installable=False)
|
Host.objects.update(pxe_installable=False)
|
||||||
|
Interface.objects.update(pxe_filename="")
|
||||||
|
|
||||||
res = admin_client.post(
|
res = admin_client.post(
|
||||||
reverse("admin:mdb_host_changelist"),
|
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 res.status_code == 200
|
||||||
assert Host.objects.get(pk=host.pk).pxe_installable
|
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]
|
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)
|
Host.objects.update(pxe_installable=False)
|
||||||
Interface.objects.create(
|
Interface.objects.create(
|
||||||
name="eth1", macaddr="00:11:22:33:44:55", host=host, domain_id=1
|
name="eth1", macaddr="00:11:22:33:44:55", host=host, domain_id=1
|
||||||
|
|||||||
Reference in new issue
Block a user