From 69e39bd81265f30f50d7637628a85e8f517fc326 Mon Sep 17 00:00:00 2001 From: eding Date: Mon, 20 Jul 2026 00:19:20 +0200 Subject: [PATCH] fix: use proxmox nocloud network disk --- terraform/main.tf | 69 +++++++++++++-- terraform/scripts/stage_talos_image.py | 84 +++---------------- .../templates/talos-metal-network.yaml.tpl | 61 ++++++-------- 3 files changed, 100 insertions(+), 114 deletions(-) diff --git a/terraform/main.tf b/terraform/main.tf index 2e5cef9..105b67a 100644 --- a/terraform/main.tf +++ b/terraform/main.tf @@ -20,23 +20,61 @@ resource "null_resource" "stage_talos_image" { for_each = var.talos_image_update_mode == "download" ? { for node in var.nodes : node.name => node } : {} triggers = { - 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 = local.node_iso_filenames[each.key] - network_config = sha256(local_file.metal_network_config[each.key].content) + 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 = local.node_iso_filenames[each.key] } 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', '${self.triggers.proxmox_node}', '--node-address', '${lookup(var.node_addresses, self.triggers.proxmox_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)" + 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}'], check=True)" + } +} + +resource "proxmox_virtual_environment_file" "nocloud_network_config" { + for_each = { for node in var.nodes : node.name => node } + + node_name = lookup(each.value, "proxmox_node", var.proxmox_node) + datastore_id = var.talos_image_storage + content_type = "snippets" + + source_raw { + data = local_file.metal_network_config[each.key].content + file_name = "${each.key}-network-config.yaml" } depends_on = [local_file.metal_network_config] } +resource "proxmox_virtual_environment_file" "nocloud_metadata" { + for_each = { for node in var.nodes : node.name => node } + + node_name = lookup(each.value, "proxmox_node", var.proxmox_node) + datastore_id = var.talos_image_storage + content_type = "snippets" + + source_raw { + data = "instance-id: ${each.key}\nlocal-hostname: ${each.key}\n" + file_name = "${each.key}-meta-data.yaml" + } +} + +resource "proxmox_virtual_environment_file" "nocloud_user_data" { + for_each = { for node in var.nodes : node.name => node } + + node_name = lookup(each.value, "proxmox_node", var.proxmox_node) + datastore_id = var.talos_image_storage + content_type = "snippets" + + source_raw { + data = "#cloud-config\n" + file_name = "${each.key}-user-data.yaml" + } +} + # Create all required network bridges resource "proxmox_virtual_environment_network_linux_bridge" "cluster_bridge" { @@ -122,12 +160,25 @@ resource "proxmox_virtual_environment_vm" "vm" { file_id = each.value.iso_file } + # Talos reads this Proxmox-generated cidata disk through its NoCloud platform. + initialization { + datastore_id = var.talos_image_storage + interface = "ide3" + type = "nocloud" + meta_data_file_id = proxmox_virtual_environment_file.nocloud_metadata[each.key].id + network_data_file_id = proxmox_virtual_environment_file.nocloud_network_config[each.key].id + user_data_file_id = proxmox_virtual_environment_file.nocloud_user_data[each.key].id + } + # Ensure image staging and bridge setup complete before VMs depends_on = [ proxmox_virtual_environment_network_linux_bridge.cluster_bridge, null_resource.stage_talos_image, + proxmox_virtual_environment_file.nocloud_network_config, + proxmox_virtual_environment_file.nocloud_metadata, + proxmox_virtual_environment_file.nocloud_user_data, ] } # (Your output definitions should still work, but you may want to update -# vm_details to show the list of MAC addresses and bridges) \ No newline at end of file +# vm_details to show the list of MAC addresses and bridges) diff --git a/terraform/scripts/stage_talos_image.py b/terraform/scripts/stage_talos_image.py index 979bc2c..92b9a6a 100644 --- a/terraform/scripts/stage_talos_image.py +++ b/terraform/scripts/stage_talos_image.py @@ -1,9 +1,5 @@ import argparse -import base64 -import gzip import json -import os -import shutil import ssl import subprocess import sys @@ -11,9 +7,6 @@ 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): url = base_url.rstrip("/") + path req = urllib.request.Request(url, method=method) @@ -28,37 +21,23 @@ def api_request(base_url, token, method, path, data=None): return json.loads(response.read().decode()) -def ensure_image(base_url, token, node, node_address, storage, image_url, filename, network_config_path=None): +def ensure_image(base_url, token, node, storage, image_url, filename): content_path = f"/api2/json/nodes/{node}/storage/{storage}/content" response = api_request(base_url, token, "GET", content_path) - if network_config_path: - if any(item.get("volid", "").endswith(filename) for item in response.get("data", [])): + for item in response.get("data", []): + if item.get("volid", "").endswith(filename): return filename, False - source_filename = f"source-{filename}" - else: - for item in response.get("data", []): - if item.get("volid", "").endswith(filename): - return filename, False - source_filename = 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": source_filename, - "url": image_url, - }) - wait_for_task(base_url, token, node, task.get("data")) - if not wait_for_storage_content(base_url, token, node, storage, source_filename): - raise RuntimeError(f"download task finished but {source_filename} was not found in {storage}") - - if network_config_path: - build_custom_iso(base_url, token, node_address, storage, source_filename, filename, network_config_path) - if not wait_for_storage_content(base_url, token, node, storage, filename): - raise RuntimeError(f"custom Talos ISO {filename} was not found in {storage} after baking") + download_path = f"/api2/json/nodes/{node}/storage/{storage}/download-url" + task = api_request(base_url, token, "POST", download_path, { + "content": "iso", + "filename": filename, + "url": image_url, + }) + wait_for_task(base_url, token, node, task.get("data")) + if not wait_for_storage_content(base_url, token, node, storage, filename): + raise RuntimeError(f"download task finished but {filename} was not found in {storage}") response = api_request(base_url, token, "GET", content_path) for item in response.get("data", []): @@ -95,55 +74,18 @@ def wait_for_storage_content(base_url, token, node, storage, filename): return False -def run_ssh(node_address, script): - ssh = shutil.which("ssh") - if ssh is None: - raise RuntimeError("ssh is required to customize Talos ISO on the Proxmox host") - subprocess.run([ssh, f"root@{node_address}", script], check=True) - - - -def build_custom_iso(base_url, token, node_address, storage, source_filename, output_filename, network_config_path): - 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") - - script = ( - "set -eu; " - f"SRC='/var/lib/vz/template/iso/{source_filename}'; " - f"OUT='/var/lib/vz/template/iso/{output_filename}'; " - "TMPDIR=$(mktemp -d); trap 'rm -rf \"$TMPDIR\"' EXIT; " - "xorriso -osirrox on -indev \"$SRC\" -extract / \"$TMPDIR/extract\" >/dev/null 2>&1; " - "for CFG in \"$TMPDIR/extract/isolinux/isolinux.cfg\" \"$TMPDIR/extract/boot/grub/grub.cfg\"; do " - " if [ -f \"$CFG\" ]; then " - f" sed -i '0,/---/s// talos.environment=INSTALLER_META_BASE64={installer_meta} ---/' \"$CFG\"; " - " fi; " - "done; " - "xorriso -as mkisofs " - "-o \"$OUT\" " - "-isohybrid-mbr \"$TMPDIR/extract/isolinux/isohdpfx.bin\" " - "-c isolinux/boot.cat " - "-b isolinux/isolinux.bin " - "-no-emul-boot -boot-load-size 4 -boot-info-table " - "-eltorito-alt-boot -e EFI/BOOT/BOOTX64.EFI -no-emul-boot -isohybrid-gpt-basdat " - "-V TALOS \"$TMPDIR/extract\" >/dev/null 2>&1" - ) - run_ssh(node_address, script) - - def main(): parser = argparse.ArgumentParser() parser.add_argument("--api-url", required=True) parser.add_argument("--api-token", required=True) parser.add_argument("--node", required=True) - parser.add_argument("--node-address", required=True) 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.node_address, args.storage, args.image_url, args.filename, args.network_config) + filename, downloaded = ensure_image(args.api_url, args.api_token, args.node, args.storage, args.image_url, args.filename) except urllib.error.HTTPError as exc: sys.stderr.write(exc.read().decode()) raise diff --git a/terraform/templates/talos-metal-network.yaml.tpl b/terraform/templates/talos-metal-network.yaml.tpl index 3308ad8..e951a53 100644 --- a/terraform/templates/talos-metal-network.yaml.tpl +++ b/terraform/templates/talos-metal-network.yaml.tpl @@ -1,39 +1,32 @@ -links: +version: 2 +ethernets: %{ 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 + eth${i}: + match: + macaddress: "${net.mac_address}" + dhcp4: false +%{ if i == 0 && net.vlan_id == 0 ~} + addresses: + - ${net.ip}/${split("/", net.cidr)[1]} + gateway4: ${net.gateway} + nameservers: + addresses: +%{ for dns in dns_servers ~} + - ${dns} +%{ endfor ~} %{ 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: +%{ if node.networks[0].vlan_id > 0 ~} +vlans: + eth0.${node.networks[0].vlan_id}: + id: ${node.networks[0].vlan_id} + link: eth0 + addresses: + - ${node.networks[0].ip}/${split("/", node.networks[0].cidr)[1]} + gateway4: ${node.networks[0].gateway} + nameservers: + addresses: %{ for dns in dns_servers ~} - - ${dns} + - ${dns} %{ endfor ~} - layer: platform +%{ endif ~}