Compare commits

...

16 Commits

Author SHA1 Message Date
2b7434c0e7 Removed unneeded --- from patches 2025-11-09 03:11:20 +01:00
08d73b95d4 Added source script to set environment variables 2025-11-09 03:07:20 +01:00
2cca38c860 Made repo root available for templates
This allows for embedding the repo root inside of, for example, scripts
to make them function properly no matter where they are run from.
2025-11-09 03:03:46 +01:00
d2a1eca146 Find root of repo that contains the actual script
This makes it possible to run the render script from anywhere and have
it still function correctly.
2025-11-09 03:03:21 +01:00
0049b5cb46 Moved logic for getting clusters to render script 2025-11-09 02:58:01 +01:00
3f8389ddd2 Made yaml template loader more generic 2025-11-09 02:26:35 +01:00
dac2864b2d Store template resolved nodes back in nodes object 2025-11-09 02:16:10 +01:00
85368a3126 Added template for config generation script 2025-11-09 02:16:10 +01:00
18b5d8fd18 Store patches as objects instead of strings 2025-11-09 02:05:08 +01:00
21ae5bc2c4 Added node types 2025-11-09 01:42:52 +01:00
17b0b05410 Added kubernetes version 2025-11-09 01:42:44 +01:00
8832371b99 Added jinja2 do extensions 2025-11-09 01:41:57 +01:00
a9fbf9aad8 Use consistent capitalization 2025-11-08 22:27:37 +01:00
4f072d7cb7 Moved around node config params 2025-11-08 22:23:43 +01:00
235ab5add7 Make python script runnable from anywhere 2025-11-08 21:47:45 +01:00
b0ac551c21 Render all template using python and jinja 2025-11-08 21:47:35 +01:00
20 changed files with 239 additions and 163 deletions

View File

@@ -7,6 +7,6 @@ indent_style = tab
indent_style = space
indent_size = 4
[{*.py,tools/merge}]
[{*.py,tools/render}]
indent_style = space
indent_size = 4

View File

@@ -1,6 +1,7 @@
schematicID: !schematic default
schematicId: !schematic default
arch: amd64
talosVersion: v1.11.3
kubernesVersion: v1.34.1
kernelArgs:
- talos.platform=metal
- console=tty0
@@ -18,8 +19,7 @@ dns:
- 1.1.1.1
- 8.8.8.8
ntp: nl.pool.ntp.org
installDisk: /dev/sda
install: false
install: true
patches:
- !patch hostname
- !patch install-disk

View File

@@ -1,4 +1,5 @@
netmask: 255.255.252.0
gateway: 10.0.0.1
install: true
clusterName: hellas
controlplaneIp: 10.0.2.1
installDisk: /dev/sda

View File

@@ -1,3 +1,4 @@
serial: 5CZ7NX2
interface: enp2s0
ip: 10.0.0.202
type: "controlplane"

View File

@@ -1,3 +1,4 @@
serial: F3PKRH2
interface: enp3s0
ip: 10.0.0.201
type: "controlplane"

View File

@@ -1,3 +1,4 @@
serial: J33CHY2
interface: enp2s0
ip: 10.0.0.203
type: "controlplane"

View File

@@ -2,4 +2,4 @@ netmask: 255.255.255.0
gateway: 192.168.1.1
clusterName: testing
controlplaneIp: 192.168.1.100
instalDisk: /dev/vda
installDisk: /dev/vda

View File

@@ -1,4 +1,4 @@
serial: talos-vm
interface: eth0
ip: 192.168.1.2
install: true
type: "controlplane"

View File

@@ -1,3 +1,2 @@
---
cluster:
allowSchedulingOnControlPlanes: true

View File

@@ -1,4 +1,3 @@
---
machine:
network:
hostname: {{hostname}}

View File

@@ -1,4 +1,3 @@
---
machine:
install:
disk: {{installDisk}}

View File

@@ -1,4 +1,3 @@
---
machine:
network:
interfaces:

View File

@@ -1,4 +1,3 @@
---
machine:
network:
interfaces:

View File

@@ -1,3 +1,4 @@
PyYAML==6.0.3
requests==2.32.5
Jinja2==3.1.6
GitPython==3.1.45

View File

@@ -5,21 +5,18 @@ dhcp
echo Starting ${serial}
: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} || shell
goto node_${serial} || exit
# Default behavior (non install mode) is to exit iPXE script
{{ range datasource "nodes" }}
{{- if .install }}
# {{ .filename }}
:node_{{ .serial }}
{{- $ipArg := printf "ip=%s::%s:%s:%s:%s::%s:%s:%s" .ip .gateway .netmask .hostname .interface (index .dns 0) (index .dns 1) .ntp }}
{{- $kernelArgs := printf "%s %s %s" $ipArg (join .kernelArgs " ") (join .extraKernelArgs " ") }}
{% for node in nodes %}
{%- if node.install -%}
# {{ node.filename }}
:node_{{ node.serial }}
{% set ipArg = "ip=" ~ node.ip ~ "::" ~ node.gateway ~ ":" ~ node.netmask ~ ":" ~ node.hostname ~ ":" ~ node.interface ~ "::" ~ node.dns[0] ~ ":" ~ node.dns[1] ~ ":" ~ node.ntp -%}
{% set kernelArgs = ipArg ~ " " ~ node.kernelArgs ~ " " ~ node.extraKernelArgs -%}
imgfree
kernel https://pxe.factory.talos.dev/image/{{ .schematicID }}/{{ .talosVersion }}/kernel-{{ .arch }} {{ $kernelArgs }}
initrd https://pxe.factory.talos.dev/image/{{ .schematicID }}/{{ .talosVersion }}/initramfs-{{ .arch }}.xz
kernel https://pxe.factory.talos.dev/image/{{ node.schematicId }}/{{ node.talosVersion }}/kernel-{{ node.arch }} {{ kernelArgs }}
initrd https://pxe.factory.talos.dev/image/{{ node.schematicId }}/{{ node.talosVersion }}/initramfs-{{ node.arch }}.xz
boot
{{- end }}
{{ end }}
{% endif %}
{% endfor %}

View File

@@ -1,4 +1,4 @@
{{ $tftpIp := (ds "config").dhcp.tftpIp -}}
{% set tftpIp = config.dhcp.tftpIp -%}
enable-tftp
tftp-root=/tftproot
@@ -9,9 +9,9 @@ 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 }}
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
@@ -30,11 +30,11 @@ 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 }}
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 }}
dhcp-boot=tag:ipxe-ok,boot.ipxe,,{{ tftpIp }}

View File

@@ -0,0 +1,40 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT={{ root }}
CONFIGS=${ROOT}/configs
# Generate the configuration for each node
{% for node in nodes -%}
talosctl gen config {{ node.clusterName }} https://{{ node.controlplaneIp }}:6443 -f \
--with-secrets ${ROOT}/secrets.yaml \
--talos-version {{ node.talosVersion }} \
--kubernetes-version {{ node.kubernesVersion }} \
--output-types {{ node.type }} \
--install-image factory.talos.dev/metal-installer/{{ node.schematicId }}:{{ node.talosVersion }} \
{% for patch in node.patches -%}
{# The double call to tojson is needed to properly escape the patch (object -> json -> string) -#}
--config-patch {{ patch|tojson|tojson }} \
{% endfor -%}
{% for patch in node.patchesControlplane -%}
--config-patch-control-plane {{ patch|tojson|tojson }} \
{% endfor -%}
--with-docs=false \
--with-examples=false \
-o ${CONFIGS}/{{ node.filename }}.yaml
{% endfor %}
# Generate the talosconfig file for each cluster
{# NOTE: This assumes that each node in a cluster specifies the same controlplane IP -#}
{% for cluster in clusters -%}
talosctl gen config {{ cluster.name }} https://{{ cluster.controlplaneIp }}:6443 -f \
--with-secrets ${ROOT}/secrets.yaml \
--output-types talosconfig \
-o ${CONFIGS}/{{ cluster.name }}/talosconfig
{% endfor %}
# Create merged talosconfig
export TALOSCONFIG=${CONFIGS}/talosconfig
rm -f ${TALOSCONFIG}
{% for cluster in clusters -%}
talosctl config merge ${CONFIGS}/{{ cluster.name }}/talosconfig
{% endfor %}

6
templates/source.sh Normal file
View File

@@ -0,0 +1,6 @@
export TALOSCONFIG={{ root }}/configs/talosconfig
{% set paths = [] %}
{%- for cluster_name in nodes|map(attribute="clusterName")|unique -%}
{%- do paths.append(root ~ "/configs/" ~ cluster_name ~ "/kubeconfig") -%}
{% endfor -%}
export KUBECONFIG={{ paths|join(":") }}

View File

@@ -1,119 +0,0 @@
#!/usr/bin/env python3
# Adapted from: https://enix.io/en/blog/pxe-talos/
import functools
import json
import pathlib
import requests
import yaml
from jinja2 import Environment, FileSystemLoader, StrictUndefined, Template
NODES = pathlib.Path("nodes")
SCHEMATICS = pathlib.Path("schematics")
PATCHES = Environment(loader=FileSystemLoader("patches"), undefined=StrictUndefined)
TEMPLATES = Environment(loader=FileSystemLoader("templates"), undefined=StrictUndefined)
def node_encoder(node: dict):
class Inner(json.JSONEncoder):
def default(self, o):
if isinstance(o, Template):
try:
rendered = o.render(node)
except Exception as e:
e.add_note(f"While rendering for: {node['hostname']}")
raise e
# Parse the rendered yaml and convert it to a json patch
return json.dumps(yaml.safe_load(rendered))
return super().default(o)
return Inner
@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(loader: yaml.SafeLoader, node: yaml.nodes.ScalarNode):
"""Load specified schematic file and get the assocatied schematic id"""
schematic_name = loader.construct_yaml_str(node)
try:
schematic = SCHEMATICS.joinpath(schematic_name).with_suffix(".yaml").read_text()
return get_schematic_id(schematic)
except Exception:
raise yaml.MarkedYAMLError("Failed to load schematic", node.start_mark)
def patch_constructor(loader: yaml.SafeLoader, node: yaml.nodes.ScalarNode):
patch_name = loader.construct_scalar(node)
try:
template = PATCHES.get_template(f"{patch_name}.yaml")
return template
except Exception:
raise yaml.MarkedYAMLError("Failed to load patch", node.start_mark)
def get_loader():
"""Add special constructors to yaml loader"""
loader = yaml.SafeLoader
loader.add_constructor("!schematic", schematic_constructor)
loader.add_constructor("!patch", patch_constructor)
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())
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():
nodes = []
for fullname in walk_files(NODES):
filename = str(fullname.relative_to(NODES).parent) + "/" + fullname.stem
with open(fullname) as fyaml:
yml_data = yaml.load(fyaml, Loader=get_loader())
yml_data = get_defaults(fullname.parent, NODES) | yml_data
yml_data["hostname"] = fullname.stem
yml_data["filename"] = filename
nodes.append(yml_data)
final_nodes = []
for node in nodes:
# Quick and dirty way to resolve all the templates using a custom encoder
final_nodes.append(json.loads(json.dumps(node, cls=node_encoder(node))))
# Dump everything to json
print(json.dumps(final_nodes, indent=4))
if __name__ == "__main__":
main()

View File

@@ -1,11 +1,163 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT=$(git rev-parse --show-toplevel)
RENDERED=${ROOT}/rendered
TEMPLATES=${ROOT}/templates
#!/usr/bin/env python3
${ROOT}/tools/merge ./nodes > ${RENDERED}/nodes.json
# Adapted from: https://enix.io/en/blog/pxe-talos/
gomplate --input-dir ${TEMPLATES} --output-dir ${RENDERED} \
-d nodes=file://${RENDERED}/nodes.json \
-d config=${ROOT}/config.yaml \
import functools
import json
import pathlib
import sys
import git
import requests
import yaml
from jinja2 import Environment, FileSystemLoader, StrictUndefined, Template
REPO = git.Repo(sys.path[0], search_parent_directories=True)
assert REPO.working_dir is not None
ROOT = pathlib.Path(REPO.working_dir)
NODES = ROOT.joinpath("nodes")
SCHEMATICS = ROOT.joinpath("schematics")
RENDERED = ROOT.joinpath("rendered")
EXTENSIONS = ["jinja2.ext.do"]
PATCHES = Environment(
loader=FileSystemLoader(ROOT.joinpath("patches")),
undefined=StrictUndefined,
extensions=EXTENSIONS,
)
TEMPLATES = Environment(
loader=FileSystemLoader(ROOT.joinpath("templates")),
undefined=StrictUndefined,
extensions=EXTENSIONS,
)
def render_templates(node: dict):
class Inner(json.JSONEncoder):
def default(self, o):
if isinstance(o, Template):
try:
rendered = o.render(node)
except Exception as e:
e.add_note(f"While rendering for: {node['hostname']}")
raise e
# Parse the rendered yaml
return yaml.safe_load(rendered)
return super().default(o)
return Inner
@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(loader: yaml.SafeLoader, node: yaml.nodes.ScalarNode):
"""Load specified schematic file and get the assocatied schematic id"""
schematic_name = loader.construct_yaml_str(node)
try:
schematic = SCHEMATICS.joinpath(schematic_name).with_suffix(".yaml").read_text()
return get_schematic_id(schematic)
except Exception:
raise yaml.MarkedYAMLError("Failed to load schematic", node.start_mark)
def template_constructor(environment: Environment):
def inner(loader: yaml.SafeLoader, node: yaml.nodes.ScalarNode):
patch_name = loader.construct_scalar(node)
try:
template = environment.get_template(f"{patch_name}.yaml")
return template
except Exception:
raise yaml.MarkedYAMLError("Failed to load patch", node.start_mark)
return inner
def get_loader():
"""Add special constructors to yaml loader"""
loader = yaml.SafeLoader
loader.add_constructor("!schematic", schematic_constructor)
loader.add_constructor("!patch", template_constructor(PATCHES))
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())
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():
nodes = []
for fullname in walk_files(NODES):
filename = str(fullname.relative_to(NODES).parent) + "/" + fullname.stem
with open(fullname) as fyaml:
yml_data = yaml.load(fyaml, Loader=get_loader())
yml_data = get_defaults(fullname.parent, NODES) | yml_data
yml_data["hostname"] = fullname.stem
yml_data["filename"] = filename
nodes.append(yml_data)
# Quick and dirty way to resolve all the templates using a custom encoder
nodes = list(
map(
lambda node: json.loads(json.dumps(node, cls=render_templates(node))), nodes
)
)
# Get all clusterName & controlplaneIp pairs
clusters = map(
lambda node: {
"name": node["clusterName"],
"controlplaneIp": node["controlplaneIp"],
},
nodes,
)
clusters = [dict(s) for s in set(frozenset(d.items()) for d in clusters)]
with open(ROOT.joinpath("config.yaml")) as fyaml:
config = yaml.safe_load(fyaml)
RENDERED.mkdir(exist_ok=True)
for template_name in TEMPLATES.list_templates():
template = TEMPLATES.get_template(template_name)
rendered = template.render(
nodes=nodes, clusters=clusters, config=config, root=ROOT
)
with open(RENDERED.joinpath(template_name), "w") as f:
f.write(rendered)
if __name__ == "__main__":
main()