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" 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) --- # --- VM Resource Transformation (No changes needed below this line) ---
all_nodes_transformed = { all_nodes_transformed = {
@ -53,7 +61,7 @@ locals {
disk_size = node.disk_size disk_size = node.disk_size
disk_storage = var.disk_storage disk_storage = var.disk_storage
tags = lookup(node, "tags", [node.role]) 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_size = lookup(node, "additional_disk_size", null)
additional_disk_storage = lookup(node, "additional_disk_size", null) != null ? var.additional_disk_storage : 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 api_token = var.proxmox_api_token
insecure = true insecure = true
} }
resource "null_resource" "stage_talos_image" { 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 = { triggers = {
node_name = each.value node_name = each.value.name
image_url = local.talos_iso_download_url proxmox_node = lookup(each.value, "proxmox_node", var.proxmox_node)
storage = var.talos_image_storage image_url = local.talos_iso_download_url
talos_version = var.talos_version storage = var.talos_image_storage
filename = "talos-${trimprefix(var.talos_version, "v")}-${substr(var.talos_factory_schematic_id, 0, 12)}-${var.talos_image_architecture}.iso" talos_version = var.talos_version
filename = local.node_iso_filenames[each.key]
network_config = sha256(local_file.metal_network_config[each.key].content)
} }
provisioner "local-exec" { provisioner "local-exec" {
interpreter = ["python", "-c"] 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 # Create all required network bridges
resource "proxmox_virtual_environment_network_linux_bridge" "cluster_bridge" { resource "proxmox_virtual_environment_network_linux_bridge" "cluster_bridge" {
for_each = local.unique_bridges for_each = local.unique_bridges

View file

@ -1,12 +1,18 @@
import argparse import argparse
import base64
import gzip
import json import json
import os import os
import shutil
import subprocess
import sys import sys
import tempfile
import ssl import ssl
import time import time
import urllib.error import urllib.error
import urllib.parse import urllib.parse
import urllib.request import urllib.request
from pathlib import Path
def api_request(base_url, token, method, path, data=None): def api_request(base_url, token, method, path, data=None):
@ -23,20 +29,30 @@ def api_request(base_url, token, method, path, data=None):
return json.loads(response.read().decode()) 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" content_path = f"/api2/json/nodes/{node}/storage/{storage}/content"
response = api_request(base_url, token, "GET", content_path) response = api_request(base_url, token, "GET", content_path)
for item in response.get("data", []): for item in response.get("data", []):
if item.get("volid", "").endswith(filename): if item.get("volid", "").endswith(filename):
return filename, False return filename, False
download_path = f"/api2/json/nodes/{node}/storage/{storage}/download-url" source_filename = filename
task = api_request(base_url, token, "POST", download_path, { if network_config_path:
"content": "iso", source_filename = f"base-{filename}"
"filename": filename,
"url": image_url, response = api_request(base_url, token, "GET", content_path)
}) source_present = any(item.get("volid", "").endswith(source_filename) for item in response.get("data", []))
wait_for_task(base_url, token, node, task.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": 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) response = api_request(base_url, token, "GET", content_path)
for item in response.get("data", []): for item in response.get("data", []):
@ -63,6 +79,42 @@ def wait_for_task(base_url, token, node, upid):
raise RuntimeError("timed out waiting for ISO download task to finish") 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(): def main():
parser = argparse.ArgumentParser() parser = argparse.ArgumentParser()
parser.add_argument("--api-url", required=True) parser.add_argument("--api-url", required=True)
@ -71,10 +123,11 @@ def main():
parser.add_argument("--storage", required=True) parser.add_argument("--storage", required=True)
parser.add_argument("--image-url", required=True) parser.add_argument("--image-url", required=True)
parser.add_argument("--filename", required=True) parser.add_argument("--filename", required=True)
parser.add_argument("--network-config")
args = parser.parse_args() args = parser.parse_args()
try: 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: except urllib.error.HTTPError as exc:
sys.stderr.write(exc.read().decode()) sys.stderr.write(exc.read().decode())
raise raise

View file

@ -54,11 +54,20 @@ resource "local_file" "talconfig" {
cluster_svc_nets = var.cluster_svc_nets cluster_svc_nets = var.cluster_svc_nets
cni_name = var.cni_name cni_name = var.cni_name
# --- Pass the proxy config ---
image_cache_proxy = var.image_cache_proxy 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) # (The output block is already correct from the last fix)
output "node_organization" { output "node_organization" {
description = "How your nodes are organized for Talos configuration" 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