Initial commit

This commit is contained in:
2025-11-07 05:29:32 +01:00
commit 6cb1c7d48b
22 changed files with 335 additions and 0 deletions

3
.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
ipxe/
rendered/
tftp/

40
Dockerfile Normal file
View File

@@ -0,0 +1,40 @@
FROM docker.io/library/debian:stable AS builder-ipxe
RUN apt-get update \
&& apt-get install -y \
build-essential \
curl \
liblzma-dev \
genisoimage
ARG IPXE_VERSION=b41bda4413bf286d7b7a449bc05e1531da1eec2e
RUN curl -L https://github.com/ipxe/ipxe/archive/${IPXE_VERSION}.tar.gz | tar -xz
WORKDIR /ipxe-${IPXE_VERSION}/src
# Enable HTTPS
RUN sed -i 's/^#undef[\t ]DOWNLOAD_PROTO_HTTPS.*$/#define DOWNLOAD_PROTO_HTTPS/g' config/general.h
RUN mkdir /build
RUN make -j$(nproc) bin/ipxe.pxe && cp bin/ipxe.pxe /build
RUN make -j$(nproc) bin-x86_64-efi/ipxe.efi && cp bin-x86_64-efi/ipxe.efi /build
FROM docker.io/library/python:3.13-slim AS config-renderer
COPY --from=docker.io/hairyhenderson/gomplate:v4.3 /gomplate /bin/gomplate
COPY ./requirements.txt /requirements.txt
RUN pip install -r /requirements.txt
COPY ./generate.sh /generate.sh
COPY ./tools /tools
COPY ./nodes /nodes
COPY ./templates /templates
RUN ./generate.sh
FROM docker.io/library/alpine:3.22.2 AS runtime
RUN apk add dnsmasq
COPY --from=builder-ipxe /build/ipxe.pxe /tftproot/
COPY --from=builder-ipxe /build/ipxe.efi /tftproot/
COPY --from=config-renderer /rendered/boot.ipxe /tftproot/
COPY --from=config-renderer /rendered/dnsmasq.conf /dnsmasq.conf
EXPOSE 67/udp
EXPOSE 69/udp
CMD ["dnsmasq", "--conf-file=/dnsmasq.conf", "--keep-in-foreground", "--user=root", "--log-facility=-", "--port=0"]

1
dhcp.yaml Normal file
View File

@@ -0,0 +1 @@
tftpIp: 10.0.0.3

5
generate.sh Executable file
View File

@@ -0,0 +1,5 @@
#!/usr/bin/env bash
set -euxo pipefail
SCRIPT_DIR=$(dirname -- "$(readlink -f -- "$BASH_SOURCE")")
${SCRIPT_DIR}/tools/merge.py ./nodes | gomplate -d nodes=stdin://nodes.json -d dhcp=${SCRIPT_DIR}/dhcp.yaml --input-dir ${SCRIPT_DIR}/templates --output-dir ${SCRIPT_DIR}/rendered

9
nodes/_defaults.yaml Normal file
View File

@@ -0,0 +1,9 @@
schematicID: !schematic "_schematic.yaml"
arch: amd64
talosVersion: v1.11.3
kernelArgs: talos.platform=metal console=tty0 init_on_alloc=1 slab_nomerge pti=on consoleblank=0 nvme_core.io_timeout=4294967295 printk.devkmsg=on selinux=1 lockdown=confidentiality
dns0: 1.1.1.1
dns1: 8.8.8.8
ntp: nl.pool.ntp.org
install: false
upgradeIPXE: false

5
nodes/_schematic.yaml Normal file
View File

@@ -0,0 +1,5 @@
customization:
systemExtensions:
officialExtensions:
- siderolabs/iscsi-tools
- siderolabs/util-linux-tools

View File

@@ -0,0 +1,3 @@
netmask: 255.255.252.0
gateway: 10.0.0.1
install: true

View File

@@ -0,0 +1,3 @@
serial: 5CZ7NX2
interface: enp2s0
ip: 10.0.0.202

View File

@@ -0,0 +1,3 @@
serial: F3PKRH2
interface: enp3s0
ip: 10.0.0.201

View File

@@ -0,0 +1,3 @@
serial: J33CHY2
interface: enp2s0
ip: 10.0.0.203

3
nodes/vm/_defaults.yaml Normal file
View File

@@ -0,0 +1,3 @@
netmask: 255.255.255.0
gateway: 192.168.1.1
upgradeIPXE: ipxe.pxe

4
nodes/vm/vm.yaml Normal file
View File

@@ -0,0 +1,4 @@
serial: vm
interface: enp1s0
ip: 192.168.1.2
install: true

2
requirements.txt Normal file
View File

@@ -0,0 +1,2 @@
PyYAML==6.0.3
requests==2.32.5

36
templates/boot.ipxe Normal file
View File

@@ -0,0 +1,36 @@
#!ipxe
dhcp
:start
# Is a known serial is set, execute that
# If an unknown serial is set, exit
# If no serial is set, ask the user
goto node_${serial} || goto manual
# Default behavior (non install mode) is to exit iPXE script
{{ range (datasource "nodes" | jsonArray) }}
{{- if .install }}
# {{ .filename }}
:node_{{ .serial }}
{{- $ipArg := printf "ip=%s::%s:%s:%s:%s::%s:%s:%s" .ip .gateway .netmask .hostname .interface .dns0 .dns1 .ntp }}
{{- $kernelArgs := printf "%s %s" $ipArg .kernelArgs }}
imgfree
kernel https://pxe.factory.talos.dev/image/{{ .schematicID }}/{{ .talosVersion }}/kernel-{{ .arch }} {{ $kernelArgs }} {{- if .upgradeIPXE }} || boot {{ .upgradeIPXE }} {{- end }}
initrd https://pxe.factory.talos.dev/image/{{ .schematicID }}/{{ .talosVersion }}/initramfs-{{ .arch }}.xz
boot
{{- end }}
{{ end }}
:manual
menu Select node
{{ range (datasource "nodes" | jsonArray) }}
item {{ .serial }} {{ .hostname }}
{{ end }}
choose selected || goto cancel
goto node_${selected}
:cancel
echo Type exit to restart script
shell
goto start

40
templates/dnsmasq.conf Normal file
View File

@@ -0,0 +1,40 @@
{{ $tftpIp := (ds "dhcp").tftpIp -}}
enable-tftp
tftp-root=/tftproot
tftp-single-port
dhcp-vendorclass=BIOS,PXEClient:Arch:00000
dhcp-vendorclass=UEFI,PXEClient:Arch:00007
dhcp-vendorclass=UEFI64,PXEClient:Arch:00009
# 1st stage: pxe rom boot on ipxe
dhcp-boot=net:BIOS,ipxe.pxe,{{ $tftpIp }},{{ $tftpIp }}
dhcp-boot=net:UEFI,ipxe.efi,{{ $tftpIp }},{{ $tftpIp }}
dhcp-boot=net:UEFI64,ipxe.efi,{{ $tftpIp }},{{ $tftpIp }}
# Based on logic in https://gist.github.com/robinsmidsrod/4008017
# iPXE sends a 175 option, checking suboptions
dhcp-match=set:ipxe-http,175,19
dhcp-match=set:ipxe-https,175,20
dhcp-match=set:ipxe-menu,175,39
dhcp-match=set:ipxe-pxe,175,33
dhcp-match=set:ipxe-bzimage,175,24
dhcp-match=set:ipxe-iscsi,175,17
dhcp-match=set:ipxe-efi,175,36
# set ipxe-ok tag if we have correct combination
tag-if=set:ipxe-ok,tag:ipxe-http,tag:ipxe-https
# these create option 43 cruft, which is required in proxy mode
# TFTP IP is required on all dhcp-boot lines (unless dnsmasq itself acts as tftp server?)
pxe-service=tag:!ipxe-ok,X86PC,PXE,undionly.kpxe,{{ $tftpIp }}
pxe-service=tag:!ipxe-ok,IA32_EFI,PXE,snponlyx32.efi,{{ $tftpIp }}
pxe-service=tag:!ipxe-ok,BC_EFI,PXE,snponly.efi,{{ $tftpIp }}
pxe-service=tag:!ipxe-ok,X86-64_EFI,PXE,snponly.efi,{{ $tftpIp }}
# later match overrides previous, keep ipxe script last
# server address must be non zero, but can be anything as long as iPXE script is not fetched over TFTP
dhcp-boot=tag:ipxe-ok,boot.ipxe,,{{ $tftpIp }}

96
tools/merge.py Executable file
View File

@@ -0,0 +1,96 @@
#!/usr/bin/env python3
# Adapted from: https://enix.io/en/blog/pxe-talos/
import argparse
import functools
import json
import pathlib
import requests
import yaml
@functools.cache
def get_schematic_id(schematic: str):
"""Lookup the schematic id associated with a given schematic"""
r = requests.post("https://factory.talos.dev/schematics", data=schematic)
r.raise_for_status()
data = r.json()
return data["id"]
def schematic_constructor(directory: pathlib.Path):
"""Load specified schematic file and get the assocatied schematic id"""
def constructor(loader: yaml.SafeLoader, node: yaml.nodes.ScalarNode):
filename = str(loader.construct_scalar(node))
try:
schematic = directory.joinpath(filename).read_text()
return get_schematic_id(schematic)
except Exception:
raise yaml.MarkedYAMLError("Failed to load schematic", node.start_mark)
return constructor
def get_loader(directory: pathlib.Path):
"""Add special constructors to yaml loader"""
loader = yaml.SafeLoader
loader.add_constructor("!schematic", schematic_constructor(directory))
return loader
@functools.cache
def get_defaults(directory: pathlib.Path, root: pathlib.Path):
"""Compute the defaults from the provided directory and parents."""
try:
with open(directory.joinpath("_defaults.yaml")) as fyaml:
yml_data = yaml.load(fyaml, Loader=get_loader(directory))
except OSError:
yml_data = {}
# Stop recursion when reaching root directory
if directory != root:
return get_defaults(directory.parent, root) | yml_data
else:
return yml_data
def walk_files(root: pathlib.Path):
"""Get all files that do not start with and underscore"""
for dirpath, _dirnames, filenames in root.walk():
for fn in filenames:
if not fn.startswith("_"):
yield dirpath.joinpath(fn)
def main():
parser = argparse.ArgumentParser()
parser.add_argument("directory", type=pathlib.Path)
parser.add_argument("-f", "--filter")
args = parser.parse_args()
data = []
for fullname in walk_files(args.directory):
filename = (
str(fullname.relative_to(args.directory).parent) + "/" + fullname.stem
)
if args.filter is not None and not filename.startswith(args.filter):
continue
with open(fullname) as fyaml:
yml_data = yaml.load(fyaml, Loader=get_loader(fullname.parent))
yml_data = get_defaults(fullname.parent, args.directory) | yml_data
yml_data["hostname"] = fullname.stem
yml_data["filename"] = filename
data.append(yml_data)
# Dump everything to json
print(json.dumps(data))
if __name__ == "__main__":
main()

13
vm/cluster-vm.xml Normal file
View File

@@ -0,0 +1,13 @@
<network xmlns:dnsmasq='http://libvirt.org/schemas/network/dnsmasq/1.0'>
<name>cluster-vm</name>
<bridge name="cluster0" stp="on" delay="0"/>
<forward mode='nat'>
<nat/>
</forward>
<ip address="192.168.1.1" netmask="255.255.255.0">
<dhcp>
<range start="192.168.1.2" end="192.168.1.254"/>
<bootp file='boot.ipxe'/>
</dhcp>
</ip>
</network>

15
vm/create.sh Executable file
View File

@@ -0,0 +1,15 @@
#!/usr/bin/env bash
SCRIPT_DIR=$(dirname -- "$(readlink -f -- "$BASH_SOURCE")")
source ${SCRIPT_DIR}/helper.sh
if [[ $(virsh --connect="${CONNECTION}" net-list --all | grep -c "${NETWORK}") == "0" ]]; then
virsh --connect="${CONNECTION}" net-define "${SCRIPT_DIR}/${NETWORK}.xml"
virsh --connect="${CONNECTION}" net-start "${NETWORK}"
virsh --connect="${CONNECTION}" net-autostart "${NETWORK}"
fi
virt-install --connect="${CONNECTION}" --name="${VM_NAME}" --vcpus="${VCPUS}" --memory="${RAM_MB}" \
--os-variant="linux2022" \
--disk="size=${DISK_GB}" \
--pxe \
--network network="${NETWORK}"

8
vm/destroy.sh Executable file
View File

@@ -0,0 +1,8 @@
#!/usr/bin/env bash
SCRIPT_DIR=$(dirname -- "$(readlink -f -- "$BASH_SOURCE")")
source ${SCRIPT_DIR}/helper.sh
virsh --connect="${CONNECTION}" destroy "${VM_NAME}"
virsh --connect="${CONNECTION}" undefine "${VM_NAME}" --remove-all-storage
virsh --connect="${CONNECTION}" net-destroy "${NETWORK}"
virsh --connect="${CONNECTION}" net-undefine "${NETWORK}"

10
vm/helper.sh Normal file
View File

@@ -0,0 +1,10 @@
set -euxo pipefail
VM_NAME="test"
VCPUS="2"
RAM_MB="2048"
DISK_GB="10"
NETWORK=cluster-vm
CONNECTION="qemu:///system"
IPXE_VERSION=b41bda4413bf286d7b7a449bc05e1531da1eec2e
IPXE_BIN=bin/ipxe.pxe

7
vm/start.sh Executable file
View File

@@ -0,0 +1,7 @@
#!/usr/bin/env bash
SCRIPT_DIR=$(dirname -- "$(readlink -f -- "$BASH_SOURCE")")
source ${SCRIPT_DIR}/helper.sh
virsh --connect="${CONNECTION}" start ${VM_NAME}
virt-viewer --connect="${CONNECTION}" ${VM_NAME}
virsh --connect="${CONNECTION}" shutdown ${VM_NAME}

26
vm/tftp.sh Executable file
View File

@@ -0,0 +1,26 @@
#!/usr/bin/env bash
SCRIPT_DIR=$(dirname -- "$(readlink -f -- "$BASH_SOURCE")")
source ${SCRIPT_DIR}/helper.sh
TFTP_DIR=${SCRIPT_DIR}/../tftp
rm -rf "${TFTP_DIR}"
mkdir -p "${TFTP_DIR}"
IPXE_DIR=${SCRIPT_DIR}/../ipxe
IPXE_FILE=${IPXE_DIR}/ipxe-${IPXE_VERSION}/src/${IPXE_BIN}
if [ ! -f "${IPXE_FILE}" ]; then
mkdir -p "${IPXE_DIR}"
rm -rf "${IPXE_DIR}/ipxe-${IPXE_VERSION}"
curl -L https://github.com/ipxe/ipxe/archive/${IPXE_VERSION}.tar.gz | tar -xz -C "${IPXE_DIR}"
cd "${IPXE_DIR}/ipxe-${IPXE_VERSION}/src"
sed -i 's/^#undef[\t ]DOWNLOAD_PROTO_HTTPS.*$/#define DOWNLOAD_PROTO_HTTPS/g' config/general.h
make -j$(nproc) ${IPXE_BIN}
cd -
fi
${SCRIPT_DIR}/../generate.sh
cp ${SCRIPT_DIR}/../rendered/boot.ipxe ${TFTP_DIR}
cp ${IPXE_FILE} ${TFTP_DIR}
sudo in.tftpd -L --secure ./tftp