From 9c67885ac9c3d41e2de32e9d15790710f26c8e46 Mon Sep 17 00:00:00 2001 From: Saad Ali Date: Wed, 8 Jul 2026 11:04:15 +0500 Subject: [PATCH 1/2] feat(kubernetes): add collection with talos-baremetal-image role - New nixknight.kubernetes collection (galaxy.yml, meta/runtime.yml, README) - talos-baremetal-image role builds Talos bare-metal boot assets (metal ISO + PXE) per node-class profile via the Talos Image Factory or local imager - Ships control-plane, worker, worker-gpu, worker-storage profiles, each rendering its own Factory schematic (extensions/kernel args/meta/overlay) - Records a per-profile manifest (schematic id, asset paths, sha256, metal-installer ref); opt-in, guarded dd-to-USB is default OFF - No external collection deps; follows repo conventions (FQCN, assert validation, restrictive modes, yamllint-clean) - Add ansible_collections -> collections symlink for local path resolution --- ansible_collections | 1 + collections/nixknight/kubernetes/README.md | 38 +++ collections/nixknight/kubernetes/galaxy.yml | 15 ++ .../nixknight/kubernetes/meta/runtime.yml | 2 + .../roles/talos-baremetal-image/README.md | 245 ++++++++++++++++++ .../talos-baremetal-image/defaults/main.yml | 116 +++++++++ .../roles/talos-baremetal-image/meta/main.yml | 25 ++ .../tasks/build_profile.yml | 40 +++ .../talos-baremetal-image/tasks/factory.yml | 41 +++ .../talos-baremetal-image/tasks/imager.yml | 98 +++++++ .../talos-baremetal-image/tasks/main.yml | 86 ++++++ .../talos-baremetal-image/tasks/output.yml | 37 +++ .../talos-baremetal-image/tasks/preflight.yml | 51 ++++ .../talos-baremetal-image/tasks/schematic.yml | 49 ++++ .../talos-baremetal-image/tasks/usb_write.yml | 244 +++++++++++++++++ .../talos-baremetal-image/tasks/verify.yml | 81 ++++++ .../templates/boot.ipxe.j2 | 13 + .../templates/schematic.yaml.j2 | 30 +++ .../roles/talos-baremetal-image/vars/main.yml | 44 ++++ 19 files changed, 1256 insertions(+) create mode 120000 ansible_collections create mode 100644 collections/nixknight/kubernetes/README.md create mode 100644 collections/nixknight/kubernetes/galaxy.yml create mode 100644 collections/nixknight/kubernetes/meta/runtime.yml create mode 100644 collections/nixknight/kubernetes/roles/talos-baremetal-image/README.md create mode 100644 collections/nixknight/kubernetes/roles/talos-baremetal-image/defaults/main.yml create mode 100644 collections/nixknight/kubernetes/roles/talos-baremetal-image/meta/main.yml create mode 100644 collections/nixknight/kubernetes/roles/talos-baremetal-image/tasks/build_profile.yml create mode 100644 collections/nixknight/kubernetes/roles/talos-baremetal-image/tasks/factory.yml create mode 100644 collections/nixknight/kubernetes/roles/talos-baremetal-image/tasks/imager.yml create mode 100644 collections/nixknight/kubernetes/roles/talos-baremetal-image/tasks/main.yml create mode 100644 collections/nixknight/kubernetes/roles/talos-baremetal-image/tasks/output.yml create mode 100644 collections/nixknight/kubernetes/roles/talos-baremetal-image/tasks/preflight.yml create mode 100644 collections/nixknight/kubernetes/roles/talos-baremetal-image/tasks/schematic.yml create mode 100644 collections/nixknight/kubernetes/roles/talos-baremetal-image/tasks/usb_write.yml create mode 100644 collections/nixknight/kubernetes/roles/talos-baremetal-image/tasks/verify.yml create mode 100644 collections/nixknight/kubernetes/roles/talos-baremetal-image/templates/boot.ipxe.j2 create mode 100644 collections/nixknight/kubernetes/roles/talos-baremetal-image/templates/schematic.yaml.j2 create mode 100644 collections/nixknight/kubernetes/roles/talos-baremetal-image/vars/main.yml diff --git a/ansible_collections b/ansible_collections new file mode 120000 index 0000000..3c60050 --- /dev/null +++ b/ansible_collections @@ -0,0 +1 @@ +collections \ No newline at end of file diff --git a/collections/nixknight/kubernetes/README.md b/collections/nixknight/kubernetes/README.md new file mode 100644 index 0000000..13623f9 --- /dev/null +++ b/collections/nixknight/kubernetes/README.md @@ -0,0 +1,38 @@ +# **nixknight.kubernetes** + +An Ansible collection for Kubernetes-adjacent bare-metal automation. Its first role builds **Talos Linux boot media for bare-metal installs** — per-node-class ISO and PXE assets, with an optional guarded USB-write path — using only `ansible.builtin` (no cloud dependencies). + +## **Table of Contents** + +- [Requirements](#requirements) +- [Contents](#contents) +- [Installation](#installation) +- [License](#license) +- [Author](#author) + +## **Requirements** + +- Ansible 2.15 or higher (per `meta/runtime.yml`) +- For the Talos Factory backend: outbound HTTPS to the Talos Image Factory +- For the Talos imager backend: a working Docker CLI on the build host +- No external Ansible collection dependencies + +## **Contents** + +### **Roles** + +- **`talos-baremetal-image`** — renders a Talos Image Factory schematic per node class (or runs the `siderolabs/imager` container offline), produces local metal ISO + PXE assets, records a build manifest (schematic id, asset paths, recorded sha256s, and the matching `metal-installer` image ref), and can optionally write a chosen ISO to a USB device behind hard safety guards. See [`roles/talos-baremetal-image/README.md`](roles/talos-baremetal-image/README.md). + +## **Installation** + +```bash +ansible-galaxy collection install git+https://github.com/NIXKnight/Ansible-Collections.git#/collections/nixknight/kubernetes +``` + +## **License** + +This collection is licensed under MIT License (See the LICENSE file). + +## **Author** + +[Saad Ali](https://github.com/nixknight) diff --git a/collections/nixknight/kubernetes/galaxy.yml b/collections/nixknight/kubernetes/galaxy.yml new file mode 100644 index 0000000..2daec14 --- /dev/null +++ b/collections/nixknight/kubernetes/galaxy.yml @@ -0,0 +1,15 @@ +--- +namespace: "nixknight" +name: "kubernetes" +version: "0.1.0" +readme: "README.md" +authors: + - "Saad Ali " +description: "Ansible collection for Kubernetes-adjacent automation" +license: + - "MIT" +tags: + - kubernetes + - talos + - baremetal + - k8s diff --git a/collections/nixknight/kubernetes/meta/runtime.yml b/collections/nixknight/kubernetes/meta/runtime.yml new file mode 100644 index 0000000..898ad8f --- /dev/null +++ b/collections/nixknight/kubernetes/meta/runtime.yml @@ -0,0 +1,2 @@ +--- +requires_ansible: '>=2.15.0' diff --git a/collections/nixknight/kubernetes/roles/talos-baremetal-image/README.md b/collections/nixknight/kubernetes/roles/talos-baremetal-image/README.md new file mode 100644 index 0000000..805837e --- /dev/null +++ b/collections/nixknight/kubernetes/roles/talos-baremetal-image/README.md @@ -0,0 +1,245 @@ +# **talos-baremetal-image** + +An Ansible role that builds **Talos Linux boot media for bare-metal installs** — one set of assets per node class. For each profile it renders a Talos Image Factory schematic (or runs the `siderolabs/imager` container offline), produces local **metal ISO** and **PXE** assets, records a manifest of everything it built, and can optionally write a chosen ISO to a USB device behind hard safety guards. It has zero cloud dependencies and uses only `ansible.builtin`. + +## **Table of Contents** + +- [Requirements](#requirements) +- [Role Variables](#role-variables) +- [Profiles](#profiles) +- [Operational Notes](#operational-notes) +- [Installation](#installation) +- [Examples](#examples) +- [License](#license) +- [Author](#author) + +## **Requirements** + +- Ansible 2.15 or higher (per the collection `meta/runtime.yml`) +- Run on a build host (often `localhost`) that has outbound HTTPS to the Talos Image Factory (factory backend) **or** a working Docker CLI (imager backend) +- `lsblk`, `findmnt`, `readlink`, and `dd` available on the host when using the optional USB-write path (`util-linux` + `coreutils`); `xz` is additionally required for the `raw` USB source format +- No external Ansible collections are required (pure `ansible.builtin`) — a deliberate divergence from cloud-snapshot builders + +## **Role Variables** + +### **Required Variables** + +This role ships working defaults for every variable and has **no strictly required inputs**. In practice you will override at least `TBI_IMAGE_PROFILES` (extension sets per node class) and usually `TBI_TALOS_VERSION` and `TBI_OUTPUT_DIR`. + +### **Optional Variables** + +| Variable | Default | Description | Type | +|----------|---------|-------------|------| +| `TBI_TALOS_VERSION` | `v1.12.7` | Talos release used for every profile unless overridden per profile | string | +| `TBI_BUILD_BACKEND` | `factory` | `factory` (Image Factory HTTP API) or `imager` (offline container builds) | string | +| `TBI_FACTORY_API` | `https://factory.talos.dev` | Talos Image Factory endpoint (factory backend) | string | +| `TBI_IMAGER_IMAGE` | `ghcr.io/siderolabs/imager` | Imager container image; the role appends `:` (imager backend) | string | +| `TBI_DEFAULT_ARCH` | `amd64` | Architecture for any profile that omits `arch` (`amd64` or `arm64`) | string | +| `TBI_OUTPUT_DIR` | `{{ playbook_dir }}/talos-images` | Root output directory; each profile gets its own subdirectory | string | +| `TBI_IMAGE_PROFILES` | 4 profiles (see [Profiles](#profiles)) | Per-node-class image definitions | list | +| `TBI_VALIDATE_EXTENSIONS` | `true` | Treat a non-200 Factory schematic response as fatal, and require pinned `extension_images` digests on the imager backend | bool | +| `TBI_VERIFY_CHECKSUM` | `true` | Enable expected-hash verification when a profile supplies `expected_checksums` (otherwise sha256 is only recorded) | bool | +| `TBI_API_RETRIES` | `3` | Retries for Factory API calls and downloads | int | +| `TBI_API_DELAY` | `10` | Delay (seconds) between retries | int | +| `TBI_DOWNLOAD_TIMEOUT` | `1200` | Per-download timeout (seconds) for `get_url` | int | +| `TBI_CREATE_MANIFEST` | `true` | Write `manifest.json` under `TBI_OUTPUT_DIR` | bool | +| `TBI_USB_WRITE_ENABLE` | `false` | Master switch for the destructive USB-write path | bool | +| `TBI_USB_DEVICE` | `""` | Target device; a stable `/dev/disk/by-id/...` path is required unless overridden | string | +| `TBI_USB_ALLOW_UNSTABLE_DEVICE` | `false` | Allow a bare `/dev/sdX` target instead of a `/dev/disk/by-id/...` path (not recommended) | bool | +| `TBI_USB_CONFIRM` | `false` | Second confirmation flag; must be `true` together with `TBI_USB_WRITE_ENABLE` | bool | +| `TBI_USB_PROFILE` | `""` | Name of the profile whose asset is written to USB | string | +| `TBI_USB_SOURCE_FORMAT` | `iso` | `iso` (write the isohybrid ISO) or `raw` (decompress `.raw.xz` through `dd`) | string | +| `TBI_DIR_MODE` | `0750` | Mode for directories created by the role | string | +| `TBI_ASSET_MODE` | `0644` | Mode for downloaded / generated boot assets | string | +| `TBI_SCHEMATIC_MODE` | `0600` | Mode for the rendered schematic kept for audit | string | +| `TBI_MANIFEST_MODE` | `0644` | Mode for `manifest.json` | string | + +### **Per-Profile Keys** + +Each entry in `TBI_IMAGE_PROFILES` supports: + +| Key | Required | Description | +|-----|----------|-------------| +| `name` | yes | Profile name (`^[a-z0-9][a-z0-9_-]*$`); also the output subdirectory | +| `arch` | no | `amd64` (default) or `arm64` | +| `formats` | yes | Subset of `[iso, pxe, raw]` | +| `role` | no | Organizational only (`controlplane` / `worker`); does **not** change the image | +| `extensions` | no | Factory official extension names (factory backend) | +| `kernel_args` | no | Extra kernel args (Factory schematic, and the imager iPXE boot line) | +| `meta` | no | List of `{key, value}` Factory meta entries | +| `overlay` | no | `{name, image}` overlay for SBC targets | +| `talos_version` | no | Override `TBI_TALOS_VERSION` for this profile | +| `schematic_id` | no | Use a pre-computed Factory schematic id and skip generation | +| `extension_images` | no | Pinned digest refs (`…@sha256:<64 hex>`), **imager backend only** | +| `expected_checksums` | no | Map of asset basename → sha256 for real verification | +| `secureboot` | no | **RESERVED, unimplemented in v1** — must be `false` | + +## **Profiles** + +The default `TBI_IMAGE_PROFILES` ships four node classes, all `amd64`, all building `iso` + `pxe`: + +| Profile | `role` | Extensions | +|---------|--------|------------| +| `control-plane` | `controlplane` | `iscsi-tools`, `util-linux-tools` | +| `worker` | `worker` | `iscsi-tools`, `util-linux-tools` | +| `worker-gpu` | `worker` | `nonfree-kmod-nvidia-production`, `nvidia-container-toolkit-production` | +| `worker-storage` | `worker` | `iscsi-tools`, `util-linux-tools`, `drbd` | + +A Talos *metal* image does **not** itself encode control-plane-vs-worker — that distinction lives in the machine config applied after first boot. Profiles differentiate **images** by extension set / kernel args / meta / overlay / arch; the `role` key is purely organizational (naming and output grouping). + +## **Operational Notes** + +- **This role does NOT embed machine config into images by default.** Boot media produced here is generic for its node class. Embedding role-specific machine config into an image is possible upstream but would blur the control-plane/worker boundary and is intentionally out of scope. Apply machine config after the node boots. +- **Boot media is not the installer.** The ISO/PXE assets only boot the node; the first-boot install/upgrade pulls the matching installer image. For the factory backend the role records `factory.talos.dev/metal-installer/:` per profile in the manifest. Reference that image in your machine config / upgrade flow. +- **Checksums are recorded, not verified, by default.** The role records the sha256 of each produced asset into the manifest. That is provenance bookkeeping, **not** an authenticity proof. Real verification runs only when `TBI_VERIFY_CHECKSUM` is `true` **and** a profile supplies `expected_checksums` for the asset basename, in which case a mismatch fails the run. Nothing here is "sha256-verified" by default — it is "sha256 recorded (+ optional expected-hash verification)". +- **Extension validation is Factory's, not a guessed endpoint.** For the factory backend, extensions are validated by POSTing the schematic to `/schematics`; an unknown extension or invalid field makes Factory return a non-200 response, which the role treats as fatal (when `TBI_VALIDATE_EXTENSIONS`). The role never GETs a guessed "official extensions list". For the imager backend, `extension_images` must be **explicit, pinned digest refs** that you resolve out of band, e.g. `crane export ghcr.io/siderolabs/extensions: | tar x -O image-digests | grep `; the role asserts they are pinned and never guesses them. +- **PXE handling differs by backend.** Factory PXE downloads the Factory-hosted iPXE script from `…/pxe///metal-` (it references Factory-hosted kernel/initramfs); the role does not hardcode kernel/initramfs filenames. Imager PXE runs the imager with `--output-kind kernel` and `--output-kind initramfs` and additionally renders a local iPXE file (`boot.ipxe.j2`) — Talos does **not** embed extra kernel args into the PXE kernel/initramfs, so kernel args are placed on the iPXE boot line. Set `TBI_IPXE_BASE_URL` to point the iPXE file at your HTTP(S) host serving the kernel/initramfs. +- **Extensions are not a complete stack.** The bundled extension choices boot the kernel pieces only. NVIDIA still requires the Talos/Kubernetes NVIDIA runtime class and the device plugin; `drbd` requires the matching userspace/operator; `util-linux-tools` is a minimal/contrib helper set, not generally required. Treat extensions as one layer, not a turnkey solution. +- **SecureBoot is deferred.** The `secureboot` per-profile key is reserved but unimplemented in v1 (it needs distinct `*-secureboot.iso` asset names plus signing/key inputs). Profiles must keep it `false`; the role refuses `true` rather than ship a half-wired path. +- **The USB-write path is destructive and OFF by default.** It runs only when `TBI_USB_WRITE_ENABLE` and `TBI_USB_CONFIRM` are both `true` and `TBI_USB_DEVICE` is a `/dev/...` path. It then: requires a stable `/dev/disk/by-id/...` device (unless `TBI_USB_ALLOW_UNSTABLE_DEVICE`), resolves symlinks to the **whole** disk, rejects partitions, rejects non-removable disks, rejects any `crypt`/`lvm`/`raid`/`dm` holders, rejects mounted children, and rejects the disk backing `/` (compared by `MAJ:MIN`, not by name). It prints the target model/serial/size before writing, runs `dd` as root, and never runs in check mode. Even with all guards, **double-check the device** — `dd` is unforgiving. +- **Idempotency.** `get_url` (factory) and `command` with `creates:` (imager) skip work when the target asset already exists; delete an asset to force a rebuild. The recorded manifest always reflects what is currently on disk. +- **Intended host.** The role produces files on the host it targets; `TBI_OUTPUT_DIR` defaults to a `playbook_dir`-relative path, so running against `localhost` (or a dedicated build host) keeps the manifest paths meaningful. + +## **Installation** + +Install the collection containing this role: + +```bash +ansible-galaxy collection install git+https://github.com/NIXKnight/Ansible-Collections.git#/collections/nixknight/kubernetes +``` + +The collection has no external collection dependencies. + +## **Examples** + +### **Build All Default Profiles via the Factory** + +```yaml +- name: Build Talos bare-metal boot media + hosts: localhost + connection: local + gather_facts: true + vars: + TBI_TALOS_VERSION: "v1.12.7" + TBI_OUTPUT_DIR: "/srv/talos-images" + roles: + - role: nixknight.kubernetes.talos-baremetal-image +``` + +### **Custom Profiles With Kernel Args, Meta, and a Pinned Schematic** + +```yaml +- name: Build custom Talos boot media + hosts: localhost + connection: local + gather_facts: true + vars: + TBI_OUTPUT_DIR: "/srv/talos-images" + TBI_IMAGE_PROFILES: + - name: "control-plane" + role: "controlplane" + arch: "amd64" + extensions: + - "siderolabs/iscsi-tools" + - "siderolabs/util-linux-tools" + kernel_args: + - "console=ttyS0,115200n8" + meta: + - key: 10 + value: "example-machine-config-marker" + formats: + - "iso" + - "pxe" + - name: "worker-arm" + role: "worker" + arch: "arm64" + extensions: + - "siderolabs/iscsi-tools" + formats: + - "iso" + roles: + - role: nixknight.kubernetes.talos-baremetal-image +``` + +### **Offline Build via the Imager With Pinned Extension Digests** + +```yaml +- name: Build Talos boot media offline with the imager + hosts: localhost + connection: local + gather_facts: true + vars: + TBI_BUILD_BACKEND: "imager" + TBI_OUTPUT_DIR: "/srv/talos-images" + TBI_IMAGE_PROFILES: + - name: "worker-storage" + role: "worker" + arch: "amd64" + # Resolve digests out of band, e.g.: + # crane export ghcr.io/siderolabs/extensions:v1.12.7 \ + # | tar x -O image-digests | grep iscsi-tools + extension_images: + - "ghcr.io/siderolabs/iscsi-tools@sha256:0000000000000000000000000000000000000000000000000000000000000000" + - "ghcr.io/siderolabs/drbd@sha256:1111111111111111111111111111111111111111111111111111111111111111" + kernel_args: + # talos.platform=metal is already on the generated iPXE boot line; + # list only genuinely-extra args here. + - "net.ifnames=0" + formats: + - "iso" + - "pxe" + roles: + - role: nixknight.kubernetes.talos-baremetal-image +``` + +### **Write a Built ISO to a USB Device (Destructive, Explicit Opt-In)** + +```yaml +- name: Write a Talos ISO to a USB stick + hosts: localhost + connection: local + gather_facts: true + become: true + vars: + TBI_OUTPUT_DIR: "/srv/talos-images" + TBI_USB_WRITE_ENABLE: true + TBI_USB_CONFIRM: true + TBI_USB_PROFILE: "control-plane" + TBI_USB_SOURCE_FORMAT: "iso" + # Always prefer a stable by-id path. Verify it points at the USB stick. + TBI_USB_DEVICE: "/dev/disk/by-id/usb-SanDisk_Ultra_4C530001234567890123-0:0" + roles: + - role: nixknight.kubernetes.talos-baremetal-image +``` + +### **Verify Recorded Hashes Against Expected Values** + +```yaml +- name: Build and verify Talos boot media against known hashes + hosts: localhost + connection: local + gather_facts: true + vars: + TBI_OUTPUT_DIR: "/srv/talos-images" + TBI_VERIFY_CHECKSUM: true + TBI_IMAGE_PROFILES: + - name: "control-plane" + role: "controlplane" + arch: "amd64" + extensions: + - "siderolabs/iscsi-tools" + formats: + - "iso" + expected_checksums: + metal-amd64.iso: "0000000000000000000000000000000000000000000000000000000000000000" + roles: + - role: nixknight.kubernetes.talos-baremetal-image +``` + +## **License** + +This role is licensed under MIT License (See the LICENSE file). + +## **Author** + +[Saad Ali](https://github.com/nixknight) diff --git a/collections/nixknight/kubernetes/roles/talos-baremetal-image/defaults/main.yml b/collections/nixknight/kubernetes/roles/talos-baremetal-image/defaults/main.yml new file mode 100644 index 0000000..eb14a88 --- /dev/null +++ b/collections/nixknight/kubernetes/roles/talos-baremetal-image/defaults/main.yml @@ -0,0 +1,116 @@ +--- +# defaults file for talos-baremetal-image + +# Talos release used for every profile unless a profile overrides it with its +# own `talos_version` key. Bump this to retarget all images. +TBI_TALOS_VERSION: "v1.12.7" + +# Build backend: "factory" (Talos Image Factory HTTP API, no local tooling) or +# "imager" (offline/custom builds via the siderolabs imager container). +TBI_BUILD_BACKEND: "factory" + +# Talos Image Factory endpoint (factory backend only). +TBI_FACTORY_API: "https://factory.talos.dev" + +# Imager container image (imager backend only). The role appends ":". +TBI_IMAGER_IMAGE: "ghcr.io/siderolabs/imager" + +# Architecture applied to a profile that does not declare its own `arch`. +TBI_DEFAULT_ARCH: "amd64" + +# Root directory for produced assets. Each profile gets its own subdirectory +# (TBI_OUTPUT_DIR//). Defaults to a path next to the playbook; +# the role is intended to run on the build host (often localhost). +TBI_OUTPUT_DIR: "{{ playbook_dir }}/talos-images" + +# Per-node-class image definitions. Each entry builds one Factory schematic (or +# one imager invocation set). A Talos *metal* image does NOT itself encode +# control-plane-vs-worker; the `role` key is organizational (naming / grouping). +# Profiles differentiate images by extension set / kernel args / meta / overlay +# / arch. Optional per-profile keys: talos_version, meta (list of {key, value}), +# overlay ({name, image}), schematic_id (skip Factory generation), +# extension_images (full digest refs, imager backend only), +# expected_checksums (map of asset-basename -> sha256 for real verification), +# secureboot (RESERVED, unimplemented in v1). +TBI_IMAGE_PROFILES: + - name: "control-plane" + role: "controlplane" + arch: "amd64" + extensions: + - "siderolabs/iscsi-tools" + - "siderolabs/util-linux-tools" + kernel_args: [] + formats: + - "iso" + - "pxe" + - name: "worker" + role: "worker" + arch: "amd64" + extensions: + - "siderolabs/iscsi-tools" + - "siderolabs/util-linux-tools" + kernel_args: [] + formats: + - "iso" + - "pxe" + - name: "worker-gpu" + role: "worker" + arch: "amd64" + extensions: + - "siderolabs/nonfree-kmod-nvidia-production" + - "siderolabs/nvidia-container-toolkit-production" + kernel_args: [] + formats: + - "iso" + - "pxe" + - name: "worker-storage" + role: "worker" + arch: "amd64" + extensions: + - "siderolabs/iscsi-tools" + - "siderolabs/util-linux-tools" + - "siderolabs/drbd" + kernel_args: [] + formats: + - "iso" + - "pxe" + +# When true (default), the factory backend treats a non-200 response from the +# schematic POST as fatal, so unknown/invalid extensions fail the run. For the +# imager backend it enforces that every `extension_images` entry is a pinned +# digest ref. This does NOT GET a guessed "official extensions" endpoint; +# validation is Factory's own schematic response / explicit digest checks. +TBI_VALIDATE_EXTENSIONS: true + +# Checksum handling. By default the role RECORDS the sha256 of each produced +# asset into the manifest (recording, not authenticity proof). Real verification +# (assert-match, fail on mismatch) runs ONLY when TBI_VERIFY_CHECKSUM is true +# AND a profile supplies `expected_checksums` for the asset basename. +TBI_VERIFY_CHECKSUM: true + +# Download resilience (factory backend). +TBI_API_RETRIES: 3 +TBI_API_DELAY: 10 +TBI_DOWNLOAD_TIMEOUT: 1200 + +# Write a manifest.json under TBI_OUTPUT_DIR describing every profile's assets. +TBI_CREATE_MANIFEST: true + +# USB write (destructive `dd`) -- ALL default-safe / OFF. Writing requires +# TBI_USB_WRITE_ENABLE and TBI_USB_CONFIRM both true and a valid device. +TBI_USB_WRITE_ENABLE: false +# Prefer a stable /dev/disk/by-id/... path. A bare /dev/sdX is rejected unless +# TBI_USB_ALLOW_UNSTABLE_DEVICE is set to true. +TBI_USB_DEVICE: "" +TBI_USB_ALLOW_UNSTABLE_DEVICE: false +TBI_USB_CONFIRM: false +# Which profile's asset to write, and which format (iso writes the isohybrid +# ISO directly; raw decompresses the .raw.xz through dd). +TBI_USB_PROFILE: "" +TBI_USB_SOURCE_FORMAT: "iso" + +# File / directory modes applied by the role. +TBI_DIR_MODE: "0750" +TBI_ASSET_MODE: "0644" +TBI_SCHEMATIC_MODE: "0600" +TBI_MANIFEST_MODE: "0644" diff --git a/collections/nixknight/kubernetes/roles/talos-baremetal-image/meta/main.yml b/collections/nixknight/kubernetes/roles/talos-baremetal-image/meta/main.yml new file mode 100644 index 0000000..e34120a --- /dev/null +++ b/collections/nixknight/kubernetes/roles/talos-baremetal-image/meta/main.yml @@ -0,0 +1,25 @@ +--- +dependencies: [] +galaxy_info: + author: Saad Ali + description: Build Talos Linux bare-metal boot media (ISO / PXE) per node class. + license: MIT + platforms: + - name: Debian + versions: + - bookworm + - name: Ubuntu + versions: + - jammy + - noble + galaxy_tags: + - talos + - kubernetes + - baremetal + - talos_linux + - image + - factory + - imager + - pxe + - iso + - k8s diff --git a/collections/nixknight/kubernetes/roles/talos-baremetal-image/tasks/build_profile.yml b/collections/nixknight/kubernetes/roles/talos-baremetal-image/tasks/build_profile.yml new file mode 100644 index 0000000..de5c656 --- /dev/null +++ b/collections/nixknight/kubernetes/roles/talos-baremetal-image/tasks/build_profile.yml @@ -0,0 +1,40 @@ +--- +# tasks file for talos-baremetal-image +# Per-profile entry point (loop_var: tbi_profile). Computes per-profile facts, +# dispatches to the factory or imager backend, then records produced assets. + +- name: Compute Per-Profile Facts + ansible.builtin.set_fact: + tbi_p_name: "{{ tbi_profile.name }}" + tbi_p_arch: "{{ tbi_profile.arch | default(TBI_DEFAULT_ARCH) }}" + tbi_p_version: "{{ tbi_profile.talos_version | default(TBI_TALOS_VERSION) }}" + tbi_p_formats: "{{ tbi_profile.formats }}" + tbi_p_outdir: "{{ TBI_OUTPUT_DIR }}/{{ tbi_profile.name }}" + tbi_p_schematic_id: "{{ tbi_profile.schematic_id | default('') }}" + +- name: Create Per-Profile Output Directory + ansible.builtin.file: + path: "{{ tbi_p_outdir }}" + state: directory + mode: "{{ TBI_DIR_MODE }}" + +- name: Generate Factory Schematic + ansible.builtin.include_tasks: + file: schematic.yml + when: + - TBI_BUILD_BACKEND == 'factory' + - tbi_p_schematic_id | length == 0 + +- name: Download Factory Assets + ansible.builtin.include_tasks: + file: factory.yml + when: TBI_BUILD_BACKEND == 'factory' + +- name: Build Assets with the Imager + ansible.builtin.include_tasks: + file: imager.yml + when: TBI_BUILD_BACKEND == 'imager' + +- name: Record and Verify Produced Assets + ansible.builtin.include_tasks: + file: verify.yml diff --git a/collections/nixknight/kubernetes/roles/talos-baremetal-image/tasks/factory.yml b/collections/nixknight/kubernetes/roles/talos-baremetal-image/tasks/factory.yml new file mode 100644 index 0000000..39a94a9 --- /dev/null +++ b/collections/nixknight/kubernetes/roles/talos-baremetal-image/tasks/factory.yml @@ -0,0 +1,41 @@ +--- +# tasks file for talos-baremetal-image +# Download the per-format Factory assets into the profile output directory. +# PXE = the Factory-hosted iPXE script (it references Factory kernel/initramfs +# URLs); kernel/initramfs filenames are never hardcoded here. + +- name: Download Metal ISO + ansible.builtin.get_url: + url: "{{ tbi_factory_iso_url }}" + dest: "{{ tbi_p_outdir }}/{{ tbi_iso_basename }}" + mode: "{{ TBI_ASSET_MODE }}" + timeout: "{{ TBI_DOWNLOAD_TIMEOUT }}" + register: tbi_get_iso + retries: "{{ TBI_API_RETRIES }}" + delay: "{{ TBI_API_DELAY }}" + until: tbi_get_iso is succeeded + when: "'iso' in tbi_p_formats" + +- name: Download Metal Raw Disk Image + ansible.builtin.get_url: + url: "{{ tbi_factory_raw_url }}" + dest: "{{ tbi_p_outdir }}/{{ tbi_raw_basename }}" + mode: "{{ TBI_ASSET_MODE }}" + timeout: "{{ TBI_DOWNLOAD_TIMEOUT }}" + register: tbi_get_raw + retries: "{{ TBI_API_RETRIES }}" + delay: "{{ TBI_API_DELAY }}" + until: tbi_get_raw is succeeded + when: "'raw' in tbi_p_formats" + +- name: Download Factory iPXE Boot Script + ansible.builtin.get_url: + url: "{{ tbi_factory_pxe_url }}" + dest: "{{ tbi_p_outdir }}/{{ tbi_factory_ipxe_basename }}" + mode: "{{ TBI_ASSET_MODE }}" + timeout: "{{ TBI_DOWNLOAD_TIMEOUT }}" + register: tbi_get_ipxe + retries: "{{ TBI_API_RETRIES }}" + delay: "{{ TBI_API_DELAY }}" + until: tbi_get_ipxe is succeeded + when: "'pxe' in tbi_p_formats" diff --git a/collections/nixknight/kubernetes/roles/talos-baremetal-image/tasks/imager.yml b/collections/nixknight/kubernetes/roles/talos-baremetal-image/tasks/imager.yml new file mode 100644 index 0000000..77ac7d6 --- /dev/null +++ b/collections/nixknight/kubernetes/roles/talos-baremetal-image/tasks/imager.yml @@ -0,0 +1,98 @@ +--- +# tasks file for talos-baremetal-image +# Build assets locally with the siderolabs imager container. Extension digests +# are taken verbatim from profile.extension_images (explicit, pinned digest +# refs) and are never guessed; resolve them out of band, e.g.: +# crane export ghcr.io/siderolabs/extensions: | tar x -O image-digests | grep +# NOTE: the raw/metal imager run is privileged and is intended to run only on a +# trusted build host. Talos does NOT embed extra kernel args into the PXE +# kernel/initramfs, so the role also renders a local iPXE file (boot.ipxe.j2) +# that carries the kernel args on its boot line. + +- name: Compose Imager Argv Fragments + ansible.builtin.set_fact: + tbi_imager_base: >- + {{ + ['docker', 'run', '--rm', '-t', '-v', tbi_p_outdir ~ ':/out'] + + (['-v', '/dev:/dev', '--privileged'] if ('raw' in tbi_p_formats) else []) + + [TBI_IMAGER_IMAGE ~ ':' ~ tbi_p_version] + }} + tbi_imager_ext_args: >- + {{ + ['--system-extension-image'] + | product(tbi_profile.extension_images | default([])) + | map('list') | flatten + }} + tbi_imager_karg_args: >- + {{ + ['--extra-kernel-arg'] + | product(tbi_profile.kernel_args | default([])) + | map('list') | flatten + }} + +- name: Build Metal ISO with the Imager + ansible.builtin.command: + argv: >- + {{ + tbi_imager_base + + ['iso', '--arch', tbi_p_arch] + + tbi_imager_ext_args + + tbi_imager_karg_args + }} + args: + creates: "{{ tbi_p_outdir }}/{{ tbi_iso_basename }}" + register: tbi_imager_iso + changed_when: tbi_imager_iso.rc == 0 + when: "'iso' in tbi_p_formats" + +- name: Build Metal Raw Disk Image with the Imager + ansible.builtin.command: + argv: >- + {{ + tbi_imager_base + + ['metal', '--arch', tbi_p_arch] + + tbi_imager_ext_args + + tbi_imager_karg_args + }} + args: + creates: "{{ tbi_p_outdir }}/{{ tbi_raw_basename }}" + register: tbi_imager_raw + changed_when: tbi_imager_raw.rc == 0 + when: "'raw' in tbi_p_formats" + +- name: Build PXE Kernel with the Imager + ansible.builtin.command: + argv: >- + {{ + tbi_imager_base + + ['iso', '--arch', tbi_p_arch, '--output-kind', 'kernel'] + + tbi_imager_ext_args + + tbi_imager_karg_args + }} + args: + creates: "{{ tbi_p_outdir }}/{{ tbi_imager_kernel_basename }}" + register: tbi_imager_kernel + changed_when: tbi_imager_kernel.rc == 0 + when: "'pxe' in tbi_p_formats" + +- name: Build PXE Initramfs with the Imager + ansible.builtin.command: + argv: >- + {{ + tbi_imager_base + + ['iso', '--arch', tbi_p_arch, '--output-kind', 'initramfs'] + + tbi_imager_ext_args + + tbi_imager_karg_args + }} + args: + creates: "{{ tbi_p_outdir }}/{{ tbi_imager_initramfs_basename }}" + register: tbi_imager_initramfs + changed_when: tbi_imager_initramfs.rc == 0 + when: "'pxe' in tbi_p_formats" + +- name: Render Local iPXE Boot Script + ansible.builtin.template: + src: "boot.ipxe.j2" + dest: "{{ tbi_p_outdir }}/{{ tbi_imager_ipxe_basename }}" + mode: "{{ TBI_ASSET_MODE }}" + when: "'pxe' in tbi_p_formats" diff --git a/collections/nixknight/kubernetes/roles/talos-baremetal-image/tasks/main.yml b/collections/nixknight/kubernetes/roles/talos-baremetal-image/tasks/main.yml new file mode 100644 index 0000000..c25ce46 --- /dev/null +++ b/collections/nixknight/kubernetes/roles/talos-baremetal-image/tasks/main.yml @@ -0,0 +1,86 @@ +--- +# tasks file for talos-baremetal-image + +- name: Validate Role Inputs + ansible.builtin.assert: + that: + # Talos version: vX.Y.Z grammar. + - TBI_TALOS_VERSION is string + - TBI_TALOS_VERSION is match('^v[0-9]+\.[0-9]+\.[0-9]+') + + # Build backend enum. + - TBI_BUILD_BACKEND in ['factory', 'imager'] + + # Output dir: non-empty, absolute, no '..' segments. + - TBI_OUTPUT_DIR is string + - TBI_OUTPUT_DIR | length > 0 + - TBI_OUTPUT_DIR is match('^/') + - "'..' not in (TBI_OUTPUT_DIR.split('/'))" + + # Profiles: a non-empty list. + - TBI_IMAGE_PROFILES is sequence + - TBI_IMAGE_PROFILES is not string + - TBI_IMAGE_PROFILES | length > 0 + fail_msg: >- + One or more role inputs failed validation. Required: TBI_TALOS_VERSION + matches ^v[0-9]+\.[0-9]+\.[0-9]+; TBI_BUILD_BACKEND in [factory, imager]; + TBI_OUTPUT_DIR absolute and free of '..'; TBI_IMAGE_PROFILES a non-empty + list. + quiet: true + +- name: Validate Image Profiles + ansible.builtin.assert: + that: + - item.name is defined + - item.name is string + - item.name | length > 0 + - item.name is match('^[a-z0-9][a-z0-9_-]*$') + - (item.arch | default(TBI_DEFAULT_ARCH)) in tbi_valid_arches + - item.formats is defined + - item.formats is sequence + - item.formats is not string + - item.formats | length > 0 + - item.formats | difference(tbi_valid_formats) | length == 0 + # SecureBoot is reserved but unimplemented in v1: refuse it rather than + # ship a half-wired path. + - (item.secureboot | default(false)) | bool == false + # Imager extension digests must be explicit and pinned (never guessed). + - (TBI_BUILD_BACKEND != 'imager') or + (item.extension_images | default([]) | length == 0) or + (item.extension_images | select('match', '.+' ~ tbi_digest_ref_regex) | list | length == + item.extension_images | length) + fail_msg: >- + TBI_IMAGE_PROFILES entry is invalid. Each profile needs: name matching + ^[a-z0-9][a-z0-9_-]*$; arch in {{ tbi_valid_arches }}; a non-empty formats + list that is a subset of {{ tbi_valid_formats }}; secureboot must be false + (unimplemented in v1); and on the imager backend every extension_images + entry must be a pinned digest ref (…@sha256:<64 hex>). Offending entry: + {{ item }} + quiet: true + loop: "{{ TBI_IMAGE_PROFILES }}" + loop_control: + label: "{{ item.name | default(item) }}" + +- name: Initialize Manifest Accumulator + ansible.builtin.set_fact: + tbi_manifest_profiles: [] + +- name: Run Preflight Checks + ansible.builtin.include_tasks: + file: preflight.yml + +- name: Build Each Image Profile + ansible.builtin.include_tasks: + file: build_profile.yml + loop: "{{ TBI_IMAGE_PROFILES }}" + loop_control: + loop_var: tbi_profile + label: "{{ tbi_profile.name }}" + +- name: Write Manifest and Print Hints + ansible.builtin.include_tasks: + file: output.yml + +- name: Write Boot Media to USB (self-guarded, default OFF) + ansible.builtin.include_tasks: + file: usb_write.yml diff --git a/collections/nixknight/kubernetes/roles/talos-baremetal-image/tasks/output.yml b/collections/nixknight/kubernetes/roles/talos-baremetal-image/tasks/output.yml new file mode 100644 index 0000000..9722c2e --- /dev/null +++ b/collections/nixknight/kubernetes/roles/talos-baremetal-image/tasks/output.yml @@ -0,0 +1,37 @@ +--- +# tasks file for talos-baremetal-image +# Write the build manifest (one entry per profile: schematic id, every asset +# path + recorded sha256 + size, and the matching metal-installer image ref) +# and print copy-pasteable dd hints for each produced ISO. The manifest is +# written on the same host as the assets so the recorded paths are accurate. + +- name: Compose Build Manifest + ansible.builtin.set_fact: + tbi_manifest: + talos_version: "{{ TBI_TALOS_VERSION }}" + backend: "{{ TBI_BUILD_BACKEND }}" + factory_api: "{{ TBI_FACTORY_API }}" + output_dir: "{{ TBI_OUTPUT_DIR }}" + generated_by: "nixknight.kubernetes.talos-baremetal-image" + profiles: "{{ tbi_manifest_profiles }}" + +- name: Write Build Manifest + ansible.builtin.copy: + content: "{{ tbi_manifest | to_nice_json(indent=2) }}\n" + dest: "{{ TBI_OUTPUT_DIR }}/manifest.json" + mode: "{{ TBI_MANIFEST_MODE }}" + when: TBI_CREATE_MANIFEST | bool + +- name: Print USB Write Hints per ISO + ansible.builtin.debug: + msg: >- + Profile '{{ item.0.name }}' ISO: {{ item.1.path }} (sha256 + {{ item.1.sha256 }}). To write it to a USB stick, set TBI_USB_WRITE_ENABLE, + TBI_USB_CONFIRM, TBI_USB_PROFILE='{{ item.0.name }}', and TBI_USB_DEVICE to + a /dev/disk/by-id/... path, then re-run. Manual equivalent (verify the + target device first): dd if={{ item.1.path }} of= bs=4M + conv=fsync oflag=direct status=progress + loop: "{{ tbi_manifest_profiles | subelements('assets') }}" + loop_control: + label: "{{ item.1.name }}" + when: item.1.name is match('.*\.iso$') diff --git a/collections/nixknight/kubernetes/roles/talos-baremetal-image/tasks/preflight.yml b/collections/nixknight/kubernetes/roles/talos-baremetal-image/tasks/preflight.yml new file mode 100644 index 0000000..8378c9d --- /dev/null +++ b/collections/nixknight/kubernetes/roles/talos-baremetal-image/tasks/preflight.yml @@ -0,0 +1,51 @@ +--- +# tasks file for talos-baremetal-image +# Backend-specific environment checks plus output-dir creation. Read-only checks +# set changed_when: false; the imager docker check is skipped in check mode. + +- name: Check Talos Image Factory Reachability + ansible.builtin.uri: + url: "{{ TBI_FACTORY_API }}" + method: GET + status_code: + - 200 + - 301 + - 302 + - 308 + return_content: false + timeout: 30 + register: tbi_factory_reachable + changed_when: false + retries: "{{ TBI_API_RETRIES }}" + delay: "{{ TBI_API_DELAY }}" + until: tbi_factory_reachable is succeeded + when: TBI_BUILD_BACKEND == 'factory' + +- name: Check Docker Is Available for the Imager Backend + ansible.builtin.command: + argv: + - "docker" + - "--version" + register: tbi_docker_version + changed_when: false + when: + - TBI_BUILD_BACKEND == 'imager' + - not ansible_check_mode + +- name: Assert Docker Is Usable for the Imager Backend + ansible.builtin.assert: + that: + - tbi_docker_version.rc == 0 + fail_msg: >- + The imager backend requires a working Docker CLI on this host, but + 'docker --version' did not succeed. + quiet: true + when: + - TBI_BUILD_BACKEND == 'imager' + - not ansible_check_mode + +- name: Create Output Directory + ansible.builtin.file: + path: "{{ TBI_OUTPUT_DIR }}" + state: directory + mode: "{{ TBI_DIR_MODE }}" diff --git a/collections/nixknight/kubernetes/roles/talos-baremetal-image/tasks/schematic.yml b/collections/nixknight/kubernetes/roles/talos-baremetal-image/tasks/schematic.yml new file mode 100644 index 0000000..4edf860 --- /dev/null +++ b/collections/nixknight/kubernetes/roles/talos-baremetal-image/tasks/schematic.yml @@ -0,0 +1,49 @@ +--- +# tasks file for talos-baremetal-image +# Render the per-profile schematic and POST it to the Factory /schematics +# endpoint. Factory validates the schematic (including extension names) and +# returns its id; a non-200 response is treated as fatal extension/schematic +# validation failure. The rendered schematic is kept (mode 0600) for audit. + +- name: Render Factory Schematic + ansible.builtin.template: + src: "schematic.yaml.j2" + dest: "{{ tbi_p_outdir }}/{{ tbi_schematic_basename }}" + mode: "{{ TBI_SCHEMATIC_MODE }}" + +- name: Submit Schematic to the Factory + ansible.builtin.uri: + url: "{{ tbi_factory_schematics_url }}" + method: POST + src: "{{ tbi_p_outdir }}/{{ tbi_schematic_basename }}" + remote_src: true + headers: + Content-Type: "application/x-yaml" + body_format: raw + return_content: true + status_code: [200, 201] + timeout: 60 + register: tbi_schematic_response + changed_when: false + retries: "{{ TBI_API_RETRIES }}" + delay: "{{ TBI_API_DELAY }}" + until: tbi_schematic_response is succeeded + +- name: Assert the Factory Accepted the Schematic + ansible.builtin.assert: + that: + - tbi_schematic_response.status in [200, 201] + - tbi_schematic_response.json is defined + - tbi_schematic_response.json.id is defined + - tbi_schematic_response.json.id | length > 0 + fail_msg: >- + The Talos Image Factory rejected the schematic for profile + '{{ tbi_p_name }}'. This usually means an unknown or invalid system + extension or schematic field. Factory status: + {{ tbi_schematic_response.status | default('unknown') }}. + quiet: true + when: TBI_VALIDATE_EXTENSIONS | bool + +- name: Set Resolved Schematic ID + ansible.builtin.set_fact: + tbi_p_schematic_id: "{{ tbi_schematic_response.json.id }}" diff --git a/collections/nixknight/kubernetes/roles/talos-baremetal-image/tasks/usb_write.yml b/collections/nixknight/kubernetes/roles/talos-baremetal-image/tasks/usb_write.yml new file mode 100644 index 0000000..a432859 --- /dev/null +++ b/collections/nixknight/kubernetes/roles/talos-baremetal-image/tasks/usb_write.yml @@ -0,0 +1,244 @@ +--- +# tasks file for talos-baremetal-image +# DESTRUCTIVE, default OFF. Every task below is gated on tbi_usb_enabled, which +# is only true when TBI_USB_WRITE_ENABLE and TBI_USB_CONFIRM are both true and +# TBI_USB_DEVICE looks like a /dev path. The dd itself additionally never runs +# in check mode (command/shell auto-skip) and runs as root. Guards: prefer a +# stable by-id path, resolve symlinks to the WHOLE disk, reject partitions, +# reject removable==0, reject any holders/slaves or dm/md/LUKS relationships, +# reject mounted children, and reject the disk backing / (compared by MAJ:MIN). + +- name: Determine Whether USB Writing Is Enabled + ansible.builtin.set_fact: + tbi_usb_enabled: >- + {{ + (TBI_USB_WRITE_ENABLE | bool) + and (TBI_USB_CONFIRM | bool) + and (TBI_USB_DEVICE is string) + and (TBI_USB_DEVICE is match('^/dev/')) + }} + +- name: Assert a Stable by-id Device Unless Overridden + ansible.builtin.assert: + that: + - (TBI_USB_DEVICE is match('^/dev/disk/by-id/')) or (TBI_USB_ALLOW_UNSTABLE_DEVICE | bool) + fail_msg: >- + TBI_USB_DEVICE ('{{ TBI_USB_DEVICE }}') is not a /dev/disk/by-id/... path. + by-id paths are stable across reboots and reduce the chance of writing to + the wrong disk. Set TBI_USB_ALLOW_UNSTABLE_DEVICE=true to override (not + recommended). + quiet: true + when: tbi_usb_enabled | bool + +- name: Resolve Device to Its Real Node + ansible.builtin.command: + argv: + - "readlink" + - "-f" + - "{{ TBI_USB_DEVICE }}" + register: tbi_usb_realdev_raw + changed_when: false + when: tbi_usb_enabled | bool + +- name: Set Resolved Device Fact + ansible.builtin.set_fact: + tbi_usb_realdev: "{{ tbi_usb_realdev_raw.stdout | trim }}" + when: tbi_usb_enabled | bool + +- name: Stat the Resolved Device + ansible.builtin.stat: + path: "{{ tbi_usb_realdev }}" + register: tbi_usb_devstat + when: tbi_usb_enabled | bool + +- name: Assert the Resolved Path Is a Block Device + ansible.builtin.assert: + that: + - tbi_usb_devstat.stat.exists + - tbi_usb_devstat.stat.isblk is defined + - tbi_usb_devstat.stat.isblk + fail_msg: >- + Resolved device '{{ tbi_usb_realdev }}' does not exist or is not a block + device. + quiet: true + when: tbi_usb_enabled | bool + +- name: Inspect the Resolved Device Topology + ansible.builtin.command: + argv: + - "lsblk" + - "-nro" + - "TYPE,RM,MOUNTPOINT" + - "{{ tbi_usb_realdev }}" + register: tbi_usb_lsblk + changed_when: false + when: tbi_usb_enabled | bool + +- name: Read Whole-Disk Attributes + ansible.builtin.command: + argv: + - "lsblk" + - "-dnro" + - "TYPE,RM,SIZE,MAJ:MIN" + - "{{ tbi_usb_realdev }}" + register: tbi_usb_disk + changed_when: false + when: tbi_usb_enabled | bool + +- name: Read Device Model + ansible.builtin.command: + argv: + - "lsblk" + - "-dnro" + - "MODEL" + - "{{ tbi_usb_realdev }}" + register: tbi_usb_model + changed_when: false + when: tbi_usb_enabled | bool + +- name: Read Device Serial + ansible.builtin.command: + argv: + - "lsblk" + - "-dnro" + - "SERIAL" + - "{{ tbi_usb_realdev }}" + register: tbi_usb_serial + changed_when: false + when: tbi_usb_enabled | bool + +- name: Identify the Root Source Device + ansible.builtin.command: + argv: + - "findmnt" + - "-nro" + - "SOURCE" + - "/" + register: tbi_usb_root_src + changed_when: false + when: tbi_usb_enabled | bool + +- name: Identify the Root Whole-Disk MAJ:MIN + ansible.builtin.shell: + cmd: >- + set -euo pipefail; + root_src="$(findmnt -nro SOURCE /)"; + pk="$(lsblk -nso PKNAME "${root_src}" | awk 'NF{print; exit}')"; + if [ -z "${pk:-}" ]; then + lsblk -dnro 'MAJ:MIN' "${root_src}"; + else + lsblk -dnro 'MAJ:MIN' "/dev/${pk}"; + fi + register: tbi_usb_root_majmin + changed_when: false + when: tbi_usb_enabled | bool + +- name: Assert the Target Is a Whole Removable Disk With No Risky Relationships + ansible.builtin.assert: + that: + # Whole disk, not a partition or mapped device. + - (tbi_usb_disk.stdout.split() | first) == 'disk' + # Removable flag on the whole disk. + - (tbi_usb_disk.stdout.split()[1]) == '1' + # Only disk/part node types in the tree: no crypt/lvm/raid/dm holders. + - (tbi_usb_lsblk.stdout_lines | map('trim') | map('regex_replace', '\s+.*$', '') + | select('!=', '') | list | unique | difference(['disk', 'part'])) | length == 0 + # Nothing mounted anywhere on the device or its children. + - (tbi_usb_lsblk.stdout_lines | map('regex_replace', '^\S+\s+\S+\s*', '') + | map('trim') | select('!=', '') | list) | length == 0 + # Target whole-disk MAJ:MIN differs from the root disk's MAJ:MIN. + - (tbi_usb_disk.stdout.split() | last) != (tbi_usb_root_majmin.stdout | trim) + fail_msg: >- + Refusing to write: target '{{ tbi_usb_realdev }}' failed safety checks. + It must be a WHOLE removable disk (TYPE=disk, RM=1) with no + crypt/lvm/raid/dm holders, nothing mounted, and a MAJ:MIN different from + the disk backing / (root MAJ:MIN {{ tbi_usb_root_majmin.stdout | default('?') | trim }}, + target {{ tbi_usb_disk.stdout.split() | last }}). + quiet: true + when: tbi_usb_enabled | bool + +- name: Select the Source Profile + ansible.builtin.set_fact: + tbi_usb_src_profile: >- + {{ + (TBI_IMAGE_PROFILES | selectattr('name', 'equalto', TBI_USB_PROFILE) | list | first) + if (TBI_IMAGE_PROFILES | selectattr('name', 'equalto', TBI_USB_PROFILE) | list | length > 0) + else {} + }} + when: tbi_usb_enabled | bool + +- name: Assert the Source Profile Exists + ansible.builtin.assert: + that: + - tbi_usb_src_profile | length > 0 + - TBI_USB_SOURCE_FORMAT in ['iso', 'raw'] + fail_msg: >- + TBI_USB_PROFILE ('{{ TBI_USB_PROFILE }}') must name one of TBI_IMAGE_PROFILES + and TBI_USB_SOURCE_FORMAT must be 'iso' or 'raw'. + quiet: true + when: tbi_usb_enabled | bool + +- name: Compute the Source Architecture and Suffix + ansible.builtin.set_fact: + tbi_usb_src_arch: "{{ tbi_usb_src_profile.arch | default(TBI_DEFAULT_ARCH) }}" + tbi_usb_src_suffix: "{{ 'iso' if TBI_USB_SOURCE_FORMAT == 'iso' else 'raw.xz' }}" + when: tbi_usb_enabled | bool + +- name: Compute the Source Asset Path + ansible.builtin.set_fact: + tbi_usb_src_path: "{{ TBI_OUTPUT_DIR }}/{{ TBI_USB_PROFILE }}/metal-{{ tbi_usb_src_arch }}.{{ tbi_usb_src_suffix }}" + when: tbi_usb_enabled | bool + +- name: Stat the Source Asset + ansible.builtin.stat: + path: "{{ tbi_usb_src_path }}" + register: tbi_usb_srcstat + when: tbi_usb_enabled | bool + +- name: Assert the Source Asset Exists + ansible.builtin.assert: + that: + - tbi_usb_srcstat.stat.exists + - tbi_usb_srcstat.stat.isreg + fail_msg: >- + Source asset '{{ tbi_usb_src_path }}' for profile '{{ TBI_USB_PROFILE }}' + ({{ TBI_USB_SOURCE_FORMAT }}) was not found. Build it first. + quiet: true + when: tbi_usb_enabled | bool + +- name: Show the Target and Source Before Writing + ansible.builtin.debug: + msg: >- + About to OVERWRITE {{ tbi_usb_realdev }} (model + '{{ tbi_usb_model.stdout | default('') | trim }}', serial + '{{ tbi_usb_serial.stdout | default('') | trim }}', size + {{ tbi_usb_disk.stdout.split()[2] | default('?') }}) with + {{ tbi_usb_src_path }} ({{ TBI_USB_SOURCE_FORMAT }}). All existing data on + the device will be destroyed. + when: tbi_usb_enabled | bool + +- name: Write ISO to USB + ansible.builtin.shell: + cmd: >- + set -euo pipefail; + dd if='{{ tbi_usb_src_path }}' of='{{ tbi_usb_realdev }}' + bs=4M conv=fsync oflag=direct status=progress + become: true + register: tbi_usb_dd_iso + changed_when: tbi_usb_dd_iso.rc == 0 + when: + - tbi_usb_enabled | bool + - TBI_USB_SOURCE_FORMAT == 'iso' + +- name: Write Decompressed Raw Image to USB + ansible.builtin.shell: + cmd: >- + set -euo pipefail; + xz -dc '{{ tbi_usb_src_path }}' + | dd of='{{ tbi_usb_realdev }}' bs=4M conv=fsync oflag=direct status=progress + become: true + register: tbi_usb_dd_raw + changed_when: tbi_usb_dd_raw.rc == 0 + when: + - tbi_usb_enabled | bool + - TBI_USB_SOURCE_FORMAT == 'raw' diff --git a/collections/nixknight/kubernetes/roles/talos-baremetal-image/tasks/verify.yml b/collections/nixknight/kubernetes/roles/talos-baremetal-image/tasks/verify.yml new file mode 100644 index 0000000..8ea0374 --- /dev/null +++ b/collections/nixknight/kubernetes/roles/talos-baremetal-image/tasks/verify.yml @@ -0,0 +1,81 @@ +--- +# tasks file for talos-baremetal-image +# Record every produced asset (path, size, sha256) for the manifest. The sha256 +# is RECORDED, not an authenticity proof. Real verification (assert-match) runs +# only when TBI_VERIFY_CHECKSUM is true AND the profile supplies an expected +# hash for that asset basename via `expected_checksums`. + +- name: Find Produced Assets + ansible.builtin.find: + paths: "{{ tbi_p_outdir }}" + file_type: file + recurse: false + excludes: + - "{{ tbi_schematic_basename }}" + get_checksum: true + checksum_algorithm: sha256 + register: tbi_p_found + +- name: Assert at Least One Asset Was Produced + ansible.builtin.assert: + that: + - tbi_p_found.files | length > 0 + fail_msg: >- + No boot assets were produced for profile '{{ tbi_p_name }}' in + {{ tbi_p_outdir }}. Expected one or more of the requested formats + ({{ tbi_p_formats | join(', ') }}). + quiet: true + when: not ansible_check_mode + +- name: Verify Expected Checksums When Supplied + ansible.builtin.assert: + that: + - item.checksum == tbi_profile.expected_checksums[item.path | basename] + fail_msg: >- + Checksum mismatch for {{ item.path }} in profile '{{ tbi_p_name }}': + recorded {{ item.checksum }} does not match the expected hash supplied in + expected_checksums. + quiet: true + loop: "{{ tbi_p_found.files }}" + loop_control: + label: "{{ item.path | basename }}" + when: + - TBI_VERIFY_CHECKSUM | bool + - tbi_profile.expected_checksums is defined + - (item.path | basename) in (tbi_profile.expected_checksums | default({})) + +- name: Initialize Per-Profile Asset List + ansible.builtin.set_fact: + tbi_p_assets: [] + +- name: Collect Asset Records + ansible.builtin.set_fact: + tbi_p_assets: >- + {{ + tbi_p_assets + [{ + 'path': item.path, + 'name': item.path | basename, + 'size': item.size, + 'sha256': item.checksum + }] + }} + loop: "{{ tbi_p_found.files }}" + loop_control: + label: "{{ item.path | basename }}" + +- name: Append Profile Entry to the Manifest Accumulator + ansible.builtin.set_fact: + tbi_manifest_profiles: >- + {{ + tbi_manifest_profiles + [{ + 'name': tbi_p_name, + 'role': tbi_profile.role | default(''), + 'arch': tbi_p_arch, + 'talos_version': tbi_p_version, + 'backend': TBI_BUILD_BACKEND, + 'schematic_id': tbi_p_schematic_id, + 'installer_image': (tbi_installer_image if (tbi_p_schematic_id | length > 0) else ''), + 'output_dir': tbi_p_outdir, + 'assets': tbi_p_assets + }] + }} diff --git a/collections/nixknight/kubernetes/roles/talos-baremetal-image/templates/boot.ipxe.j2 b/collections/nixknight/kubernetes/roles/talos-baremetal-image/templates/boot.ipxe.j2 new file mode 100644 index 0000000..cd6bc47 --- /dev/null +++ b/collections/nixknight/kubernetes/roles/talos-baremetal-image/templates/boot.ipxe.j2 @@ -0,0 +1,13 @@ +#!ipxe +# boot.ipxe rendered by talos-baremetal-image (imager backend, profile {{ tbi_profile.name }}). +# Talos does NOT embed extra kernel args into the PXE kernel/initramfs; they are +# placed on the kernel line below. Serve this file together with the imager +# kernel/initramfs output. Override TBI_IPXE_BASE_URL to point at the HTTP(S) +# host that serves the kernel/initramfs (leave empty to load them relative to +# the directory this script is served from). +{% set base = (TBI_IPXE_BASE_URL | default('')) %} +{% set prefix = (base ~ '/') if (base | length > 0) else '' %} +{% set extra = (tbi_profile.kernel_args | default([]) | join(' ')) %} +kernel {{ prefix }}{{ tbi_imager_kernel_basename }} talos.platform=metal console=tty0 console=ttyS0 init_on_alloc=1 slab_nomerge pti=on{% if extra | length > 0 %} {{ extra }}{% endif %} initrd={{ tbi_imager_initramfs_basename }} +initrd {{ prefix }}{{ tbi_imager_initramfs_basename }} +boot diff --git a/collections/nixknight/kubernetes/roles/talos-baremetal-image/templates/schematic.yaml.j2 b/collections/nixknight/kubernetes/roles/talos-baremetal-image/templates/schematic.yaml.j2 new file mode 100644 index 0000000..86e8c71 --- /dev/null +++ b/collections/nixknight/kubernetes/roles/talos-baremetal-image/templates/schematic.yaml.j2 @@ -0,0 +1,30 @@ +--- +# schematic template file for talos-baremetal-image +# Rendered per profile and POSTed to the Talos Image Factory /schematics +# endpoint (Content-Type: application/x-yaml). Factory returns a schematic id. +customization: +{% if tbi_profile.extensions | default([]) | length > 0 %} + systemExtensions: + officialExtensions: +{% for ext in tbi_profile.extensions %} + - {{ ext }} +{% endfor %} +{% endif %} +{% if tbi_profile.kernel_args | default([]) | length > 0 %} + extraKernelArgs: +{% for arg in tbi_profile.kernel_args %} + - {{ arg }} +{% endfor %} +{% endif %} +{% if tbi_profile.meta | default([]) | length > 0 %} + meta: +{% for m in tbi_profile.meta %} + - key: {{ m.key }} + value: {{ m.value }} +{% endfor %} +{% endif %} +{% if tbi_profile.overlay is defined %} + overlay: + name: {{ tbi_profile.overlay.name }} + image: {{ tbi_profile.overlay.image }} +{% endif %} diff --git a/collections/nixknight/kubernetes/roles/talos-baremetal-image/vars/main.yml b/collections/nixknight/kubernetes/roles/talos-baremetal-image/vars/main.yml new file mode 100644 index 0000000..9811492 --- /dev/null +++ b/collections/nixknight/kubernetes/roles/talos-baremetal-image/vars/main.yml @@ -0,0 +1,44 @@ +--- +# vars file for talos-baremetal-image +# +# Lowercase internal constants. URL and filename templates live here so the +# task files stay readable. These reference per-profile facts (tbi_p_*) that are +# set in build_profile.yml; Ansible templates them lazily on access, so they are +# only resolved inside the per-profile loop where those facts exist. + +# Asset basenames (factory + imager share the ISO / raw naming). +tbi_iso_basename: "metal-{{ tbi_p_arch }}.iso" +tbi_raw_basename: "metal-{{ tbi_p_arch }}.raw.xz" + +# Factory PXE = the Factory-hosted iPXE script (it references Factory kernel / +# initramfs URLs); the role does not guess kernel/initramfs filenames. +tbi_factory_ipxe_basename: "ipxe-{{ tbi_p_arch }}.ipxe" + +# Imager PXE = locally produced kernel + initramfs + a role-generated iPXE file. +tbi_imager_kernel_basename: "kernel-{{ tbi_p_arch }}" +tbi_imager_initramfs_basename: "initramfs-{{ tbi_p_arch }}.xz" +tbi_imager_ipxe_basename: "boot-{{ tbi_p_arch }}.ipxe" + +# Rendered Factory schematic kept per profile for auditability (mode 0600). +tbi_schematic_basename: "schematic-{{ tbi_p_arch }}.yaml" + +# Factory URLs. +tbi_factory_schematics_url: "{{ TBI_FACTORY_API }}/schematics" +tbi_factory_iso_url: "{{ TBI_FACTORY_API }}/image/{{ tbi_p_schematic_id }}/{{ tbi_p_version }}/metal-{{ tbi_p_arch }}.iso" +tbi_factory_raw_url: "{{ TBI_FACTORY_API }}/image/{{ tbi_p_schematic_id }}/{{ tbi_p_version }}/metal-{{ tbi_p_arch }}.raw.xz" +tbi_factory_pxe_url: "{{ TBI_FACTORY_API }}/pxe/{{ tbi_p_schematic_id }}/{{ tbi_p_version }}/metal-{{ tbi_p_arch }}" + +# Matching first-boot installer image (boot media only boots the node; the +# install/upgrade uses this image). Recorded per profile in the manifest. +tbi_installer_image: "factory.talos.dev/metal-installer/{{ tbi_p_schematic_id }}:{{ tbi_p_version }}" + +# Validation helpers. +tbi_valid_arches: + - "amd64" + - "arm64" +tbi_valid_formats: + - "iso" + - "pxe" + - "raw" +# Pinned digest ref pattern for imager extension_images (registry/path@sha256:<64 hex>). +tbi_digest_ref_regex: "@sha256:[0-9a-f]{64}$" From 0362ec9a598c6fc39a1b779c5b76cfeca4798cf6 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 8 Jul 2026 06:13:31 +0000 Subject: [PATCH 2/2] chore: bump kubernetes collection version to 0.1.1 --- collections/nixknight/kubernetes/galaxy.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/collections/nixknight/kubernetes/galaxy.yml b/collections/nixknight/kubernetes/galaxy.yml index 2daec14..37c1517 100644 --- a/collections/nixknight/kubernetes/galaxy.yml +++ b/collections/nixknight/kubernetes/galaxy.yml @@ -1,7 +1,7 @@ --- namespace: "nixknight" name: "kubernetes" -version: "0.1.0" +version: "0.1.1" readme: "README.md" authors: - "Saad Ali "