fix: bake talos bootstrap network into iso

This commit is contained in:
eding 2026-07-19 22:56:17 +02:00
parent bb38c261af
commit d61225a9d3
5 changed files with 133 additions and 20 deletions

View file

@ -39,7 +39,15 @@ locals {
talos_iso_download_url = "https://factory.talos.dev/image/${var.talos_factory_schematic_id}/${var.talos_version}/nocloud-${var.talos_image_architecture}.iso"
image_target_nodes = toset(distinct([for node in var.nodes : lookup(node, "proxmox_node", var.proxmox_node)]))
image_target_nodes = toset([for node in var.nodes : node.name])
node_iso_filenames = {
for node in var.nodes : node.name => "talos-${trimprefix(var.talos_version, "v")}-${substr(var.talos_factory_schematic_id, 0, 12)}-${node.name}-${var.talos_image_architecture}.iso"
}
node_iso_files = {
for node in var.nodes : node.name => format("%s:iso/%s", var.talos_image_storage, local.node_iso_filenames[node.name])
}
# --- VM Resource Transformation (No changes needed below this line) ---
all_nodes_transformed = {
@ -53,7 +61,7 @@ locals {
disk_size = node.disk_size
disk_storage = var.disk_storage
tags = lookup(node, "tags", [node.role])
iso_file = var.talos_iso_file
iso_file = local.node_iso_files[node.name]
additional_disk_size = lookup(node, "additional_disk_size", null)
additional_disk_storage = lookup(node, "additional_disk_size", null) != null ? var.additional_disk_storage : null

View file

@ -16,24 +16,28 @@ provider "proxmox" {
api_token = var.proxmox_api_token
insecure = true
}
resource "null_resource" "stage_talos_image" {
for_each = var.talos_image_update_mode == "download" ? local.image_target_nodes : []
for_each = var.talos_image_update_mode == "download" ? { for node in var.nodes : node.name => node } : {}
triggers = {
node_name = each.value
node_name = each.value.name
proxmox_node = lookup(each.value, "proxmox_node", var.proxmox_node)
image_url = local.talos_iso_download_url
storage = var.talos_image_storage
talos_version = var.talos_version
filename = "talos-${trimprefix(var.talos_version, "v")}-${substr(var.talos_factory_schematic_id, 0, 12)}-${var.talos_image_architecture}.iso"
filename = local.node_iso_filenames[each.key]
network_config = sha256(local_file.metal_network_config[each.key].content)
}
provisioner "local-exec" {
interpreter = ["python", "-c"]
command = "import subprocess; subprocess.run(['python', r'${path.module}/scripts/stage_talos_image.py', '--api-url', '${var.proxmox_api_url}', '--api-token', '${var.proxmox_api_token}', '--node', '${each.value}', '--storage', '${var.talos_image_storage}', '--image-url', '${local.talos_iso_download_url}', '--filename', '${self.triggers.filename}'], check=True)"
command = "import subprocess; subprocess.run(['python', r'${path.module}/scripts/stage_talos_image.py', '--api-url', '${var.proxmox_api_url}', '--api-token', '${var.proxmox_api_token}', '--node', '${self.triggers.proxmox_node}', '--storage', '${var.talos_image_storage}', '--image-url', '${local.talos_iso_download_url}', '--filename', '${self.triggers.filename}', '--network-config', r'${local_file.metal_network_config[each.key].filename}'], check=True)"
}
depends_on = [local_file.metal_network_config]
}
# Create all required network bridges
resource "proxmox_virtual_environment_network_linux_bridge" "cluster_bridge" {
for_each = local.unique_bridges

View file

@ -1,12 +1,18 @@
import argparse
import base64
import gzip
import json
import os
import shutil
import subprocess
import sys
import tempfile
import ssl
import time
import urllib.error
import urllib.parse
import urllib.request
from pathlib import Path
def api_request(base_url, token, method, path, data=None):
@ -23,21 +29,31 @@ def api_request(base_url, token, method, path, data=None):
return json.loads(response.read().decode())
def ensure_image(base_url, token, node, storage, image_url, filename):
def ensure_image(base_url, token, node, storage, image_url, filename, network_config_path=None):
content_path = f"/api2/json/nodes/{node}/storage/{storage}/content"
response = api_request(base_url, token, "GET", content_path)
for item in response.get("data", []):
if item.get("volid", "").endswith(filename):
return filename, False
source_filename = filename
if network_config_path:
source_filename = f"base-{filename}"
response = api_request(base_url, token, "GET", content_path)
source_present = any(item.get("volid", "").endswith(source_filename) for item in response.get("data", []))
if not source_present:
download_path = f"/api2/json/nodes/{node}/storage/{storage}/download-url"
task = api_request(base_url, token, "POST", download_path, {
"content": "iso",
"filename": filename,
"filename": source_filename,
"url": image_url,
})
wait_for_task(base_url, token, node, task.get("data"))
if network_config_path:
build_custom_iso(base_url, token, node, storage, source_filename, filename, network_config_path)
response = api_request(base_url, token, "GET", content_path)
for item in response.get("data", []):
if item.get("volid", "").endswith(filename):
@ -63,6 +79,42 @@ def wait_for_task(base_url, token, node, upid):
raise RuntimeError("timed out waiting for ISO download task to finish")
def build_custom_iso(base_url, token, node, storage, source_filename, output_filename, network_config_path):
storage_path = f"/var/lib/vz/template/iso/{source_filename}"
output_path = f"/var/lib/vz/template/iso/{output_filename}"
network_config = Path(network_config_path).read_text(encoding="utf-8").strip()
installer_meta = base64.b64encode(gzip.compress(f"0xa={network_config}".encode("utf-8"), compresslevel=9)).decode("ascii")
if shutil.which("docker") is None:
raise RuntimeError("docker is required to build Talos ISO with embedded META")
with tempfile.TemporaryDirectory() as temp_dir:
temp_path = Path(temp_dir)
script_path = temp_path / "build.sh"
script_path.write_text(
"#!/bin/sh\n"
"set -eu\n"
"apk add --no-cache xorriso >/dev/null\n"
"cp /input/source.iso /work/source.iso\n"
"xorriso -indev /work/source.iso -outdev /output/result.iso -boot_image any replay \\\n"
f" -append_partition 2 0x0 /dev/null \\\n"
f" -boot_image any keep \\\n"
f" -map /input/cmdline /cmdline\n",
encoding="utf-8",
)
cmdline_path = temp_path / "cmdline"
cmdline_path.write_text(f"talos.environment=INSTALLER_META_BASE64={installer_meta}\n", encoding="utf-8")
subprocess.run([
"docker", "run", "--rm",
"-v", f"{script_path}:/input/build.sh:ro",
"-v", f"{cmdline_path}:/input/cmdline:ro",
"-v", f"{Path(storage_path).resolve()}:/input/source.iso:ro",
"-v", f"{temp_path}:/work",
"-v", f"{Path(output_path).resolve().parent}:/output",
"alpine:3.20", "sh", "/input/build.sh",
], check=True)
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--api-url", required=True)
@ -71,10 +123,11 @@ def main():
parser.add_argument("--storage", required=True)
parser.add_argument("--image-url", required=True)
parser.add_argument("--filename", required=True)
parser.add_argument("--network-config")
args = parser.parse_args()
try:
filename, downloaded = ensure_image(args.api_url, args.api_token, args.node, args.storage, args.image_url, args.filename)
filename, downloaded = ensure_image(args.api_url, args.api_token, args.node, args.storage, args.image_url, args.filename, args.network_config)
except urllib.error.HTTPError as exc:
sys.stderr.write(exc.read().decode())
raise

View file

@ -54,11 +54,20 @@ resource "local_file" "talconfig" {
cluster_svc_nets = var.cluster_svc_nets
cni_name = var.cni_name
# --- Pass the proxy config ---
image_cache_proxy = var.image_cache_proxy
})
}
resource "local_file" "metal_network_config" {
for_each = { for node in var.nodes : node.name => node }
filename = "${path.module}/../generated/metal-network/${each.key}.yaml"
content = templatefile("${path.module}/templates/talos-metal-network.yaml.tpl", {
node = each.value
dns_servers = var.dns_servers
})
}
# (The output block is already correct from the last fix)
output "node_organization" {
description = "How your nodes are organized for Talos configuration"

View file

@ -0,0 +1,39 @@
links:
%{ for i, net in node.networks ~}
- name: eth${i}
up: true
%{ if net.vlan_id > 0 ~}
- name: eth${i}.${net.vlan_id}
logical: true
up: true
kind: vlan
type: ether
parentName: eth${i}
vlan:
vlanID: ${net.vlan_id}
vlanProtocol: 802.1q
%{ endif ~}
%{ endfor ~}
addresses:
- address: ${node.networks[0].ip}/${split("/", node.networks[0].cidr)[1]}
linkName: %{ if node.networks[0].vlan_id > 0 }eth0.${node.networks[0].vlan_id}%{ else }eth0%{ endif }
family: inet4
scope: global
flags: permanent
layer: platform
routes:
- family: inet4
gateway: ${node.networks[0].gateway}
outLinkName: %{ if node.networks[0].vlan_id > 0 }eth0.${node.networks[0].vlan_id}%{ else }eth0%{ endif }
table: main
priority: 1024
scope: global
type: unicast
protocol: static
layer: platform
resolvers:
- dnsServers:
%{ for dns in dns_servers ~}
- ${dns}
%{ endfor ~}
layer: platform