diff --git a/terraform/locals.tf b/terraform/locals.tf index 9bcb0cc..cbb3abc 100644 --- a/terraform/locals.tf +++ b/terraform/locals.tf @@ -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 diff --git a/terraform/main.tf b/terraform/main.tf index e091c86..261b6aa 100644 --- a/terraform/main.tf +++ b/terraform/main.tf @@ -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 - 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" + 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) } 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 diff --git a/terraform/scripts/stage_talos_image.py b/terraform/scripts/stage_talos_image.py index 5ca766f..4f33377 100644 --- a/terraform/scripts/stage_talos_image.py +++ b/terraform/scripts/stage_talos_image.py @@ -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,20 +29,30 @@ 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 - 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")) + 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": 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", []): @@ -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 diff --git a/terraform/talconfig_generator.tf b/terraform/talconfig_generator.tf index 96a5488..fbbffb7 100644 --- a/terraform/talconfig_generator.tf +++ b/terraform/talconfig_generator.tf @@ -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" diff --git a/terraform/templates/talos-metal-network.yaml.tpl b/terraform/templates/talos-metal-network.yaml.tpl new file mode 100644 index 0000000..3308ad8 --- /dev/null +++ b/terraform/templates/talos-metal-network.yaml.tpl @@ -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