diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 00000000..bcf43f6c --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,3 @@ +{ + "includeCoAuthoredBy": false +} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ed37905c..f698b284 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -41,11 +41,13 @@ jobs: name: Build runs-on: ubuntu-latest strategy: + fail-fast: false matrix: platform: [amd64] action: - archive2disk - cexec + - cidataio - grub2disk - image2disk - kexec @@ -54,6 +56,9 @@ jobs: - rootio - slurp - syslinux + - taloscmdline + - talosmeta + - ubootenv - writefile steps: - uses: actions/checkout@v7 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index fbe4bc5e..887431d7 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -5,18 +5,17 @@ on: - "main" workflow_dispatch: {} -env: - REGISTRY: quay.io - jobs: build: name: Release runs-on: ubuntu-latest strategy: + fail-fast: false matrix: action: - archive2disk - cexec + - cidataio - grub2disk - image2disk - kexec @@ -25,25 +24,45 @@ jobs: - rootio - slurp - syslinux + - taloscmdline + - talosmeta + - ubootenv - writefile + permissions: + contents: read + packages: write + id-token: write steps: - uses: actions/checkout@v7 + - name: Set Registry + id: registry + run: | + if [ -n "${{ secrets.QUAY_USERNAME }}" ] && [ -n "${{ secrets.QUAY_PASSWORD }}" ]; then + echo "registry=quay.io" >> $GITHUB_OUTPUT + else + echo "registry=ghcr.io" >> $GITHUB_OUTPUT + fi + - name: Set up QEMU uses: docker/setup-qemu-action@v4 - name: Set up Docker Buildx uses: docker/setup-buildx-action@v4 - - name: Login to Quay.io + - name: Login to Registry uses: docker/login-action@v4 with: - registry: ${{ env.REGISTRY }} - username: ${{ secrets.QUAY_USERNAME }} - password: ${{ secrets.QUAY_PASSWORD }} + registry: ${{ steps.registry.outputs.registry }} + username: ${{ secrets.QUAY_USERNAME || github.actor }} + password: ${{ secrets.QUAY_PASSWORD || github.token }} - name: Prepare Release run: make prepare-release + env: + CONTAINER_REPOSITORY: ${{ steps.registry.outputs.registry }}/${{ github.repository }} - name: Run Release run: make release-${{ matrix.action }} -j $(nproc) + env: + CONTAINER_REPOSITORY: ${{ steps.registry.outputs.registry }}/${{ github.repository }} diff --git a/Makefile b/Makefile index dc906c25..c138230c 100644 --- a/Makefile +++ b/Makefile @@ -9,7 +9,10 @@ SHELL := bash .SECONDEXPANSION: # Define the list of actions that can be built. -ACTIONS := archive2disk cexec grub2disk image2disk kexec oci2disk qemuimg2disk rootio slurp syslinux writefile +ACTIONS := archive2disk cexec cidataio grub2disk image2disk kexec oci2disk qemuimg2disk rootio slurp syslinux taloscmdline talosmeta ubootenv writefile + +# Platform for locally built images; defaults to the host architecture. +BUILD_PLATFORM ?= linux/$(shell go env GOARCH) # Define the commit for tagging images. GIT_COMMIT := $(shell git rev-parse HEAD) @@ -28,7 +31,7 @@ help: ## Print this help .PHONY: $(ACTIONS) $(ACTIONS): ## Build a specific action image. - docker buildx build --platform linux/amd64 --load -t $@:latest -f ./$@/Dockerfile . + docker buildx build --platform $(BUILD_PLATFORM) --load -t $@:latest -f ./$@/Dockerfile . .PHONY: images images: ## Build all action images. diff --git a/README.md b/README.md index b8069c45..1527c6bf 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,7 @@ This repository is a suite of reusable Tinkerbell Actions that are used to compo | --- | --- | | [archive2disk](/archive2disk/) | Write archives to a block device | | [cexec](/cexec/) | chroot and execute binaries | +| [cidataio](/cidataio/) | Create a cloud-init `cidata` partition on a block device | | [grub2disk](/grub2disk/) | Write grub configs to a block device | | [image2disk](/image2disk/) | Write images to a block device | | [kexec](/kexec/) | kexec to a Linux Kernel | @@ -14,6 +15,9 @@ This repository is a suite of reusable Tinkerbell Actions that are used to compo | [rootio](/rootio/) | Manage disks (partition, format etc) | [slurp](/slurp/) | Stream a block device to a remote server | | [syslinux](/syslinux/) | Install the syslinux bootloader to a block device | +| [taloscmdline](/taloscmdline/) | Set kernel arguments in Talos Linux unified kernel images | +| [talosmeta](/talosmeta/) | Write Talos Linux network configuration to the META partition | +| [ubootenv](/ubootenv/) | Update U-Boot environment variables on a block device | | [writefile](/writefile/) | Write a file to a file system on a block device | ## Releases diff --git a/cidataio/Dockerfile b/cidataio/Dockerfile new file mode 100644 index 00000000..f105ca33 --- /dev/null +++ b/cidataio/Dockerfile @@ -0,0 +1,13 @@ +FROM golang:1.26-alpine AS cidataio +COPY . /src +WORKDIR /src/cidataio +RUN --mount=type=cache,sharing=locked,id=gomod,target=/go/pkg/mod/cache \ + --mount=type=cache,sharing=locked,id=goroot,target=/root/.cache/go-build \ + CGO_ENABLED=0 GOOS=linux go build -ldflags "-s -w" -o cidataio + +# cidataio shells out to sgdisk, partprobe, mkfs.vfat and mount, so it needs a +# real userland rather than scratch. +FROM alpine:3.22 +RUN apk add --no-cache sgdisk dosfstools util-linux +COPY --from=cidataio /src/cidataio/cidataio /usr/bin/cidataio +ENTRYPOINT ["/usr/bin/cidataio"] diff --git a/cidataio/README.md b/cidataio/README.md new file mode 100644 index 00000000..bbc77117 --- /dev/null +++ b/cidataio/README.md @@ -0,0 +1,47 @@ +# Tinkerbell `cidataio` Action + +This action creates a `cidata` partition and writes cloud-init data to it. + +## Description + +The `cidataio` action is a Go-based tool designed to run in a Tinkerbell workflow. It prepares a disk that has just been flashed with an OS image (e.g., Talos "nocloud") by adding a new partition for cloud-init data. + +It performs the following steps: +1. Finds the target disk from the `DEST_DISK` environment variable. +2. Creates a new partition using all remaining free space on the disk. +3. Formats this new partition as `vfat` with the label `cidata`. +4. Mounts the partition. +5. Writes the contents of `USER_DATA`, `META_DATA`, and `VENDOR_DATA` environment variables to `user-data`, `meta-data`, and `vendor-data` files, respectively. +6. Unmounts the partition. + +## Environment Variables + +* **`DEST_DISK`** (Required): The block device to operate on (e.g., `/dev/sda`, `/dev/nvme0n1`). +* **`USER_DATA`** (Optional): The content for the `user-data` file. +* **`META_DATA`** (Optional): The content for the `meta-data` file. +* **`NETWORK_CONFIG`** (Optional): The content for the `network-config` file. + +## Example Workflow YAML + +This action is typically run immediately after `image2disk`. + +```yaml +actions: + - name: "stream talos nocloud image" + image: quay.io/tinkerbell/actions/image2disk:latest + timeout: 9600 + environment: + DEST_DISK: {{ index .Hardware.Disks 0 }} + IMG_URL: "..." + COMPRESSED: "true" + + - name: "create cidata partition and write files" + image: ghcr.io/tinkerbell/cidataio:latest + timeout: 120 + environment: + DEST_DISK: {{ index .Hardware.Disks 0 }} + USER_DATA: | + # user-data content here + META_DATA: | + local-hostname: my-node-1 +``` diff --git a/cidataio/main.go b/cidataio/main.go new file mode 100644 index 00000000..e33db249 --- /dev/null +++ b/cidataio/main.go @@ -0,0 +1,128 @@ +package main + +import ( + "fmt" + "log" + "os" + "os/exec" + "path/filepath" + "strings" + "time" +) + +const ( + configISOLabel = "cidata" + configNetworkConfigPath = "network-config" + configMetaDataPath = "meta-data" + configUserDataPath = "user-data" +) + +// run is a helper to run a shell command and log it. +func run(cmdStr string, args ...string) { + log.Printf("Running: %s %s", cmdStr, strings.Join(args, " ")) //nolint:gosec // G706: values come from the action environment supplied by the operator. + cmd := exec.Command(cmdStr, args...) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + if err := cmd.Run(); err != nil { + log.Fatalf("Command failed: %v", err) + } +} + +// runWithOutput runs a command and returns its stdout. +func runWithOutput(cmdStr string, args ...string) string { + log.Printf("Running (for output): %s %s", cmdStr, strings.Join(args, " ")) //nolint:gosec // G706: values come from the action environment supplied by the operator. + out, err := exec.Command(cmdStr, args...).CombinedOutput() + if err != nil { + log.Printf("Command failed: %s - %v", string(out), err) //nolint:gosec // G706: values come from the action environment supplied by the operator. + // Don't fatalf, as some commands (like ls) might fail gracefully + } + return strings.TrimSpace(string(out)) +} + +// findNewPartition compares a list of partitions before and after an operation. +func findNewPartition(before, after string) string { + beforeSet := make(map[string]bool) + for _, p := range strings.Split(before, "\n") { + if p != "" { + beforeSet[p] = true + } + } + + for _, p := range strings.Split(after, "\n") { + if p != "" && !beforeSet[p] { + return p // Found the new one + } + } + return "" +} + +// writeFileIfEnv writes content from an env var to a file. +func writeFileIfEnv(envVar, path string) { + content := os.Getenv(envVar) + if content == "" { + log.Printf("Env var %s not set, skipping file.", envVar) + return + } + + log.Printf("Writing content from %s to %s", envVar, path) + err := os.WriteFile(path, []byte(content), 0o644) //nolint:gosec // G306: cloud-init reads these files from the cidata volume. + if err != nil { + log.Fatalf("Failed to write file %s: %v", path, err) + } +} + +func main() { + log.Println("Starting cidataio action...") + + // 1. Get DEST_DISK + disk := os.Getenv("DEST_DISK") + if disk == "" { + log.Fatalf("DEST_DISK environment variable not set.") + } + + // 2. Force kernel to read partition table and get "before" list + run("partprobe", disk) + time.Sleep(1 * time.Second) // Give udev time to create devices + + // List all partitions for this disk using regex to match both: + // - Standard devices: /dev/sda1, /dev/sdb2, /dev/vda3 + // - NVMe/MMC devices: /dev/nvme0n1p1, /dev/mmcblk0p2 + globPattern := fmt.Sprintf("ls -1 %s* 2>/dev/null | grep -E '%sp?[0-9]+$' || true", disk, disk) + partsBefore := runWithOutput("sh", "-c", globPattern) + + // 3. Create the new partition + log.Printf("Creating new partition on %s", disk) //nolint:gosec // G706: values come from the action environment supplied by the operator. + run("sgdisk", "-n", "0:0:+2M", "-t", "0:0700", disk) + + // 4. Force kernel to re-read and find the new partition + run("partprobe", disk) + time.Sleep(2 * time.Second) // Give udev time to settle + partsAfter := runWithOutput("sh", "-c", globPattern) + + newPart := findNewPartition(partsBefore, partsAfter) + if newPart == "" { + log.Fatalf("Could not find a new partition. Before: [%s], After: [%s]", partsBefore, partsAfter) //nolint:gosec // G706: values come from the action environment supplied by the operator. + } + log.Printf("Found new partition: %s", newPart) + + // 5. Format the new partition + log.Printf("Formatting %s as vfat with label cidata", newPart) + run("mkfs.vfat", "-n", configISOLabel, newPart) + + // 6. Mount, Write, Unmount + mountPoint := "/mnt/cidata" + log.Printf("Mounting %s to %s", newPart, mountPoint) + run("mkdir", "-p", mountPoint) + run("mount", newPart, mountPoint) + + // 7. Write data from Env Vars + writeFileIfEnv("USER_DATA", filepath.Join(mountPoint, configUserDataPath)) + writeFileIfEnv("META_DATA", filepath.Join(mountPoint, configMetaDataPath)) + writeFileIfEnv("NETWORK_CONFIG", filepath.Join(mountPoint, configNetworkConfigPath)) + + // 8. Unmount + log.Printf("Unmounting %s", mountPoint) + run("umount", mountPoint) + + log.Println("cidataio action completed successfully.") +} diff --git a/docs/superpowers/specs/2026-09-07-taloscmdline-action-design.md b/docs/superpowers/specs/2026-09-07-taloscmdline-action-design.md new file mode 100644 index 00000000..e1e0409b --- /dev/null +++ b/docs/superpowers/specs/2026-09-07-taloscmdline-action-design.md @@ -0,0 +1,89 @@ +# taloscmdline action design + +Date: 2026-09-07 + +## Goal + +Add a Tinkerbell action that sets kernel command line arguments inside the +Talos Linux unified kernel image (UKI) on a freshly imaged disk, so that a node +boots with, for example, +`talos.config=http://:7080/2009-04-04/user-data` and fetches its +machine configuration from Tinkerbell's metadata service. + +Talos 1.10+ metal images boot through systemd-boot on UEFI. The kernel command +line is not a text file: it is the `.cmdline` PE section of +`EFI/Linux/Talos-.efi` on the FAT32 `EFI` partition. Talos re-reads +that section from the file for kexec reboots, so systemd-boot addons would be +lost on the first `talosctl reboot`; the section itself has to change. + +## Inputs + +| Variable | Required | Default | Purpose | +| --- | --- | --- | --- | +| `DEST_DISK` | yes | | Whole disk holding the Talos image, e.g. `/dev/sda`. | +| `KERNEL_ARGS` | yes | | Whitespace separated arguments to set. `key=value` replaces every existing `key=...`; a bare flag is appended once. | +| `UKI_PATTERN` | no | `EFI/Linux/Talos-*.efi` | Glob, relative to the EFI partition root, of the UKIs to edit. | +| `STRIP_SIGNATURE` | no | `false` | Allow editing an Authenticode-signed UKI; the signature is removed. | + +## Flow + +1. Read the GPT of `DEST_DISK`, find the partition named `EFI`, derive its + device node (`/dev/sda1`, `/dev/nvme0n1p1`, `/dev/disk/by-id/...-part1`). +2. Mount it as `vfat` on `/mountAction`; unmount on exit. +3. For each file matching the pattern: inspect the PE, merge `KERNEL_ARGS` + into the current command line, skip when nothing changes, otherwise rewrite + the UKI to a temporary file next to it and rename over the original. +4. Log the old and new command line for every UKI. + +## UKI rewrite + +Talos assembles a UKI by appending data sections (`.osrel`, `.cmdline`, +`.uname`, `.splash`, `.linux`, `.initrd`, profiles) to the systemd stub with +sequential virtual addresses aligned to `SectionAlignment` and raw sizes +aligned to `FileAlignment`. The rewrite keeps everything before `.cmdline` +byte-identical and re-lays out `.cmdline` and the sections after it: + +- `.cmdline` gets `VirtualSize = len(cmdline)` and an aligned raw size, as + Talos and ukify write it. +- Later sections keep their data and virtual sizes; virtual addresses and + file offsets are recomputed sequentially with the same alignment. +- `SizeOfImage`, `SizeOfInitializedData` and `SizeOfCode` are recomputed; + `CheckSum` is zeroed like Talos does. Anything after the last section (an + Authenticode certificate table) is dropped and the security data directory + cleared, only when `STRIP_SIGNATURE` is set; otherwise a signed UKI is an + error. + +Talos 1.10+ UKIs carry more than one `.cmdline`: one for the default boot and +one after the `.profile` sections for the "reset" boot entry. Every +`.cmdline` is rewritten, each merged independently, and the re-layout starts +at the first one. + +Safety checks before writing: PE32+ only, at least one `.cmdline`, sections +from the first `.cmdline` on are data-only (no code, execute or write flags, +not `.reloc`), file order matches table order. + +## Packages + +- `taloscmdline/main.go`: environment, partition lookup, mount, glob, loop. +- `taloscmdline/kargs`: command line merge. +- `taloscmdline/uki`: `Inspect` and `Rewrite` for PE files; `ukitest` + builds synthetic UKIs for tests. +- `taloscmdline/efipart`: GPT lookup by label and partition device naming. + +No new module dependencies; `debug/pe` and `go-diskfs` are enough. + +## Out of scope + +GRUB. Dual-boot images also carry `grub/grub.cfg` on the XFS `BOOT` +partition for BIOS boots; the action only changes the UEFI path and says so in +its README. + +## Testing + +Synthetic UKIs (stub-like code section, `.reloc`, then the Talos data +sections) cover reading, growing, shrinking and same-size rewrites, signed +files with and without stripping, refusal of unsafe layouts, atomic +replacement, the merge semantics and partition naming. Validation against the +real Talos v1.14.0 metal image was done through a loop device and QEMU/OVMF +boots: the kernel reported the new command line both when the section kept +its raw size and when a 500-byte argument forced the later sections to move. diff --git a/docs/superpowers/specs/2026-09-07-talosmeta-action-design.md b/docs/superpowers/specs/2026-09-07-talosmeta-action-design.md new file mode 100644 index 00000000..ccc25e6e --- /dev/null +++ b/docs/superpowers/specs/2026-09-07-talosmeta-action-design.md @@ -0,0 +1,132 @@ +# talosmeta action design + +Date: 2026-09-07 + +## Goal + +Add a Tinkerbell action that writes Talos Linux metal network configuration into +the META partition of a freshly imaged Talos disk. The action retrieves the +machine's Hardware data from the Tinkerbell metadata service the same way the +`rootio` action does, maps that data onto Talos' platform network configuration +document, and stores the YAML under META key `0xa` +(`MetalNetworkPlatformConfig`), which is what `talosctl meta write 0xa "$(cat +network.yaml)"` does on a running node. + +This lets a Talos node come up with static addressing, routes, hostname, DNS +and NTP before it has fetched its machine configuration, which matters when the +Tinkerbell DHCP server stops serving the machine after provisioning. + +## Inputs + +| Variable | Required | Default | Purpose | +| --- | --- | --- | --- | +| `DEST_DISK` | yes | | Block device holding the Talos image, e.g. `/dev/sda`. | +| `MIRROR_HOST` | unless `NETWORK_CONFIG` is set | | Tinkerbell host serving `/metadata`. | +| `METADATA_SERVICE_PORT` | no | `7080` | Port of the consolidated Tinkerbell HTTP server. | +| `NETWORK_CONFIG` | no | | Raw Talos network YAML. When set, the metadata service is not consulted and the document is written verbatim. | +| `LINK_NAMING` | no | `predictable` | `predictable` derives the name Talos' udev will assign from sysfs; `kernel` keeps the name HookOS uses. | + +## Data flow + +1. Read environment. +2. Unless `NETWORK_CONFIG` is set, `GET http://MIRROR_HOST:PORT/metadata` and + decode the Hardware spec shaped JSON: `interfaces[].dhcp` and + `metadata.instance`. +3. Map to a Talos `PlatformConfigSpec` shaped document (see mapping). +4. Open `DEST_DISK`, read its GPT, find the partition named `META`. +5. Load the existing Talos ADV (first 512 KiB of the partition, two redundant + copies) and the legacy syslinux ADV (last 1 KiB) with `siderolabs/go-adv`. +6. Set tag `0xa` to the YAML, keep every other tag, and flush both ADVs at the + same offsets Talos uses. `fsync` the device. +7. Log the YAML that was written. + +Writes go through the whole-disk device at the partition's byte offset, so the +action does not depend on partition device nodes existing inside the container. + +## Mapping + +Per Talos docs "Metal network configuration". Every emitted resource carries +`layer: platform`. + +For every interface with `dhcp.ip.address`: + +- Link: `{name, up: true}`. If `dhcp.vlan_id` is set, also emit a logical link + `.` with `kind: vlan`, `type: ether`, `parentName: `, + `vlan: {vlanID: , vlanProtocol: 802.1q}`; addresses and routes attach to + the VLAN link. +- Address: `{address: /, linkName, family: inet4|inet6, scope: + global, flags: permanent}`. Prefix length comes from `dhcp.ip.netmask` + (dotted quad or a bare prefix length). Missing netmask is an error. +- Default route when `dhcp.ip.gateway` is set: `{family, gateway, outLinkName, + table: main, priority: 1024 (inet4) or 2048 (inet6), scope: global, type: + unicast, protocol: static}`. +- One route per `dhcp.classless_static_routes` entry with `dst`. + +Once: + +- Hostname: `metadata.instance.hostname`, else the first `dhcp.hostname`. + Domain from the first `dhcp.domain_name`. +- Resolvers: de-duplicated union of `dhcp.name_servers` as `dnsServers`. +- Time servers: de-duplicated union of `dhcp.time_servers`. +- `externalIPs`: `metadata.instance.ips[].address` where `public` is true. +- `operators: []`. `metadata` is omitted because the metal platform replaces it. + +If no interface has a static address the action fails; there is nothing useful +to write and Talos' default DHCP behaviour should be left alone. + +## Link names + +Talos names interfaces with udev's predictable scheme (`enp1s0`, `eno1`, +`ens3`) unless booted with `net.ifnames=0`. HookOS uses kernel names (`eth0`). +The name written into META must be the one Talos will see. + +Resolution order: + +1. `dhcp.iface_name` when set. Operators can always pin the name here. +2. Match `dhcp.mac` against `/sys/class/net/*/address`. + - `LINK_NAMING=kernel`: use that interface name. + - `LINK_NAMING=predictable`: derive the udev name from sysfs: onboard + (`eno`), then PCI hotplug slot (`ens`), then PCI + path (`enps[f]`, prefixed with `P` when the + domain is non-zero). If the device is not PCI, fall back to the kernel + name and log a warning. +3. Otherwise fail. + +## Packages + +- `talosmeta/main.go`: environment parsing and orchestration, `run(logger) error` + like `writefile`. +- `talosmeta/hardware`: metadata client and JSON types. +- `talosmeta/talosnet`: Talos document types with YAML tags, `FromHardware`, + link naming (`Namer` interface with a sysfs implementation rooted at a + configurable path for tests). +- `talosmeta/meta`: META partition discovery and ADV read/write. + +New dependencies: `github.com/siderolabs/go-adv`, `go.yaml.in/yaml/v3`. +`go-adv` requires Go 1.26.3, so the module's `go` directive moves to that. + +## Errors + +Every failure is fatal and logged with context. Partial writes are avoided by +building the full ADV bytes in memory before writing. Missing META partition, +unreachable metadata service, metadata without interfaces, unresolvable link +name and unparseable addresses each produce a distinct message. + +## Testing + +- `talosnet`: table tests for netmask parsing, family detection, VLAN handling + and a golden YAML test against the structure in the Talos docs. Link naming + tests use a fake sysfs tree. +- `hardware`: `httptest` server returning a Hardware spec. +- `meta`: create a small GPT image with `go-diskfs`, add a `META` partition, + write tag `0xa`, read it back with `go-adv`, and re-run to prove other tags + survive. + +## Known gap + +The `/metadata` endpoint in tinkerbell's tootles round-trips the Hardware spec +through `data.HackInstance`, which only keeps storage fields, so interface data +is dropped today. Extending `HackInstance` with `interfaces` and +`metadata.instance.hostname`/`ips` (the JSON tags already line up) unblocks +this action. Until then the action fails with a message pointing at this, and +`NETWORK_CONFIG` can be used as a passthrough. diff --git a/go.mod b/go.mod index 5c21e8dd..9e4188a2 100644 --- a/go.mod +++ b/go.mod @@ -1,10 +1,9 @@ module github.com/tinkerbell/actions -go 1.25.0 +go 1.26.3 require ( github.com/cenkalti/backoff v2.2.1+incompatible - github.com/containerd/containerd v1.7.34 github.com/diskfs/go-diskfs v1.9.4 github.com/dustin/go-humanize v1.0.1 github.com/klauspost/compress v1.19.1 @@ -17,48 +16,21 @@ require ( github.com/spf13/cobra v1.10.2 github.com/ulikunitz/xz v0.5.16 golang.org/x/sys v0.47.0 - oras.land/oras-go v1.2.7 + oras.land/oras-go/v2 v2.6.2 ) require ( - github.com/AdaLogics/go-fuzz-headers v0.0.0-20230811130428-ced1acdcaa24 // indirect github.com/anchore/go-lzo v0.1.0 // indirect - github.com/beorn7/perks v1.0.1 // indirect - github.com/cespare/xxhash/v2 v2.3.0 // indirect - github.com/containerd/errdefs v0.3.0 // indirect - github.com/containerd/log v0.1.0 // indirect - github.com/containerd/platforms v0.2.1 // indirect - github.com/distribution/reference v0.6.0 // indirect github.com/djherbis/times v1.6.0 // indirect - github.com/docker/cli v28.5.1+incompatible // indirect - github.com/docker/distribution v2.8.3+incompatible // indirect - github.com/docker/docker v28.5.1+incompatible // indirect - github.com/docker/docker-credential-helpers v0.8.2 // indirect - github.com/docker/go-connections v0.6.0 // indirect - github.com/docker/go-metrics v0.0.1 // indirect github.com/elliotwutingfeng/asciiset v0.0.0-20260129054604-cfde2086bc57 // indirect - github.com/felixge/httpsnoop v1.0.4 // indirect - github.com/go-logr/logr v1.4.2 // indirect - github.com/go-logr/stdr v1.2.2 // indirect github.com/google/uuid v1.6.0 // indirect - github.com/gorilla/mux v1.8.1 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect - github.com/moby/locker v1.0.1 // indirect - github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/pierrec/lz4/v4 v4.1.26 // indirect - github.com/pkg/errors v0.9.1 // indirect github.com/pkg/xattr v0.4.12 // indirect - github.com/prometheus/client_golang v1.20.5 // indirect - github.com/prometheus/client_model v0.6.1 // indirect - github.com/prometheus/common v0.60.1 // indirect - github.com/prometheus/procfs v0.15.1 // indirect + github.com/siderolabs/gen v0.8.6 // indirect + github.com/siderolabs/go-adv v1.0.0 // indirect github.com/spf13/pflag v1.0.9 // indirect - go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.57.0 // indirect - go.opentelemetry.io/otel v1.32.0 // indirect - go.opentelemetry.io/otel/metric v1.32.0 // indirect - go.opentelemetry.io/otel/trace v1.32.0 // indirect - golang.org/x/sync v0.20.0 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20241104194629-dd2ea8efbc28 // indirect - google.golang.org/grpc v1.68.0 // indirect - google.golang.org/protobuf v1.35.2 // indirect + github.com/stretchr/testify v1.11.1 // indirect + go.yaml.in/yaml/v3 v3.0.5 // indirect + golang.org/x/sync v0.22.0 // indirect ) diff --git a/go.sum b/go.sum index 0df2b9fe..d2fc77d9 100644 --- a/go.sum +++ b/go.sum @@ -1,285 +1,70 @@ -github.com/AdaLogics/go-fuzz-headers v0.0.0-20230811130428-ced1acdcaa24 h1:bvDV9vkmnHYOMsOr4WLk+Vo07yKIzd94sVoIqshQ4bU= -github.com/AdaLogics/go-fuzz-headers v0.0.0-20230811130428-ced1acdcaa24/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8= -github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= -github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= -github.com/Microsoft/hcsshim v0.11.7 h1:vl/nj3Bar/CvJSYo7gIQPyRWc9f3c6IeSNavBTSZNZQ= -github.com/Microsoft/hcsshim v0.11.7/go.mod h1:MV8xMfmECjl5HdO7U/3/hFVnkmSBjAjmA09d4bExKcU= -github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= -github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= github.com/anchore/go-lzo v0.1.0 h1:NgAacnzqPeGH49Ky19QKLBZEuFRqtTG9cdaucc3Vncs= github.com/anchore/go-lzo v0.1.0/go.mod h1:3kLx0bve2oN1iDwgM1U5zGku1Tfbdb0No5qp1eL1fIk= -github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= -github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8= -github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= -github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= -github.com/bshuster-repo/logrus-logstash-hook v1.0.0 h1:e+C0SB5R1pu//O4MQ3f9cFuPGoOVeF2fE4Og9otCc70= -github.com/bshuster-repo/logrus-logstash-hook v1.0.0/go.mod h1:zsTqEiSzDgAa/8GZR7E1qaXrhYNDKBYy5/dWPTIflbk= github.com/cenkalti/backoff v2.2.1+incompatible h1:tNowT99t7UNflLxfYYSlKYsBpXdEet03Pg2g16Swow4= github.com/cenkalti/backoff v2.2.1+incompatible/go.mod h1:90ReRw6GdpyfrHakVjL/QHaoyV4aDUVVkXQJJJ3NXXM= -github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= -github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= -github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= -github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= -github.com/containerd/cgroups v1.1.0 h1:v8rEWFl6EoqHB+swVNjVoCJE8o3jX7e8nqBGPLaDFBM= -github.com/containerd/cgroups v1.1.0/go.mod h1:6ppBcbh/NOOUU+dMKrykgaBnK9lCIBxHqJDGwsa1mIw= -github.com/containerd/containerd v1.7.34 h1:Q35B4FUECxcoaMz9QrOlqp+0s72w8/0NWawVMhVdf5g= -github.com/containerd/containerd v1.7.34/go.mod h1:ozI//0TomTCLPhQREnx0IXDIQMg+Fk7yTtg9fNvU8EQ= -github.com/containerd/continuity v0.4.4 h1:/fNVfTJ7wIl/YPMHjf+5H32uFhl63JucB34PlCpMKII= -github.com/containerd/continuity v0.4.4/go.mod h1:/lNJvtJKUQStBzpVQ1+rasXO1LAWtUQssk28EZvJ3nE= -github.com/containerd/errdefs v0.3.0 h1:FSZgGOeK4yuT/+DnF07/Olde/q4KBoMsaamhXxIMDp4= -github.com/containerd/errdefs v0.3.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= -github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I= -github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo= -github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpSBQv6A= -github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw= -github.com/coreos/go-systemd/v22 v22.5.0 h1:RrqgGjYQKalulkV8NGVIfkXQf6YYmOyiJKk8iXXhfZs= -github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= -github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= -github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= github.com/diskfs/go-diskfs v1.9.4 h1:0j2d7eG4IjyxL6+ChWbDPocdBCF6HQ4HBWU2WDYWVnc= github.com/diskfs/go-diskfs v1.9.4/go.mod h1:TePJORO83Adh5pb2SqsxAwaP0fofFxKLkxctiS/9OQc= -github.com/distribution/distribution/v3 v3.0.0 h1:q4R8wemdRQDClzoNNStftB2ZAfqOiN6UX90KJc4HjyM= -github.com/distribution/distribution/v3 v3.0.0/go.mod h1:tRNuFoZsUdyRVegq8xGNeds4KLjwLCRin/tTo6i1DhU= -github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= -github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= github.com/djherbis/times v1.6.0 h1:w2ctJ92J8fBvWPxugmXIv7Nz7Q3iDMKNx9v5ocVH20c= github.com/djherbis/times v1.6.0/go.mod h1:gOHeRAz2h+VJNZ5Gmc/o7iD9k4wW7NMVqieYCY99oc0= -github.com/docker/cli v28.5.1+incompatible h1:ESutzBALAD6qyCLqbQSEf1a/U8Ybms5agw59yGVc+yY= -github.com/docker/cli v28.5.1+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8= -github.com/docker/distribution v2.8.3+incompatible h1:AtKxIZ36LoNK51+Z6RpzLpddBirtxJnzDrHLEKxTAYk= -github.com/docker/distribution v2.8.3+incompatible/go.mod h1:J2gT2udsDAN96Uj4KfcMRqY0/ypR+oyYUYmja8H+y+w= -github.com/docker/docker v28.5.1+incompatible h1:Bm8DchhSD2J6PsFzxC35TZo4TLGR2PdW/E69rU45NhM= -github.com/docker/docker v28.5.1+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= -github.com/docker/docker-credential-helpers v0.8.2 h1:bX3YxiGzFP5sOXWc3bTPEXdEaZSeVMrFgOr3T+zrFAo= -github.com/docker/docker-credential-helpers v0.8.2/go.mod h1:P3ci7E3lwkZg6XiHdRKft1KckHiO9a2rNtyFbZ/ry9M= -github.com/docker/go-connections v0.6.0 h1:LlMG9azAe1TqfR7sO+NJttz1gy6KO7VJBh+pMmjSD94= -github.com/docker/go-connections v0.6.0/go.mod h1:AahvXYshr6JgfUJGdDCs2b5EZG/vmaMAntpSFH5BFKE= -github.com/docker/go-events v0.0.0-20190806004212-e31b211e4f1c h1:+pKlWGMw7gf6bQ+oDZB4KHQFypsfjYlq/C4rfL7D3g8= -github.com/docker/go-events v0.0.0-20190806004212-e31b211e4f1c/go.mod h1:Uw6UezgYA44ePAFQYUehOuCzmy5zmg/+nl2ZfMWGkpA= -github.com/docker/go-metrics v0.0.1 h1:AgB/0SvBxihN0X8OR4SjsblXkbMvalQ8cjmtKQ2rQV8= -github.com/docker/go-metrics v0.0.1/go.mod h1:cG1hvH2utMXtqgqqYE9plW6lDxS3/5ayHzueweSI3Vw= -github.com/docker/libtrust v0.0.0-20150114040149-fa567046d9b1 h1:ZClxb8laGDf5arXfYcAtECDFgAgHklGI8CxgjHnXKJ4= -github.com/docker/libtrust v0.0.0-20150114040149-fa567046d9b1/go.mod h1:cyGadeNEkKy96OOhEzfZl+yxihPEzKnqJwvfuSUqbZE= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/elliotwutingfeng/asciiset v0.0.0-20260129054604-cfde2086bc57 h1:x5yxNrq8XffV/OoNUeFPM6hxHVi5OTspSTBxr/9pemg= github.com/elliotwutingfeng/asciiset v0.0.0-20260129054604-cfde2086bc57/go.mod h1:GLo/8fDswSAniFG+BFIaiSPcK610jyzgEhWYPQwuQdw= -github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= -github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= -github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= -github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE= -github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk= -github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= -github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= -github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= -github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= -github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= -github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= github.com/go-test/deep v1.1.1 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U= github.com/go-test/deep v1.1.1/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= -github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= -github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= -github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= -github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE= -github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= -github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= -github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= -github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= -github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= -github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= -github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= -github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/gorilla/handlers v1.5.2 h1:cLTUSsNkgcwhgRqvCNmdbRWG0A3N4F+M2nWKdScwyEE= -github.com/gorilla/handlers v1.5.2/go.mod h1:dX+xVpaxdSw+q0Qek8SSsl3dfMk3jNddUkMzo0GtH0w= -github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= -github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= -github.com/grpc-ecosystem/grpc-gateway/v2 v2.23.0 h1:ad0vkEBuk23VJzZR9nkLVG0YAoN9coASF1GusYX6AlU= -github.com/grpc-ecosystem/grpc-gateway/v2 v2.23.0/go.mod h1:igFoXX2ELCW06bol23DWPB5BEWfZISOzSP5K2sbLea0= -github.com/hashicorp/golang-lru/arc/v2 v2.0.5 h1:l2zaLDubNhW4XO3LnliVj0GXO3+/CGNJAg1dcN2Fpfw= -github.com/hashicorp/golang-lru/arc/v2 v2.0.5/go.mod h1:ny6zBSQZi2JxIeYcv7kt2sH2PXJtirBN7RDhRpxPkxU= -github.com/hashicorp/golang-lru/v2 v2.0.5 h1:wW7h1TG88eUIJ2i69gaE3uNVtEPIagzhGvHgwfx2Vm4= -github.com/hashicorp/golang-lru/v2 v2.0.5/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= -github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU= -github.com/json-iterator/go v1.1.7/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= -github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w= github.com/klauspost/compress v1.19.1 h1:VsB4HPswih7mmZ8WleSFQ75c/Ui1M4trX5oAsJnhSlk= github.com/klauspost/compress v1.19.1/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= -github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= -github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc= -github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= -github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/lmittmann/tint v1.1.3 h1:Hv4EaHWXQr+GTFnOU4VKf8UvAtZgn0VuKT+G0wFlO3I= github.com/lmittmann/tint v1.1.3/go.mod h1:HIS3gSy7qNwGCj+5oRjAutErFBl4BzdQP6cJZ0NfMwE= github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI= github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A= -github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= -github.com/moby/locker v1.0.1 h1:fOXqR41zeveg4fFODix+1Ch4mj/gT0NE1XJbp/epuBg= -github.com/moby/locker v1.0.1/go.mod h1:S7SDdo5zpBK84bzzVlKr2V0hz+7x9hWbYC/kq7oQppc= -github.com/moby/sys/mountinfo v0.6.2 h1:BzJjoreD5BMFNmD9Rus6gdd1pLuecOFPt8wC+Vygl78= -github.com/moby/sys/mountinfo v0.6.2/go.mod h1:IJb6JQeOklcdMU9F5xQ8ZALD+CUr5VlGpwtX+VE0rpI= -github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g= -github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28= -github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= -github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= -github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= -github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= -github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= -github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= -github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= github.com/peterbourgon/ff/v3 v3.4.0 h1:QBvM/rizZM1cB0p0lGMdmR7HxZeI/ZrBWB4DqLkMUBc= github.com/peterbourgon/ff/v3 v3.4.0/go.mod h1:zjJVUhx+twciwfDl0zBcFzl4dW8axCRyXE/eKY9RztQ= -github.com/phayes/freeport v0.0.0-20220201140144-74d24b5ae9f5 h1:Ii+DKncOVM8Cu1Hc+ETb5K+23HdAMvESYE3ZJ5b5cMI= -github.com/phayes/freeport v0.0.0-20220201140144-74d24b5ae9f5/go.mod h1:iIss55rKnNBTvrwdmkUpLnDpZoAHvWaiq5+iMmen4AE= github.com/pierrec/lz4/v4 v4.1.26 h1:GrpZw1gZttORinvzBdXPUXATeqlJjqUG/D87TKMnhjY= github.com/pierrec/lz4/v4 v4.1.26/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4= -github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= -github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= -github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/xattr v0.4.12 h1:rRTkSyFNTRElv6pkA3zpjHpQ90p/OdHQC1GmGh1aTjM= github.com/pkg/xattr v0.4.12/go.mod h1:di8WF84zAKk8jzR1UBTEWh9AUlIZZ7M/JNt8e9B6ktU= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw= -github.com/prometheus/client_golang v1.0.0/go.mod h1:db9x61etRT2tGnBNRi70OPL5FsnadC4Ky3P0J6CfImo= -github.com/prometheus/client_golang v1.1.0/go.mod h1:I1FGZT9+L76gKKOs5djB6ezCbFQP1xR9D75/vuwEF3g= -github.com/prometheus/client_golang v1.20.5 h1:cxppBPuYhUnsO6yo/aoRol4L7q7UFfdm+bR9r+8l63Y= -github.com/prometheus/client_golang v1.20.5/go.mod h1:PIEt8X02hGcP8JWbeHyeZ53Y/jReSnHgO035n//V5WE= -github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo= -github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= -github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E= -github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY= -github.com/prometheus/common v0.4.1/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4= -github.com/prometheus/common v0.6.0/go.mod h1:eBmuwkDJBwy6iBfxCBob6t6dR6ENT/y+J+Zk0j9GMYc= -github.com/prometheus/common v0.60.1 h1:FUas6GcOw66yB/73KC+BOZoFJmbo/1pojoILArPAaSc= -github.com/prometheus/common v0.60.1/go.mod h1:h0LYf1R1deLSKtD4Vdg8gy4RuOvENW2J/h19V5NADQw= -github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= -github.com/prometheus/procfs v0.0.2/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA= -github.com/prometheus/procfs v0.0.3/go.mod h1:4A/X28fw3Fc593LaREMrKMqOKvUAntwMDaekg4FpcdQ= -github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc= -github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk= -github.com/redis/go-redis/extra/rediscmd/v9 v9.0.5 h1:EaDatTxkdHG+U3Bk4EUr+DZ7fOGwTfezUiUJMaIcaho= -github.com/redis/go-redis/extra/rediscmd/v9 v9.0.5/go.mod h1:fyalQWdtzDBECAQFBJuQe5bzQ02jGd5Qcbgb97Flm7U= -github.com/redis/go-redis/extra/redisotel/v9 v9.0.5 h1:EfpWLLCyXw8PSM2/XNJLjI3Pb27yVE+gIAfeqp8LUCc= -github.com/redis/go-redis/extra/redisotel/v9 v9.0.5/go.mod h1:WZjPDy7VNzn77AAfnAfVjZNvfJTYfPetfZk5yoSTLaQ= -github.com/redis/go-redis/v9 v9.7.3 h1:YpPyAayJV+XErNsatSElgRZZVCwXX9QzkKYNvO7x0wM= -github.com/redis/go-redis/v9 v9.7.3/go.mod h1:bGUrSggJ9X9GUmZpZNEOQKaANxSGgOEBRltRTZHSvrA= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= -github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= +github.com/siderolabs/gen v0.8.6 h1:pE6shuqov3L+5rEcAUJ/kY6iJofimljQw5G95P8a5c4= +github.com/siderolabs/gen v0.8.6/go.mod h1:J9IbusbES2W6QWjtSHpDV9iPGZHc978h1+KJ4oQRspQ= +github.com/siderolabs/go-adv v1.0.0 h1:ZWXnoGq1GKeEIkLSR4o6oKcayFsowZkJsWcyQqwPk6c= +github.com/siderolabs/go-adv v1.0.0/go.mod h1:nR6YwduJv56mZI1D3ow1Zok5lwefiM94hS0o1d6KmNc= github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w= github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g= github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= -github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= -github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/ulikunitz/xz v0.5.16 h1:ld6NyySjx5lowVKwJvMRLnW5nxKX/xnpSiFYZ/Lxur0= github.com/ulikunitz/xz v0.5.16/go.mod h1:H9Rt/W6/Qj27PGauhQc6nfCDy7vHpzsOThBSaYDoEhw= -go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0= -go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo= -go.opentelemetry.io/contrib/bridges/prometheus v0.57.0 h1:UW0+QyeyBVhn+COBec3nGhfnFe5lwB0ic1JBVjzhk0w= -go.opentelemetry.io/contrib/bridges/prometheus v0.57.0/go.mod h1:ppciCHRLsyCio54qbzQv0E4Jyth/fLWDTJYfvWpcSVk= -go.opentelemetry.io/contrib/exporters/autoexport v0.57.0 h1:jmTVJ86dP60C01K3slFQa2NQ/Aoi7zA+wy7vMOKD9H4= -go.opentelemetry.io/contrib/exporters/autoexport v0.57.0/go.mod h1:EJBheUMttD/lABFyLXhce47Wr6DPWYReCzaZiXadH7g= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.57.0 h1:DheMAlT6POBP+gh8RUH19EOTnQIor5QE0uSRPtzCpSw= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.57.0/go.mod h1:wZcGmeVO9nzP67aYSLDqXNWK87EZWhi7JWj1v7ZXf94= -go.opentelemetry.io/otel v1.32.0 h1:WnBN+Xjcteh0zdk01SVqV55d/m62NJLJdIyb4y/WO5U= -go.opentelemetry.io/otel v1.32.0/go.mod h1:00DCVSB0RQcnzlwyTfqtxSm+DRr9hpYrHjNGiBHVQIg= -go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.8.0 h1:WzNab7hOOLzdDF/EoWCt4glhrbMPVMOO5JYTmpz36Ls= -go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.8.0/go.mod h1:hKvJwTzJdp90Vh7p6q/9PAOd55dI6WA6sWj62a/JvSs= -go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.8.0 h1:S+LdBGiQXtJdowoJoQPEtI52syEP/JYBUpjO49EQhV8= -go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.8.0/go.mod h1:5KXybFvPGds3QinJWQT7pmXf+TN5YIa7CNYObWRkj50= -go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.32.0 h1:j7ZSD+5yn+lo3sGV69nW04rRR0jhYnBwjuX3r0HvnK0= -go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.32.0/go.mod h1:WXbYJTUaZXAbYd8lbgGuvih0yuCfOFC5RJoYnoLcGz8= -go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.32.0 h1:t/Qur3vKSkUCcDVaSumWF2PKHt85pc7fRvFuoVT8qFU= -go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.32.0/go.mod h1:Rl61tySSdcOJWoEgYZVtmnKdA0GeKrSqkHC1t+91CH8= -go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.32.0 h1:IJFEoHiytixx8cMiVAO+GmHR6Frwu+u5Ur8njpFO6Ac= -go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.32.0/go.mod h1:3rHrKNtLIoS0oZwkY2vxi+oJcwFRWdtUyRII+so45p8= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.32.0 h1:9kV11HXBHZAvuPUZxmMWrH8hZn/6UnHX4K0mu36vNsU= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.32.0/go.mod h1:JyA0FHXe22E1NeNiHmVp7kFHglnexDQ7uRWDiiJ1hKQ= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.32.0 h1:cMyu9O88joYEaI47CnQkxO1XZdpoTF9fEnW2duIddhw= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.32.0/go.mod h1:6Am3rn7P9TVVeXYG+wtcGE7IE1tsQ+bP3AuWcKt/gOI= -go.opentelemetry.io/otel/exporters/prometheus v0.54.0 h1:rFwzp68QMgtzu9PgP3jm9XaMICI6TsofWWPcBDKwlsU= -go.opentelemetry.io/otel/exporters/prometheus v0.54.0/go.mod h1:QyjcV9qDP6VeK5qPyKETvNjmaaEc7+gqjh4SS0ZYzDU= -go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.8.0 h1:CHXNXwfKWfzS65yrlB2PVds1IBZcdsX8Vepy9of0iRU= -go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.8.0/go.mod h1:zKU4zUgKiaRxrdovSS2amdM5gOc59slmo/zJwGX+YBg= -go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.32.0 h1:SZmDnHcgp3zwlPBS2JX2urGYe/jBKEIT6ZedHRUyCz8= -go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.32.0/go.mod h1:fdWW0HtZJ7+jNpTKUR0GpMEDP69nR8YBJQxNiVCE3jk= -go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.32.0 h1:cC2yDI3IQd0Udsux7Qmq8ToKAx1XCilTQECZ0KDZyTw= -go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.32.0/go.mod h1:2PD5Ex6z8CFzDbTdOlwyNIUywRr1DN0ospafJM1wJ+s= -go.opentelemetry.io/otel/log v0.8.0 h1:egZ8vV5atrUWUbnSsHn6vB8R21G2wrKqNiDt3iWertk= -go.opentelemetry.io/otel/log v0.8.0/go.mod h1:M9qvDdUTRCopJcGRKg57+JSQ9LgLBrwwfC32epk5NX8= -go.opentelemetry.io/otel/metric v1.32.0 h1:xV2umtmNcThh2/a/aCP+h64Xx5wsj8qqnkYZktzNa0M= -go.opentelemetry.io/otel/metric v1.32.0/go.mod h1:jH7CIbbK6SH2V2wE16W05BHCtIDzauciCRLoc/SyMv8= -go.opentelemetry.io/otel/sdk v1.32.0 h1:RNxepc9vK59A8XsgZQouW8ue8Gkb4jpWtJm9ge5lEG4= -go.opentelemetry.io/otel/sdk v1.32.0/go.mod h1:LqgegDBjKMmb2GC6/PrTnteJG39I8/vJCAP9LlJXEjU= -go.opentelemetry.io/otel/sdk/log v0.8.0 h1:zg7GUYXqxk1jnGF/dTdLPrK06xJdrXgqgFLnI4Crxvs= -go.opentelemetry.io/otel/sdk/log v0.8.0/go.mod h1:50iXr0UVwQrYS45KbruFrEt4LvAdCaWWgIrsN3ZQggo= -go.opentelemetry.io/otel/sdk/metric v1.32.0 h1:rZvFnvmvawYb0alrYkjraqJq0Z4ZUJAiyYCU9snn1CU= -go.opentelemetry.io/otel/sdk/metric v1.32.0/go.mod h1:PWeZlq0zt9YkYAp3gjKZ0eicRYvOh1Gd+X99x6GHpCQ= -go.opentelemetry.io/otel/trace v1.32.0 h1:WIC9mYrXf8TmY/EXuULKc8hR17vE+Hjv2cssQDe03fM= -go.opentelemetry.io/otel/trace v1.32.0/go.mod h1:+i4rkvCraA+tG6AzwloGaCtkx53Fa+L+V8e9a7YvhT8= -go.opentelemetry.io/proto/otlp v1.3.1 h1:TrMUixzpM0yuc/znrFTP9MMRh8trP93mkCiDVeXrui0= -go.opentelemetry.io/proto/otlp v1.3.1/go.mod h1:0X1WI4de4ZsLrrJNLAQbFeLCm3T7yBkR0XqQ7niQU+8= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= -golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= -golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= -golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988= -golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc= -golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20190613194153-d28f0bde5980/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= -golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= -golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= -golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= -golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20190801041406-cbf593c0f2f3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw= +go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg= +golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= +golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20220408201424-a24fb2fb8a0f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220615213510-4f61da869c0c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= -golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= -golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= -google.golang.org/genproto v0.0.0-20231211222908-989df2bf70f3 h1:1hfbdAfFbkmpg41000wDVqr7jUpK/Yo+LPnIxxGzmkg= -google.golang.org/genproto/googleapis/api v0.0.0-20241104194629-dd2ea8efbc28 h1:M0KvPgPmDZHPlbRbaNU1APr28TvwvvdUPlSv7PUvy8g= -google.golang.org/genproto/googleapis/api v0.0.0-20241104194629-dd2ea8efbc28/go.mod h1:dguCy7UOdZhTvLzDyt15+rOrawrpM4q7DD9dQ1P11P4= -google.golang.org/genproto/googleapis/rpc v0.0.0-20241104194629-dd2ea8efbc28 h1:XVhgTWWV3kGQlwJHR3upFWZeTsei6Oks1apkZSeonIE= -google.golang.org/genproto/googleapis/rpc v0.0.0-20241104194629-dd2ea8efbc28/go.mod h1:GX3210XPVPUjJbTUbvwI8f2IpZDMZuPJWDzDuebbviI= -google.golang.org/grpc v1.68.0 h1:aHQeeJbo8zAkAa3pRzrVjZlbz6uSfeOXlJNQM0RAbz0= -google.golang.org/grpc v1.68.0/go.mod h1:fmSPC5AsjSBCK54MyHRx48kpOti1/jRfOlwEWywNjWA= -google.golang.org/protobuf v1.35.2 h1:8Ar7bF+apOIoThw1EdZl0p1oWvMqTHmpA2fRTyZO8io= -google.golang.org/protobuf v1.35.2/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= -gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= -gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -gotest.tools/v3 v3.4.0 h1:ZazjZUfuVeZGLAmlKKuyv3IKP5orXcwtOwDQH6YVr6o= -gotest.tools/v3 v3.4.0/go.mod h1:CtbdzLSsqVhDgMtKsx03ird5YTGB3ar27v0u/yKBW5g= -oras.land/oras-go v1.2.7 h1:KF9rBAtKYMGB5gjgHV5XquUfYDER3ecQBEXjdI7KZWI= -oras.land/oras-go v1.2.7/go.mod h1:WVpIPbm82xjWT/GJU3TqZ0y9Ctj3DGco4wLYvGdOVvA= +oras.land/oras-go/v2 v2.6.2 h1:N04RXngAp1LJKTG6ifz3xHPipasEkWr+hFmInja5YKo= +oras.land/oras-go/v2 v2.6.2/go.mod h1:PlTtg4JTDJkDe8yVHpM2wz7/YDc00GVas+i4jAW2TZ4= diff --git a/oci2disk/README.md b/oci2disk/README.md index ac549da2..a28201e1 100644 --- a/oci2disk/README.md +++ b/oci2disk/README.md @@ -5,7 +5,7 @@ quay.io/tinkerbell/actions/oci2disk:latest This action provides the capability to stream a raw (compressed) disk image from an OCI compliant registry and write this to a block device on a server -To upload a disk image to a compliant OCI registry the [ORAS]() tool is recommended, +To upload a disk image to a compliant OCI registry the [ORAS](https://oras.land) tool is recommended, as this will simplify the process of creating a new "artifact" that can be used by `oci2disk`. @@ -28,7 +28,6 @@ actions: environment: DEST_DISK: /dev/nvme0n1 IMG_URL: "192.168.0.173/test/debian:raw.gz" - COMPRESSED: true # optional fields for registry authentication REGISTRY_USERNAME: "foo" REGISTRY_PASSWORD: "bar" @@ -36,9 +35,23 @@ actions: SKIP_VERIFY: "true" ``` -## Compression format supported: +## Environment Variables: -- bzip2 (`.bzip2`) +- `DEST_DISK`: Target block device to write the image to (required) +- `IMG_URL`: OCI image reference, `` (required) +- `REGISTRY_USERNAME` / `REGISTRY_PASSWORD`: registry credentials (optional, anonymous pull when unset) +- `SKIP_VERIFY`: set to `true` to skip TLS certificate verification (optional, defaults to `false`) + +The platform (`linux/amd64`, `linux/arm64`, ...) is selected automatically from the +architecture the action runs on when the image is a multi-platform index. + +## Compression format supported: + +The compression format is detected from the file name recorded in the layer's +`org.opencontainers.image.title` annotation, which `oras push` sets to the name of +the pushed file. + +- bzip2 (`.bzip2`, `.bz2`) - gzip (`.gz`) - xz (`.xz`) -- xs (`.xs`) +- zstd (`.zst`, `.zs`) diff --git a/oci2disk/image/client.go b/oci2disk/image/client.go new file mode 100644 index 00000000..4582f471 --- /dev/null +++ b/oci2disk/image/client.go @@ -0,0 +1,40 @@ +package image + +import ( + "crypto/tls" + "net/http" + + "oras.land/oras-go/v2/registry/remote/auth" + "oras.land/oras-go/v2/registry/remote/retry" +) + +// newRegistryClient builds the HTTP client used to talk to the registry. +// +// A token cache is always configured so registries that answer every request +// with a bearer challenge (ghcr.io does this even for public images) can be +// pulled anonymously. Credentials, when supplied, are bound to the registry +// host of the image reference. +func newRegistryClient(registryHost, username, password string, skipVerify bool) *auth.Client { + transport, ok := http.DefaultTransport.(*http.Transport) + if ok { + transport = transport.Clone() + } else { + transport = &http.Transport{Proxy: http.ProxyFromEnvironment} + } + + if skipVerify { + transport.TLSClientConfig = &tls.Config{InsecureSkipVerify: true} //nolint:gosec // G402: opt-in through SKIP_VERIFY, defaults to verifying. + } + + client := &auth.Client{ + Client: &http.Client{Transport: retry.NewTransport(transport)}, + Cache: auth.NewCache(), + } + client.SetUserAgent("oci2disk") + + if username != "" && password != "" { + client.Credential = auth.StaticCredential(registryHost, auth.Credential{Username: username, Password: password}) + } + + return client +} diff --git a/oci2disk/image/client_test.go b/oci2disk/image/client_test.go new file mode 100644 index 00000000..33839ffe --- /dev/null +++ b/oci2disk/image/client_test.go @@ -0,0 +1,76 @@ +package image + +import ( + "context" + "net/http" + "testing" + + "oras.land/oras-go/v2/registry/remote/auth" + "oras.land/oras-go/v2/registry/remote/retry" +) + +func baseTransport(t *testing.T, client *auth.Client) *http.Transport { + t.Helper() + + rt := client.Client.Transport + if r, ok := rt.(*retry.Transport); ok { + rt = r.Base + } + transport, ok := rt.(*http.Transport) + if !ok { + t.Fatalf("expected *http.Transport, got %T", rt) + } + + return transport +} + +func TestNewRegistryClientUsesStaticCredentials(t *testing.T) { + client := newRegistryClient("192.168.0.173", "foo", "bar", false) + + if client.Credential == nil { + t.Fatal("expected a credential function") + } + cred, err := client.Credential(context.Background(), "192.168.0.173") + if err != nil { + t.Fatalf("Credential() error: %v", err) + } + if cred.Username != "foo" || cred.Password != "bar" { + t.Fatalf("expected foo/bar credentials, got %q/%q", cred.Username, cred.Password) + } +} + +func TestNewRegistryClientAnonymousWhenNoCredentials(t *testing.T) { + client := newRegistryClient("ghcr.io", "", "", false) + + // oras treats a nil credential func as anonymous. + if client.Credential != nil { + cred, err := client.Credential(context.Background(), "ghcr.io") + if err != nil { + t.Fatalf("Credential() error: %v", err) + } + if cred.Username != "" || cred.Password != "" || cred.AccessToken != "" { + t.Fatalf("expected empty credentials, got %+v", cred) + } + } + if client.Cache == nil { + t.Fatal("expected a token cache so bearer challenges are answered once") + } +} + +func TestNewRegistryClientSkipVerify(t *testing.T) { + client := newRegistryClient("registry.local", "", "", true) + + transport := baseTransport(t, client) + if transport.TLSClientConfig == nil || !transport.TLSClientConfig.InsecureSkipVerify { + t.Fatal("expected InsecureSkipVerify to be set") + } +} + +func TestNewRegistryClientVerifiesByDefault(t *testing.T) { + client := newRegistryClient("registry.local", "", "", false) + + transport := baseTransport(t, client) + if transport.TLSClientConfig != nil && transport.TLSClientConfig.InsecureSkipVerify { + t.Fatal("expected TLS verification to stay enabled") + } +} diff --git a/oci2disk/image/image.go b/oci2disk/image/image.go index 34bb1505..a18de5ec 100644 --- a/oci2disk/image/image.go +++ b/oci2disk/image/image.go @@ -6,92 +6,228 @@ import ( "compress/bzip2" "compress/gzip" "context" - "crypto/tls" - "errors" + "encoding/json" "fmt" "io" - "net/http" + "math" "os" "path/filepath" + "runtime" + "sync/atomic" + "time" - "github.com/containerd/containerd/reference" - "github.com/containerd/containerd/remotes/docker" "github.com/klauspost/compress/zstd" + ocispec "github.com/opencontainers/image-spec/specs-go/v1" log "github.com/sirupsen/logrus" "github.com/ulikunitz/xz" "golang.org/x/sys/unix" - - "oras.land/oras-go/pkg/oras" + "oras.land/oras-go/v2" + "oras.land/oras-go/v2/content" + "oras.land/oras-go/v2/registry/remote" ) -// WriteCounter counts the number of bytes written to it. It implements to the io.Writer interface -// and we can pass this into io.TeeReader() which will report progress on each write cycle. -type WriteCounter struct { - Total uint64 +type Progress struct { + w io.Writer + r io.Reader + wBytes atomic.Int64 + rBytes atomic.Int64 +} + +func NewProgress(w io.Writer, r io.Reader) *Progress { + return &Progress{w: w, r: r} +} + +func (p *Progress) Write(b []byte) (n int, err error) { + nu, err := p.w.Write(b) + p.wBytes.Add(int64(nu)) + return nu, err +} + +func (p *Progress) Read(b []byte) (n int, err error) { + nu, err := p.r.Read(b) + p.rBytes.Add(int64(nu)) + return nu, err } -func (wc *WriteCounter) Write(p []byte) (int, error) { - n := len(p) - wc.Total += uint64(n) - return n, nil +func (p *Progress) readBytes() int64 { + return p.rBytes.Load() } -// Write will pull an image and write it to local storage device -// with compress set to true it will use gzip compression to expand the data before -// writing to an underlying device. -func Write(sourceImage, destinationDevice string, compressed bool, registryUsername, registryPassword string, skipVerify bool) error { +func (p *Progress) writeBytes() int64 { + return p.wBytes.Load() +} + +func prettyByteSize(b int64) string { + bf := float64(b) + for _, unit := range []string{"", "Ki", "Mi", "Gi", "Ti", "Pi", "Ei", "Zi"} { + if math.Abs(bf) < 1024.0 { + return fmt.Sprintf("%3.6f%sB", bf, unit) + } + bf /= 1024.0 + } + return fmt.Sprintf("%.6fYiB", bf) +} + +// Write will pull an image and write it to local storage device. +// Compression type is automatically detected from the layer's org.opencontainers.image.title annotation. +// Platform is automatically detected from the runtime (linux/arm64, linux/amd64, etc.). +// Registry credentials are optional; when empty the pull is anonymous. skipVerify disables TLS +// certificate verification for registries with self-signed certificates. +func Write(sourceImage, destinationDevice, registryUsername, registryPassword string, skipVerify bool) error { ctx := context.Background() - client := &http.Client{ - Transport: &http.Transport{ - TLSClientConfig: &tls.Config{ - InsecureSkipVerify: skipVerify, //nolint:gosec // This is intentional: the top-level setting defaults to false, but this option is configurable to provide flexibility when needed. - }, - }, + + // Detect platform from runtime + platformOS := "linux" + platformArch := runtime.GOARCH + + // Create repository connection + repo, err := remote.NewRepository(sourceImage) + if err != nil { + return fmt.Errorf("failed to create repository: %w", err) } - // Always install an authorizer so the resolver can handle Bearer-token - // challenges from registries like ghcr.io even for anonymous pulls. When - // credentials are provided, wire them into the auth callback; otherwise the - // authorizer performs an unauthenticated token exchange. - authorizerOpts := []docker.AuthorizerOpt{docker.WithAuthClient(client)} if registryUsername != "" && registryPassword != "" { log.Infof("Registry credentials provided, using authenticated pull") - authorizerOpts = append(authorizerOpts, docker.WithAuthCreds(func(_ string) (string, string, error) { - return registryUsername, registryPassword, nil - })) } - registryOpts := []docker.RegistryOpt{ - docker.WithClient(client), - docker.WithAuthorizer(docker.NewDockerAuthorizer(authorizerOpts...)), + + if skipVerify { + log.Warnf("TLS certificate verification disabled for registry [%s]", repo.Reference.Registry) } - resolver := docker.NewResolver(docker.ResolverOptions{ - Hosts: docker.ConfigureDefaultRegistries(registryOpts...), - }) + repo.Client = newRegistryClient(repo.Reference.Registry, registryUsername, registryPassword, skipVerify) + // Open destination device fileOut, err := os.OpenFile(destinationDevice, os.O_CREATE|os.O_WRONLY, 0o644) if err != nil { return err } defer fileOut.Close() - customMediaType := "application/vnd.oci.image.layer.v1.tar" - allowedMediaTypes := []string{customMediaType} - - f := NewDiskImageStore(sourceImage, compressed, fileOut) log.Infof("Beginning write of image [%s] to disk [%s]", filepath.Base(sourceImage), destinationDevice) - copyOpts := []oras.CopyOpt{ - oras.WithAllowedMediaTypes(allowedMediaTypes), - oras.WithPullStatusTrack(os.Stdout), + + // Resolve the manifest descriptor with optional platform filtering + tagOrDigest := repo.Reference.Reference + if tagOrDigest == "" { + return fmt.Errorf("image reference format is invalid. Please specify ") } - _, err = oras.Copy(ctx, resolver, sourceImage, f, "", copyOpts...) + + // Configure resolution options with platform filtering if specified + resolveOpts := oras.ResolveOptions{} + if platformOS != "" && platformArch != "" { + resolveOpts.TargetPlatform = &ocispec.Platform{ + OS: platformOS, + Architecture: platformArch, + } + log.Infof("Filtering for platform: %s/%s", platformOS, platformArch) + } + + manifestDescriptor, err := oras.Resolve(ctx, repo, tagOrDigest, resolveOpts) + if err != nil { + return fmt.Errorf("failed to resolve manifest: %w", err) + } + + // Fetch the manifest + manifestBytes, err := content.FetchAll(ctx, repo, manifestDescriptor) if err != nil { - if errors.Is(err, reference.ErrObjectRequired) { - return fmt.Errorf("image reference format is invalid. Please specify ") + return fmt.Errorf("failed to fetch manifest: %w", err) + } + + // Parse the manifest + var manifest ocispec.Manifest + if err := json.Unmarshal(manifestBytes, &manifest); err != nil { + return fmt.Errorf("failed to parse manifest: %w", err) + } + + // Filter and process layers + customMediaType := "application/vnd.oci.image.layer.v1.tar" + var totalBytes int64 + var processedLayers int + + for _, layer := range manifest.Layers { + // Skip layers that don't match our media type + if layer.MediaType != customMediaType { + log.Debugf("Skipping layer with media type: %s", layer.MediaType) + continue } - return err + + log.Infof("Fetching layer: %s (size: %d bytes)", layer.Digest, layer.Size) + + // Fetch the layer content + layerReader, err := repo.Fetch(ctx, layer) + if err != nil { + return fmt.Errorf("failed to fetch layer %s: %w", layer.Digest, err) + } + + // Determine compression and create appropriate reader + var sourceReader io.Reader = layerReader + var decompressor io.ReadCloser + + // Determine compression type from annotation + if layer.Annotations != nil { + if titleAnnotation, ok := layer.Annotations[ocispec.AnnotationTitle]; ok { + // Create decompressor if needed + var err error + decompressor, err = findDecompressor(titleAnnotation, layerReader) + if err != nil { + layerReader.Close() + return fmt.Errorf("failed to create decompressor: %w", err) + } + sourceReader = decompressor + } + } + + // Create progress tracker that wraps the writer and source reader + progress := NewProgress(fileOut, sourceReader) + + // Start progress reporting + ticker := time.NewTicker(5 * time.Second) + done := make(chan bool) + go func() { + layerSize := layer.Size + for { + select { + case <-done: + log.Infof("Progress: written=%s, compressed=%s, read=%s", + prettyByteSize(progress.writeBytes()), + prettyByteSize(layerSize), + prettyByteSize(progress.readBytes())) + return + case <-ticker.C: + log.Infof("Progress: written=%s, compressed=%s, read=%s", + prettyByteSize(progress.writeBytes()), + prettyByteSize(layerSize), + prettyByteSize(progress.readBytes())) + } + } + }() + + // Copy to destination device: read from progress (which reads from sourceReader) and write to progress + _, err = io.Copy(progress, progress) + + // Stop progress reporting + ticker.Stop() + done <- true + + // Close in correct order: decompressor first (if exists), then underlying layerReader + if decompressor != nil { + decompressor.Close() + } + layerReader.Close() + + if err != nil { + return fmt.Errorf("failed to write layer to device: %w", err) + } + + totalBytes += progress.writeBytes() + processedLayers++ } + if processedLayers == 0 { + return fmt.Errorf("no layers with media type %s found in manifest", customMediaType) + } + + log.Infof("Successfully processed %d layer(s), total: %s", processedLayers, prettyByteSize(totalBytes)) + // Do the equivalent of partprobe on the device if err := fileOut.Sync(); err != nil { log.Warnf("Failed to sync the block device") @@ -106,7 +242,7 @@ func Write(sourceImage, destinationDevice string, compressed bool, registryUsern func findDecompressor(imageURL string, r io.Reader) (io.ReadCloser, error) { switch filepath.Ext(imageURL) { - case ".bzip2": + case ".bzip2", ".bz2": return io.NopCloser(bzip2.NewReader(r)), nil case ".gz": reader, err := gzip.NewReader(r) @@ -120,7 +256,7 @@ func findDecompressor(imageURL string, r io.Reader) (io.ReadCloser, error) { return nil, fmt.Errorf("[ERROR] New xz reader: %w", err) } return io.NopCloser(reader), nil - case ".zs": + case ".zs", ".zst": reader, err := zstd.NewReader(r) if err != nil { return nil, fmt.Errorf("[ERROR] New zs reader: %w", err) diff --git a/oci2disk/image/image_test.go b/oci2disk/image/image_test.go index 7cb42c01..a8df050c 100644 --- a/oci2disk/image/image_test.go +++ b/oci2disk/image/image_test.go @@ -45,44 +45,64 @@ func xzReader(t *testing.T) io.Reader { func Test_findDecompressor(t *testing.T) { tests := []struct { name string - imageURL string + filename string reader func(*testing.T) io.Reader wantOut io.Reader wantErr bool }{ { - "tar gzip", - "http://192.168.0.1/a.tar.gz", + "gzip", + "image.raw.gz", gzipReader, nil, false, }, { "broken gzip", - "http://192.168.0.1/a.gz", + "image.raw.gz", xzReader, nil, true, }, { "xz", - "http://192.168.0.1/a.xz", + "image.raw.xz", xzReader, nil, false, }, { - "unknown", - "http://192.168.0.1/a.abc", + "unknown extension", + "image.raw.abc", xzReader, nil, true, }, - // TODO: Add test cases. + { + "no extension", + "image.raw", + gzipReader, + nil, + true, + }, + { + "bzip2", + "image.raw.bz2", + gzipReader, + nil, + false, + }, + { + "zstd .zst", + "image.raw.zst", + gzipReader, + nil, + false, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - _, err := findDecompressor(tt.imageURL, tt.reader(t)) + _, err := findDecompressor(tt.filename, tt.reader(t)) if (err != nil) != tt.wantErr { t.Errorf("findDecompressor() error = %v, wantErr %v", err, tt.wantErr) return diff --git a/oci2disk/image/writer.go b/oci2disk/image/writer.go deleted file mode 100644 index 5bfd8ed2..00000000 --- a/oci2disk/image/writer.go +++ /dev/null @@ -1,228 +0,0 @@ -package image - -import ( - "context" - "fmt" - "io" - "log" - - ctrcontent "github.com/containerd/containerd/content" - "github.com/containerd/containerd/remotes" - "github.com/opencontainers/go-digest" - ocispec "github.com/opencontainers/image-spec/specs-go/v1" - - "oras.land/oras-go/pkg/content" -) - -// DiskImageStore -. -type DiskImageStore struct { - sourceImage string - writer io.Writer - compressed bool -} - -// NewDiskImageStore -. -func NewDiskImageStore(sourceImage string, compressed bool, w io.Writer) DiskImageStore { - // we have to reprocess the opts to find the blocksize - // var wOpts := content.DefaultWriterOpts() - // for _, opt := range opts { - // if err := opt(&wOpts); err != nil { - // // TODO: we probably should handle errors here - // continueå - // } - // } - return DiskImageStore{sourceImage: sourceImage, writer: w, compressed: compressed} -} - -// Writer get a writer. -func (d DiskImageStore) Writer(_ context.Context, opts ...ctrcontent.WriterOpt) (ctrcontent.Writer, error) { - // the logic is straightforward: - // - if there is a desc in the opts, and the mediatype is tar or tar+gzip, then pass the correct decompress writer - // - else, pass the regular writer - // var ( - // writer ctrcontent.Writer - // err error - // multiIngester MultiWriterIngester - // ok bool - // ) - - // // check to see if we are supposed to use a MultiWriterIngester - // if d.multiWriterIngester { - // multiIngester, ok = d.ingester.(MultiWriterIngester) - // if !ok { - // return nil, errors.New("configured to use multiwriter ingester, but ingester does not implement multiwriter") - // } - // } - - // // we have to reprocess the opts to find the desc - // var wOpts ctrcontent.WriterOpts - // for _, opt := range opts { - // if err := opt(&wOpts); err != nil { - // return nil, err - // } - // } - // // figure out if compression and/or archive exists - // desc := wOpts.Desc - // // before we pass it down, we need to strip anything we are removing here - // // and possibly update the digest, since the store indexes things by digest - // hasGzip, hasTar, modifiedMediaType := checkCompression(desc.MediaType) - // wOpts.Desc.MediaType = modifiedMediaType - // opts = append(opts, ctrcontent.WithDescriptor(wOpts.Desc)) - // // determine if we pass it blocksize, only if positive - // writerOpts := []WriterOpt{} - // if d.blocksize > 0 { - // writerOpts = append(writerOpts, WithBlocksize(d.blocksize)) - // } - - // writer, err = d.ingester.Writer(ctx, opts...) - // if err != nil { - // return nil, err - // } - - // // do we need to wrap with an untar writer? - // if hasTar { - // // if not multiingester, get a regular writer - // if multiIngester == nil { - // writer = NewUntarWriter(writer, writerOpts...) - // } else { - // writers, err := multiIngester.Writers(ctx, opts...) - // if err != nil { - // return nil, err - // } - // writer = NewUntarWriterByName(writers, writerOpts...) - // } - // } - // if hasGzip { - // if writer == nil { - // writer, err = d.ingester.Writer(ctx, opts...) - // if err != nil { - // return nil, err - // } - // } - // writer = NewGunzipWriter(writer, writerOpts...) - // } - // return writer, nil - var f func(r io.Reader, w io.Writer, done chan<- error) - - wOpts := content.DefaultWriterOpts() - - di := &DiskImage{ - writer: d.writer, - digester: digest.Canonical.Digester(), - // we take the OutputHash, since the InputHash goes to the passthrough writer, - // which then passes the processed output to us - // hash: wOpts.OutputHash, - } - - // we have to reprocess the opts to find the desc - var wOpts1 ctrcontent.WriterOpts - for _, opt := range opts { - if err := opt(&wOpts1); err != nil { - return nil, err - } - } - // figure out if compression and/or archive exists - desc := wOpts1.Desc - fmt.Printf("%v\n", desc.Annotations["org.opencontainers.image.title"]) - if desc.Annotations["org.opencontainers.image.title"] == "" { - return content.NewIoContentWriter(io.Discard, content.WithOutputHash(desc.Digest)), nil - } - if !d.compressed { - // Without compression send raw output - f = func(r io.Reader, w io.Writer, done chan<- error) { - var err error - b := make([]byte, wOpts.Blocksize) - _, err = io.CopyBuffer(w, r, b) - done <- err - } - } else { - f = func(r io.Reader, w io.Writer, done chan<- error) { - decompressReader, err := findDecompressor(d.sourceImage, r) - if err != nil { - log.Fatal(err) //nolint:revive // this is fine - } - defer decompressReader.Close() - b := make([]byte, wOpts.Blocksize) - _, err = io.CopyBuffer(w, decompressReader, b) - done <- err - } - } - writerOpts := []content.WriterOpt{} - return content.NewPassthroughWriter(di, f, writerOpts...), nil - // return nil, err -} - -// DiskImage -. -type DiskImage struct { - writer io.Writer - digester digest.Digester - size int64 - hash *digest.Digest -} - -func (w *DiskImage) Write(p []byte) (n int, err error) { - n, err = w.writer.Write(p) - if err != nil { - return 0, err - } - w.size += int64(n) - if w.hash == nil { - w.digester.Hash().Write(p[:n]) - } - return -} - -// Close -. -func (w *DiskImage) Close() error { - return nil -} - -// Digest may return empty digest or panics until committed. -func (w *DiskImage) Digest() digest.Digest { - return w.digester.Digest() -} - -// Commit commits the blob (but no roll-back is guaranteed on an error). -// size and expected can be zero-value when unknown. -// Commit always closes the writer, even on error. -// ErrAlreadyExists aborts the writer. -func (w *DiskImage) Commit(context.Context, int64, digest.Digest, ...ctrcontent.Opt) error { - return nil -} - -// Status returns the current state of write. -func (w *DiskImage) Status() (ctrcontent.Status, error) { - return ctrcontent.Status{}, nil -} - -// Truncate updates the size of the target blob. -func (w *DiskImage) Truncate(int64) error { - return nil -} - -// Resolve satisfies the remotes.Resolver portion of oras target.Target. The -// destination store does not resolve refs; oras.Copy only invokes Resolve on -// the source target. -func (d DiskImageStore) Resolve(_ context.Context, _ string) (string, ocispec.Descriptor, error) { - return "", ocispec.Descriptor{}, fmt.Errorf("DiskImageStore does not support Resolve") -} - -// Fetcher satisfies remotes.Resolver. The destination store is never fetched -// from during oras.Copy. -func (d DiskImageStore) Fetcher(_ context.Context, _ string) (remotes.Fetcher, error) { - return nil, fmt.Errorf("DiskImageStore does not support Fetch") -} - -// Pusher returns a pusher that adapts our Ingester-style Writer to the -// remotes.Pusher interface expected by oras.Copy. -func (d DiskImageStore) Pusher(_ context.Context, _ string) (remotes.Pusher, error) { - return diskImagePusher{store: d}, nil -} - -type diskImagePusher struct { - store DiskImageStore -} - -func (p diskImagePusher) Push(ctx context.Context, desc ocispec.Descriptor) (ctrcontent.Writer, error) { - return p.store.Writer(ctx, ctrcontent.WithDescriptor(desc)) -} diff --git a/oci2disk/main.go b/oci2disk/main.go index 520f7bfc..1d1587cc 100644 --- a/oci2disk/main.go +++ b/oci2disk/main.go @@ -13,19 +13,14 @@ func main() { fmt.Printf("OCI2DISK - OCI Container Disk image streamer\n------------------------\n") disk := os.Getenv("DEST_DISK") img := os.Getenv("IMG_URL") - compressedEnv := os.Getenv("COMPRESSED") registryUsername := os.Getenv("REGISTRY_USERNAME") registryPassword := os.Getenv("REGISTRY_PASSWORD") // We can ignore the error and default to false. skipVerify, _ := strconv.ParseBool(os.Getenv("SKIP_VERIFY")) - // We can ignore the error and default compressed to false. - cmp, _ := strconv.ParseBool(compressedEnv) - - // Write the image to disk - err := image.Write(img, disk, cmp, registryUsername, registryPassword, skipVerify) + // Write the image to disk (platform and compression auto-detected) + err := image.Write(img, disk, registryUsername, registryPassword, skipVerify) if err != nil { log.Fatal(err) } - log.Infof("Successfully written [%s] to [%s]", img, disk) } diff --git a/taloscmdline/Dockerfile b/taloscmdline/Dockerfile new file mode 100644 index 00000000..d229a17c --- /dev/null +++ b/taloscmdline/Dockerfile @@ -0,0 +1,11 @@ +FROM golang:1.26-alpine AS taloscmdline +RUN apk add --no-cache ca-certificates +COPY . /src +WORKDIR /src/taloscmdline +RUN --mount=type=cache,sharing=locked,id=gomod,target=/go/pkg/mod/cache \ + --mount=type=cache,sharing=locked,id=goroot,target=/root/.cache/go-build \ + CGO_ENABLED=0 GOOS=linux go build -ldflags "-s -w" -o taloscmdline + +FROM scratch +COPY --from=taloscmdline /src/taloscmdline/taloscmdline /usr/bin/taloscmdline +ENTRYPOINT ["/usr/bin/taloscmdline"] diff --git a/taloscmdline/README.md b/taloscmdline/README.md new file mode 100644 index 00000000..63ef9df1 --- /dev/null +++ b/taloscmdline/README.md @@ -0,0 +1,97 @@ +``` +quay.io/tinkerbell/actions/taloscmdline:latest +``` + +This action sets kernel command line arguments inside the +[Talos Linux](https://www.talos.dev) unified kernel image (UKI) on a disk +that already holds a Talos image. Talos boots through systemd-boot on UEFI, +and the command line lives in the `.cmdline` section of +`EFI/Linux/Talos-.efi` on the `EFI` partition rather than in a text +file. The action rewrites that section the way Talos' own image assembler lays +it out, so the result is a regular Talos UKI with a different command line. + +The typical use is pointing a node at Tinkerbell's metadata service for its +machine configuration: + +```yaml +actions: +- name: "stream-talos-image" + image: quay.io/tinkerbell/actions/oci2disk:latest + timeout: 600 + environment: + DEST_DISK: /dev/sda + IMG_URL: "192.168.1.2:5000/talos/metal-amd64:v1.14.0" +- name: "set-talos-config-url" + image: quay.io/tinkerbell/actions/taloscmdline:latest + timeout: 120 + environment: + DEST_DISK: /dev/sda + KERNEL_ARGS: "talos.config=http://192.168.1.2:7080/2009-04-04/user-data" +``` + +Talos then fetches its machine configuration from that URL on every boot, and +the installer copies `talos.config` into the UKI it generates on upgrades. + +## Environment Variables + +| Variable | Required | Default | Description | +| --- | --- | --- | --- | +| `DEST_DISK` | yes | | Whole disk holding the Talos image, for example `/dev/sda`. The `EFI` partition is located through the GPT and mounted as `vfat`. | +| `KERNEL_ARGS` | yes | | Whitespace separated arguments to set. A `key=value` argument replaces every existing argument with that key; a bare flag is appended once. Other arguments are kept in place. | +| `UKI_PATTERN` | no | `EFI/Linux/Talos-*.efi` | Glob, relative to the EFI partition root, of the UKIs to edit. Every match is updated. | +| `STRIP_SIGNATURE` | no | `false` | Allow editing an Authenticode-signed UKI. The signature is removed, so the file only boots with Secure Boot disabled. Without this the action refuses signed UKIs. | + +The action is idempotent: a UKI whose command line already contains the +requested arguments is left untouched. Each UKI is rewritten to a temporary +file next to it and renamed over the original. + +## Where the arguments live + +Checked against the Image Factory image +`factory.talos.dev/image//v1.13.10/metal-amd64.raw.zst` and the +upstream `v1.14.0` metal image: + +| Item | Location | +| --- | --- | +| Partition | GPT partition 1, name `EFI`, EFI System type, starts at sector 2048 (byte 1048576), about 2 GiB, FAT32. Device `/dev/sda1` or `/dev/nvme0n1p1`. | +| Bootloader | `EFI/boot/BOOTX64.efi` (systemd-boot), menu config `loader/loader.conf`. | +| UKI | `EFI/Linux/Talos-.efi`, about 107 MB, unsigned for non Secure Boot images. | +| Default boot command line | PE section `.cmdline` (section 7), file offset `0x18e00` in the v1.13.10 UKI, 512-byte raw section, 195 bytes used. | +| Reset profile command line | Second `.cmdline` (section 14) after the two `.profile` sections, file offset `0x6694e00`, 242 bytes used. | + +The factory `metal-amd64.raw.zst` image is systemd-boot only; it has no +`BIOS` or GRUB `BOOT` partition. The upstream metal image is dual-boot and +additionally carries those, which only matter for legacy BIOS boots. + +The action logs the section index, offset and size of every command line it +finds, so the exact location on a given disk is always in the action output. + +## What is rewritten + +Talos assembles a UKI by appending data sections (`.osrel`, `.cmdline`, +`.uname`, `.splash`, `.linux`, `.initrd`, profiles) to the systemd stub with +sequential, aligned virtual addresses. A Talos UKI carries one `.cmdline` +for the default boot and another, after the `.profile` sections, for the +"Reset Talos installation" boot entry; the action updates every one of them. +It keeps everything before the first `.cmdline` byte-identical, writes each +new command line with an aligned raw size, moves the later data sections by +the size difference, and recomputes the image size fields. Anything after the last section, such as a certificate +table, is dropped. It refuses to touch a file whose layout does not match +these assumptions, for example when a code section follows `.cmdline`. + +## Limitations + +- Only the UEFI path is changed. Talos dual-boot images also carry + `grub/grub.cfg` on the `BOOT` partition for BIOS boots; that file is not + modified. +- Secure Boot images are signed; rewriting them invalidates the signature and + any TPM PCR policy signature. Use Talos' image factory `extraKernelArgs` for + Secure Boot deployments instead. +- Arguments that legitimately repeat, such as `console=`, are collapsed to + the supplied value when they are part of `KERNEL_ARGS`. + +## Verifying + +``` +talosctl -n read /proc/cmdline +``` diff --git a/taloscmdline/efipart/efipart.go b/taloscmdline/efipart/efipart.go new file mode 100644 index 00000000..dfc31d7d --- /dev/null +++ b/taloscmdline/efipart/efipart.go @@ -0,0 +1,84 @@ +// Package efipart finds a GPT partition by label and names its device node. +package efipart + +import ( + "fmt" + "path/filepath" + "strconv" + "strings" + "unicode" + + diskfs "github.com/diskfs/go-diskfs" + "github.com/diskfs/go-diskfs/partition/gpt" +) + +// Partition is the location of a partition on a disk. +type Partition struct { + // Index is the 1-based partition number. + Index int + Offset int64 + Size int64 +} + +// Locate finds the partition named label in the GPT of diskPath. +func Locate(diskPath, label string) (Partition, error) { + d, err := diskfs.Open(diskPath, diskfs.WithOpenMode(diskfs.ReadOnly)) + if err != nil { + return Partition{}, fmt.Errorf("opening %s: %w", diskPath, err) + } + defer d.Close() + + table, err := d.GetPartitionTable() + if err != nil { + return Partition{}, fmt.Errorf("reading partition table of %s: %w", diskPath, err) + } + + gptTable, ok := table.(*gpt.Table) + if !ok { + return Partition{}, fmt.Errorf("%s has a %s partition table, expected GPT", diskPath, table.Type()) + } + + sectorSize := int64(gptTable.LogicalSectorSize) + if sectorSize == 0 { + sectorSize = d.LogicalBlocksize + } + + for i, p := range gptTable.Partitions { + if p == nil || p.Name != label { + continue + } + + index := p.Index + if index == 0 { + index = i + 1 + } + + return Partition{ + Index: index, + Offset: int64(p.Start) * sectorSize, + Size: int64(p.End-p.Start+1) * sectorSize, + }, nil + } + + return Partition{}, fmt.Errorf("no partition labelled %s found on %s", label, diskPath) +} + +// Device returns the device node of partition index on disk, following the +// kernel's naming: /dev/sda1, /dev/nvme0n1p1, /dev/mmcblk0p2, and the +// -partN suffix used by /dev/disk/by-* symlinks. +func Device(disk string, index int) string { + n := strconv.Itoa(index) + + for _, byDir := range []string{"/by-id/", "/by-path/", "/by-uuid/", "/by-partuuid/", "/by-partlabel/", "/by-diskseq/"} { + if strings.Contains(disk, byDir) { + return disk + "-part" + n + } + } + + base := filepath.Base(disk) + if base != "" && unicode.IsDigit(rune(base[len(base)-1])) { + return disk + "p" + n + } + + return disk + n +} diff --git a/taloscmdline/efipart/efipart_test.go b/taloscmdline/efipart/efipart_test.go new file mode 100644 index 00000000..1efc4e7c --- /dev/null +++ b/taloscmdline/efipart/efipart_test.go @@ -0,0 +1,77 @@ +package efipart + +import ( + "path/filepath" + "testing" + + diskfs "github.com/diskfs/go-diskfs" + "github.com/diskfs/go-diskfs/partition/gpt" +) + +func newDisk(t *testing.T, names ...string) string { + t.Helper() + + path := filepath.Join(t.TempDir(), "disk.img") + d, err := diskfs.Create(path, 8*1024*1024, diskfs.SectorSize512) + if err != nil { + t.Fatal(err) + } + + var parts []*gpt.Partition + start := uint64(2048) + for i, name := range names { + parts = append(parts, &gpt.Partition{Index: i + 1, Start: start, End: start + 2047, Type: gpt.LinuxFilesystem, Name: name}) + start += 2048 + } + if err := d.Partition(&gpt.Table{Partitions: parts, LogicalSectorSize: 512, PhysicalSectorSize: 512, ProtectiveMBR: true}); err != nil { + t.Fatal(err) + } + if err := d.Close(); err != nil { + t.Fatal(err) + } + + return path +} + +func TestLocateReturnsIndexAndOffset(t *testing.T) { + path := newDisk(t, "EFI", "BIOS", "BOOT", "META") + + p, err := Locate(path, "BOOT") + if err != nil { + t.Fatalf("Locate() error: %v", err) + } + if p.Index != 3 || p.Offset != (2048+2*2048)*512 || p.Size != 2048*512 { + t.Fatalf("unexpected partition: %+v", p) + } +} + +func TestLocateMissingLabel(t *testing.T) { + path := newDisk(t, "BOOT") + + if _, err := Locate(path, "EFI"); err == nil { + t.Fatal("expected an error for a missing label") + } +} + +func TestDevice(t *testing.T) { + tests := []struct { + disk string + index int + want string + }{ + {"/dev/sda", 1, "/dev/sda1"}, + {"/dev/vdb", 3, "/dev/vdb3"}, + {"/dev/nvme0n1", 1, "/dev/nvme0n1p1"}, + {"/dev/mmcblk0", 2, "/dev/mmcblk0p2"}, + {"/dev/loop0", 1, "/dev/loop0p1"}, + {"/dev/disk/by-id/nvme-Foo_123", 1, "/dev/disk/by-id/nvme-Foo_123-part1"}, + {"/dev/disk/by-path/pci-0000:00:1f.2-ata-1", 2, "/dev/disk/by-path/pci-0000:00:1f.2-ata-1-part2"}, + } + for _, tt := range tests { + t.Run(tt.disk, func(t *testing.T) { + if got := Device(tt.disk, tt.index); got != tt.want { + t.Fatalf("Device(%q, %d) = %q, want %q", tt.disk, tt.index, got, tt.want) + } + }) + } +} diff --git a/taloscmdline/kargs/kargs.go b/taloscmdline/kargs/kargs.go new file mode 100644 index 00000000..960d2f01 --- /dev/null +++ b/taloscmdline/kargs/kargs.go @@ -0,0 +1,50 @@ +// Package kargs merges kernel command line arguments. +package kargs + +import ( + "slices" + "strings" +) + +// Merge applies the arguments in set to the existing command line. A +// key=value argument replaces every existing argument with the same key, +// keeping the position of the first one; a bare flag is appended once. +// Whitespace is normalised to single spaces. +func Merge(existing, set string) string { + current := strings.Fields(existing) + + for _, arg := range strings.Fields(set) { + key, _, hasValue := strings.Cut(arg, "=") + if !hasValue { + if !slices.Contains(current, arg) { + current = append(current, arg) + } + + continue + } + + replaced := false + kept := current[:0] + + for _, c := range current { + if cKey, _, cHasValue := strings.Cut(c, "="); cHasValue && cKey == key { + if !replaced { + kept = append(kept, arg) + replaced = true + } + + continue + } + + kept = append(kept, c) + } + + current = kept + + if !replaced { + current = append(current, arg) + } + } + + return strings.Join(current, " ") +} diff --git a/taloscmdline/kargs/kargs_test.go b/taloscmdline/kargs/kargs_test.go new file mode 100644 index 00000000..85e5d2f5 --- /dev/null +++ b/taloscmdline/kargs/kargs_test.go @@ -0,0 +1,31 @@ +package kargs + +import "testing" + +func TestMerge(t *testing.T) { + tests := []struct { + name string + existing string + set string + want string + }{ + {"append new key", "console=ttyS0 init_on_alloc=1", "talos.config=http://x/y", "console=ttyS0 init_on_alloc=1 talos.config=http://x/y"}, + {"replace existing key in place", "a=1 talos.config=old b=2", "talos.config=new", "a=1 talos.config=new b=2"}, + {"replace duplicate keys keeps one", "talos.config=old1 x talos.config=old2", "talos.config=new", "talos.config=new x"}, + {"flag appended once", "quiet", "quiet splash", "quiet splash"}, + {"multiple args", "a=1", "b=2 c", "a=1 b=2 c"}, + {"whitespace normalised", " a=1 \t b=2 ", " c=3 ", "a=1 b=2 c=3"}, + {"empty existing", "", "a=1", "a=1"}, + {"empty set keeps existing", "a=1 b", "", "a=1 b"}, + {"value containing equals and colons", "a=1", "talos.config=http://h:7080/2009-04-04/user-data?x=1", "a=1 talos.config=http://h:7080/2009-04-04/user-data?x=1"}, + {"idempotent", "a=1 talos.config=new", "talos.config=new", "a=1 talos.config=new"}, + {"untouched keys with same prefix survive", "talos.platform=metal", "talos.config=u", "talos.platform=metal talos.config=u"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := Merge(tt.existing, tt.set); got != tt.want { + t.Fatalf("Merge(%q, %q) = %q, want %q", tt.existing, tt.set, got, tt.want) + } + }) + } +} diff --git a/taloscmdline/main.go b/taloscmdline/main.go new file mode 100644 index 00000000..da292b64 --- /dev/null +++ b/taloscmdline/main.go @@ -0,0 +1,158 @@ +package main + +import ( + "errors" + "fmt" + "log/slog" + "os" + "path/filepath" + "sort" + "strconv" + "strings" + "syscall" + + "github.com/tinkerbell/actions/taloscmdline/efipart" + "github.com/tinkerbell/actions/taloscmdline/kargs" + "github.com/tinkerbell/actions/taloscmdline/uki" +) + +const ( + mountAction = "/mountAction" + efiLabel = "EFI" + efiFilesystem = "vfat" + defaultUKIPattern = "EFI/Linux/Talos-*.efi" +) + +func main() { + logger := slog.New(slog.NewTextHandler(os.Stdout, nil)) + logger.Info("TALOSCMDLINE - Set kernel arguments in Talos unified kernel images") + + if err := run(logger); err != nil { + logger.Error(err.Error()) + os.Exit(1) + } +} + +func run(logger *slog.Logger) error { + disk := os.Getenv("DEST_DISK") + if disk == "" { + return errors.New("no block device specified with environment variable [DEST_DISK]") + } + + args := strings.TrimSpace(os.Getenv("KERNEL_ARGS")) + if args == "" { + return errors.New("no kernel arguments specified with environment variable [KERNEL_ARGS]") + } + + pattern := os.Getenv("UKI_PATTERN") + if pattern == "" { + pattern = defaultUKIPattern + } + + if filepath.IsAbs(pattern) || strings.Contains(pattern, "..") { + return fmt.Errorf("UKI_PATTERN must be relative to the EFI partition, got %q", pattern) + } + + // The error can be ignored: anything unparsable means false. + strip, _ := strconv.ParseBool(os.Getenv("STRIP_SIGNATURE")) + + partition, err := efipart.Locate(disk, efiLabel) + if err != nil { + return err + } + + device := efipart.Device(disk, partition.Index) + logger.Info("Found EFI partition", "disk", disk, "partition", partition.Index, "device", device, "offset", partition.Offset, "size", partition.Size) + + if err := os.MkdirAll(mountAction, 0o755); err != nil { + return fmt.Errorf("creating mountpoint %s: %w", mountAction, err) + } + + if err := syscall.Mount(device, mountAction, efiFilesystem, 0, ""); err != nil { + return fmt.Errorf("mounting %s on %s as %s: %w", device, mountAction, efiFilesystem, err) + } + + defer func() { + if err := syscall.Unmount(mountAction, 0); err != nil { + logger.Error("Error unmounting device", "source", device, "destination", mountAction, "error", err) + } else { + logger.Info("Unmounted device", "source", device, "destination", mountAction) + } + }() + logger.Info("Mounted device", "source", device, "destination", mountAction) + + updated, unchanged, err := editUKIs(mountAction, pattern, args, strip, logger) + if err != nil { + return err + } + + logger.Info("Finished", "updated", updated, "unchanged", unchanged) + + return nil +} + +// editUKIs merges args into the command line of every UKI under root that +// matches pattern. Files whose command line already contains the arguments +// are left untouched. +func editUKIs(root, pattern, args string, strip bool, logger *slog.Logger) (updated, unchanged int, err error) { + matches, err := filepath.Glob(filepath.Join(root, pattern)) + if err != nil { + return 0, 0, fmt.Errorf("invalid UKI pattern %q: %w", pattern, err) + } + + if len(matches) == 0 { + return 0, 0, fmt.Errorf("no UKI matching %q found on the EFI partition", pattern) + } + + sort.Strings(matches) + + for _, path := range matches { + rel, _ := filepath.Rel(root, path) + + info, err := uki.Inspect(path) + if err != nil { + return updated, unchanged, err + } + + merged := make([]string, len(info.Cmdlines)) + changed := false + + for i, current := range info.Cmdlines { + merged[i] = kargs.Merge(current, args) + if merged[i] != current { + changed = true + } + } + + for i, loc := range info.Locations { + logger.Info("Found kernel command line", "uki", rel, "cmdline", i, "section", loc.Section, "offset", fmt.Sprintf("%#x", loc.Offset), "bytes", loc.Size) + } + + if !changed { + logger.Info("UKI already has the requested arguments", "uki", rel, "cmdline", info.Cmdlines[0]) + unchanged++ + + continue + } + + if info.Signed { + if !strip { + return updated, unchanged, fmt.Errorf("%s: %w; set STRIP_SIGNATURE=true to remove it (the UKI will then only boot with Secure Boot disabled)", rel, uki.ErrSigned) + } + + logger.Warn("Removing Authenticode signature; the UKI will not boot with Secure Boot enabled", "uki", rel) + } + + if err := uki.Rewrite(path, merged, uki.Options{StripSignature: strip}); err != nil { + return updated, unchanged, err + } + + for i := range merged { + logger.Info("Updated UKI", "uki", rel, "cmdline", i, "old", info.Cmdlines[i], "new", merged[i]) + } + + updated++ + } + + return updated, unchanged, nil +} diff --git a/taloscmdline/main_test.go b/taloscmdline/main_test.go new file mode 100644 index 00000000..7968731e --- /dev/null +++ b/taloscmdline/main_test.go @@ -0,0 +1,121 @@ +package main + +import ( + "io" + "log/slog" + "os" + "path/filepath" + "testing" + + "github.com/tinkerbell/actions/taloscmdline/uki" + "github.com/tinkerbell/actions/taloscmdline/uki/ukitest" +) + +func quietLogger() *slog.Logger { + return slog.New(slog.NewTextHandler(io.Discard, nil)) +} + +func newEFITree(t *testing.T) string { + t.Helper() + + root := t.TempDir() + for _, name := range []string{"EFI/Linux/Talos-v1.14.0.efi", "EFI/Linux/Talos-v1.14.0~1.efi"} { + path := filepath.Join(root, name) + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + if err := ukitest.Write(path, ukitest.TalosLike("talos.platform=metal console=ttyS0"), ukitest.Options{}); err != nil { + t.Fatal(err) + } + } + bootloader := filepath.Join(root, "EFI/boot/BOOTX64.efi") + if err := os.MkdirAll(filepath.Dir(bootloader), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(bootloader, []byte("not a uki"), 0o644); err != nil { + t.Fatal(err) + } + + return root +} + +func TestEditUKIsUpdatesEveryMatchAndIsIdempotent(t *testing.T) { + root := newEFITree(t) + args := "talos.config=http://192.168.1.2:7080/2009-04-04/user-data" + + updated, unchanged, err := editUKIs(root, defaultUKIPattern, args, false, quietLogger()) + if err != nil { + t.Fatalf("editUKIs() error: %v", err) + } + if updated != 2 || unchanged != 0 { + t.Fatalf("expected 2 updated / 0 unchanged, got %d / %d", updated, unchanged) + } + + for _, name := range []string{"EFI/Linux/Talos-v1.14.0.efi", "EFI/Linux/Talos-v1.14.0~1.efi"} { + info, err := uki.Inspect(filepath.Join(root, name)) + if err != nil { + t.Fatalf("Inspect(%s) error: %v", name, err) + } + want := []string{ + "talos.platform=metal console=ttyS0 " + args, + "talos.platform=metal console=ttyS0" + ukitest.ResetSuffix + " " + args, + } + if len(info.Cmdlines) != 2 || info.Cmdlines[0] != want[0] || info.Cmdlines[1] != want[1] { + t.Fatalf("%s: unexpected cmdlines %q, want %q", name, info.Cmdlines, want) + } + } + + if got, _ := os.ReadFile(filepath.Join(root, "EFI/boot/BOOTX64.efi")); string(got) != "not a uki" { + t.Fatal("expected files outside the pattern to be untouched") + } + + updated, unchanged, err = editUKIs(root, defaultUKIPattern, args, false, quietLogger()) + if err != nil { + t.Fatalf("second editUKIs() error: %v", err) + } + if updated != 0 || unchanged != 2 { + t.Fatalf("expected 0 updated / 2 unchanged on rerun, got %d / %d", updated, unchanged) + } +} + +func TestEditUKIsFailsWithoutMatches(t *testing.T) { + root := t.TempDir() + + if _, _, err := editUKIs(root, defaultUKIPattern, "a=1", false, quietLogger()); err == nil { + t.Fatal("expected an error when no UKI matches") + } +} + +func TestEditUKIsRefusesSignedUnlessStripping(t *testing.T) { + root := t.TempDir() + path := filepath.Join(root, "EFI/Linux/Talos-v1.14.0.efi") + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + if err := ukitest.Write(path, ukitest.TalosLike("a=1"), ukitest.Options{Signed: true}); err != nil { + t.Fatal(err) + } + + if _, _, err := editUKIs(root, defaultUKIPattern, "a=2", false, quietLogger()); err == nil { + t.Fatal("expected an error for a signed UKI") + } + + updated, _, err := editUKIs(root, defaultUKIPattern, "a=2", true, quietLogger()) + if err != nil || updated != 1 { + t.Fatalf("expected the signed UKI to be rewritten with stripping, updated=%d err=%v", updated, err) + } +} + +func TestRunRequiresInputs(t *testing.T) { + t.Setenv("DEST_DISK", "") + t.Setenv("KERNEL_ARGS", "a=1") + if err := run(quietLogger()); err == nil { + t.Fatal("expected an error without DEST_DISK") + } + + t.Setenv("DEST_DISK", "/dev/null") + t.Setenv("KERNEL_ARGS", "") + if err := run(quietLogger()); err == nil { + t.Fatal("expected an error without KERNEL_ARGS") + } +} diff --git a/taloscmdline/uki/uki.go b/taloscmdline/uki/uki.go new file mode 100644 index 00000000..af207ddf --- /dev/null +++ b/taloscmdline/uki/uki.go @@ -0,0 +1,407 @@ +// Package uki reads and rewrites the kernel command line stored in the +// .cmdline sections of a unified kernel image. +// +// Talos assembles a UKI by appending data-only sections (.osrel, .cmdline, +// .uname, .splash, .linux, .initrd, then .profile sections with their own +// .cmdline) to the systemd stub, laying them out sequentially with +// SectionAlignment for virtual addresses and FileAlignment for raw sizes. +// Rewrite keeps everything before the first .cmdline byte-identical and +// re-lays out that section and everything after it with the same rules, so +// the result matches what Talos' own assembler would produce. +package uki + +import ( + "bytes" + "debug/pe" + "encoding/binary" + "errors" + "fmt" + "io" + "os" + "path/filepath" +) + +// SectionName is the PE section holding a kernel command line. +const SectionName = ".cmdline" + +// ErrSigned is returned when a UKI carries an Authenticode signature and +// stripping it was not allowed. +var ErrSigned = errors.New("UKI carries an Authenticode signature") + +// Info describes a UKI. +type Info struct { + Path string + // Cmdlines holds every .cmdline section in file order: the default boot + // first, then one per boot profile that overrides it. + Cmdlines []string + // Locations describes where each entry of Cmdlines is stored, in the + // same order. + Locations []Location + Signed bool +} + +// Location is where a .cmdline section lives inside the UKI file. +type Location struct { + // Section is the index in the PE section table. + Section int + // Offset is the byte offset of the section data in the file. + Offset int64 + // Size is the length of the command line in bytes. + Size int64 +} + +// Options control Rewrite. +type Options struct { + // StripSignature allows rewriting a signed UKI; the signature is removed + // and the file will no longer boot with Secure Boot enabled. + StripSignature bool +} + +const ( + dosHeaderSize = 0x40 + peSignatureSize = 4 + fileHeaderSize = 20 + sectionHeaderSize = 40 + optionalHeaderMagic = 0x20b + unsafeCharacteristic = pe.IMAGE_SCN_CNT_CODE | pe.IMAGE_SCN_MEM_EXECUTE | pe.IMAGE_SCN_MEM_WRITE + + // Offsets inside the PE32+ optional header. + offSizeOfCode = 4 + offSizeOfInitializedData = 8 + offSizeOfImage = 56 + offCheckSum = 64 + offDataDirectory = 112 + + // Offsets inside a section header. + offVirtualSize = 8 + offVirtualAddress = 12 + offSizeOfRawData = 16 + offPointerToRawData = 20 + + tmpSuffix = ".tmp" +) + +// layout is the parsed header structure of a UKI. +type layout struct { + optional pe.OptionalHeader64 + sections []pe.SectionHeader + optionalOff uint32 + sectionTable uint32 + // cmdlines are the indexes of the .cmdline sections, ascending. + cmdlines []int +} + +func (l *layout) signed() bool { + dir := l.optional.DataDirectory[pe.IMAGE_DIRECTORY_ENTRY_SECURITY] + + return dir.VirtualAddress != 0 && dir.Size != 0 +} + +func (l *layout) first() int { + return l.cmdlines[0] +} + +func align(v, a uint64) uint64 { + return (v + a - 1) &^ (a - 1) +} + +func parse(r io.ReaderAt) (*layout, error) { + peFile, err := pe.NewFile(r) + if err != nil { + return nil, fmt.Errorf("parsing PE headers: %w", err) + } + defer peFile.Close() + + optional, ok := peFile.OptionalHeader.(*pe.OptionalHeader64) + if !ok || optional.Magic != optionalHeaderMagic { + return nil, errors.New("only PE32+ images are supported") + } + + var dos [dosHeaderSize]byte + if _, err := r.ReadAt(dos[:], 0); err != nil { + return nil, fmt.Errorf("reading DOS header: %w", err) + } + + peOffset := binary.LittleEndian.Uint32(dos[0x3c:]) + + l := &layout{ + optional: *optional, + optionalOff: peOffset + peSignatureSize + fileHeaderSize, + } + l.sectionTable = l.optionalOff + uint32(peFile.SizeOfOptionalHeader) + + for i, s := range peFile.Sections { + l.sections = append(l.sections, s.SectionHeader) + + if s.Name == SectionName { + l.cmdlines = append(l.cmdlines, i) + } + } + + if len(l.cmdlines) == 0 { + return nil, fmt.Errorf("no %s section found", SectionName) + } + + return l, l.validate() +} + +// validate checks the assumptions the rewrite relies on: sections are laid +// out in table order, and everything from the first .cmdline on is plain +// data that can be moved. +func (l *layout) validate() error { + first := l.sections[l.first()] + + if uint64(l.sectionTable)+uint64(sectionHeaderSize)*uint64(len(l.sections)) > uint64(first.Offset) { + return errors.New("section table overlaps the first .cmdline section") + } + + for i, s := range l.sections { + switch { + case i < l.first(): + if uint64(s.Offset)+uint64(s.Size) > uint64(first.Offset) { + return fmt.Errorf("section %s is not stored before %s", s.Name, SectionName) + } + case i > l.first(): + prev := l.sections[i-1] + + if s.Offset < prev.Offset+prev.Size { + return fmt.Errorf("section %s is not stored after %s in table order", s.Name, prev.Name) + } + + if s.VirtualAddress < prev.VirtualAddress+prev.VirtualSize { + return fmt.Errorf("section %s virtual address overlaps %s", s.Name, prev.Name) + } + + fallthrough + default: + if s.Characteristics&unsafeCharacteristic != 0 || s.Name == ".reloc" { + return fmt.Errorf("section %s after %s is not plain data; refusing to relocate it", s.Name, SectionName) + } + } + } + + return nil +} + +func readCmdline(r io.ReaderAt, s pe.SectionHeader) (string, error) { + n := s.VirtualSize + if s.Size < n { + n = s.Size + } + + buf := make([]byte, n) + if _, err := r.ReadAt(buf, int64(s.Offset)); err != nil { + return "", fmt.Errorf("reading %s: %w", SectionName, err) + } + + return string(bytes.TrimSpace(bytes.TrimRight(buf, "\x00"))), nil +} + +// Inspect returns the command lines of the UKI at path and whether it is signed. +func Inspect(path string) (Info, error) { + f, err := os.Open(path) + if err != nil { + return Info{}, err + } + defer f.Close() + + l, err := parse(f) + if err != nil { + return Info{}, fmt.Errorf("%s: %w", path, err) + } + + info := Info{Path: path, Signed: l.signed()} + + for _, i := range l.cmdlines { + cmdline, err := readCmdline(f, l.sections[i]) + if err != nil { + return Info{}, fmt.Errorf("%s: %w", path, err) + } + + info.Cmdlines = append(info.Cmdlines, cmdline) + info.Locations = append(info.Locations, Location{ + Section: i, + Offset: int64(l.sections[i].Offset), + Size: int64(l.sections[i].VirtualSize), + }) + } + + return info, nil +} + +// Rewrite replaces the command lines of the UKI at path, one per .cmdline +// section in file order. The new file is written next to the original and +// renamed over it. +func Rewrite(path string, cmdlines []string, opts Options) error { + in, err := os.Open(path) + if err != nil { + return err + } + defer in.Close() + + l, err := parse(in) + if err != nil { + return fmt.Errorf("%s: %w", path, err) + } + + if len(cmdlines) != len(l.cmdlines) { + return fmt.Errorf("%s: %d command lines given for %d %s sections", path, len(cmdlines), len(l.cmdlines), SectionName) + } + + if l.signed() && !opts.StripSignature { + return fmt.Errorf("%s: %w", path, ErrSigned) + } + + tmp := path + tmpSuffix + + out, err := os.OpenFile(tmp, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o644) + if err != nil { + return fmt.Errorf("creating %s: %w", tmp, err) + } + + if err := write(in, out, l, cmdlines); err != nil { + out.Close() + os.Remove(tmp) + + return fmt.Errorf("%s: %w", path, err) + } + + if err := out.Sync(); err != nil { + out.Close() + os.Remove(tmp) + + return fmt.Errorf("syncing %s: %w", tmp, err) + } + + if err := out.Close(); err != nil { + os.Remove(tmp) + + return fmt.Errorf("closing %s: %w", tmp, err) + } + + if err := os.Rename(tmp, path); err != nil { + os.Remove(tmp) + + return fmt.Errorf("replacing %s: %w", path, err) + } + + syncDir(filepath.Dir(path)) + + return nil +} + +// write produces the rewritten image: the original bytes up to the first +// .cmdline with patched headers, then every section from there on at its new +// offset. Anything after the last section (an Authenticode certificate +// table) is dropped. +func write(in io.ReaderAt, out io.Writer, l *layout, cmdlines []string) error { + fileAlignment := uint64(l.optional.FileAlignment) + sectionAlignment := uint64(l.optional.SectionAlignment) + + if fileAlignment == 0 || sectionAlignment == 0 { + return errors.New("invalid alignment in optional header") + } + + updated := make([]pe.SectionHeader, len(l.sections)) + copy(updated, l.sections) + + newData := make(map[int][]byte, len(cmdlines)) + + for n, i := range l.cmdlines { + data := []byte(cmdlines[n]) + newData[i] = data + updated[i].VirtualSize = uint32(len(data)) + updated[i].Size = uint32(align(uint64(len(data)), fileAlignment)) + } + + first := l.first() + va := uint64(updated[first].VirtualAddress) + offset := uint64(updated[first].Offset) + + for i := first; i < len(updated); i++ { + updated[i].VirtualAddress = uint32(va) + updated[i].Offset = uint32(offset) + + va = align(va+uint64(updated[i].VirtualSize), sectionAlignment) + offset += uint64(updated[i].Size) + } + + last := updated[len(updated)-1] + sizeOfImage := align(uint64(last.VirtualAddress)+uint64(last.VirtualSize), sectionAlignment) + + var sizeOfCode, sizeOfData uint32 + + for _, s := range updated { + if s.Characteristics&pe.IMAGE_SCN_CNT_INITIALIZED_DATA != 0 { + sizeOfData += s.Size + } else { + sizeOfCode += s.Size + } + } + + // Headers and the sections before the first .cmdline, patched in place. + prefix := make([]byte, l.sections[first].Offset) + if _, err := in.ReadAt(prefix, 0); err != nil { + return fmt.Errorf("reading headers: %w", err) + } + + putU32 := func(off uint32, v uint32) { + binary.LittleEndian.PutUint32(prefix[off:off+4], v) + } + + putU32(l.optionalOff+offSizeOfCode, sizeOfCode) + putU32(l.optionalOff+offSizeOfInitializedData, sizeOfData) + putU32(l.optionalOff+offSizeOfImage, uint32(sizeOfImage)) + putU32(l.optionalOff+offCheckSum, 0) + + securityDir := l.optionalOff + offDataDirectory + 8*pe.IMAGE_DIRECTORY_ENTRY_SECURITY + putU32(securityDir, 0) + putU32(securityDir+4, 0) + + for i := first; i < len(updated); i++ { + entry := l.sectionTable + uint32(i)*sectionHeaderSize + + putU32(entry+offVirtualSize, updated[i].VirtualSize) + putU32(entry+offVirtualAddress, updated[i].VirtualAddress) + putU32(entry+offSizeOfRawData, updated[i].Size) + putU32(entry+offPointerToRawData, updated[i].Offset) + } + + if _, err := out.Write(prefix); err != nil { + return fmt.Errorf("writing headers: %w", err) + } + + for i := first; i < len(updated); i++ { + if data, ok := newData[i]; ok { + if _, err := out.Write(data); err != nil { + return fmt.Errorf("writing %s: %w", SectionName, err) + } + + if _, err := out.Write(make([]byte, int(updated[i].Size)-len(data))); err != nil { + return fmt.Errorf("padding %s: %w", SectionName, err) + } + + continue + } + + orig := l.sections[i] + + if _, err := io.Copy(out, io.NewSectionReader(in, int64(orig.Offset), int64(orig.Size))); err != nil { + return fmt.Errorf("copying section %s: %w", orig.Name, err) + } + } + + return nil +} + +// syncDir flushes the directory entry after the rename; vfat may not support +// it, so failures are ignored. +func syncDir(dir string) { + d, err := os.Open(dir) + if err != nil { + return + } + defer d.Close() + + _ = d.Sync() +} diff --git a/taloscmdline/uki/uki_test.go b/taloscmdline/uki/uki_test.go new file mode 100644 index 00000000..f0a169aa --- /dev/null +++ b/taloscmdline/uki/uki_test.go @@ -0,0 +1,360 @@ +package uki + +import ( + "bytes" + "debug/pe" + "errors" + "io" + "os" + "path/filepath" + "reflect" + "strings" + "testing" + + "github.com/tinkerbell/actions/taloscmdline/uki/ukitest" +) + +func build(t *testing.T, sections []ukitest.Section, opts ukitest.Options) string { + t.Helper() + + path := filepath.Join(t.TempDir(), "Talos-v1.14.0.efi") + if err := ukitest.Write(path, sections, opts); err != nil { + t.Fatal(err) + } + + return path +} + +func nthSectionData(t *testing.T, f *pe.File, i int) []byte { + t.Helper() + + s := f.Sections[i] + data, err := io.ReadAll(io.LimitReader(s.Open(), int64(s.VirtualSize))) + if err != nil { + t.Fatal(err) + } + + return data +} + +func openPE(t *testing.T, path string) *pe.File { + t.Helper() + + f, err := pe.Open(path) + if err != nil { + t.Fatalf("parsing %s: %v", path, err) + } + t.Cleanup(func() { f.Close() }) + + return f +} + +// assertLayout checks the invariants sd-stub and UEFI loaders rely on. +func assertLayout(t *testing.T, path string, cmdlines []string, original []ukitest.Section) { + t.Helper() + + f := openPE(t, path) + + if len(f.Sections) != len(original) { + t.Fatalf("expected %d sections, got %d", len(original), len(f.Sections)) + } + + header, ok := f.OptionalHeader.(*pe.OptionalHeader64) + if !ok { + t.Fatal("expected PE32+") + } + + st, err := os.Stat(path) + if err != nil { + t.Fatal(err) + } + + var prev *pe.Section + cmdlineIndex := 0 + for i, s := range f.Sections { + want := original[i] + if s.Name != want.Name { + t.Fatalf("section %d: expected %s, got %s", i, want.Name, s.Name) + } + + wantData := want.Data + if s.Name == ".cmdline" { + wantData = []byte(cmdlines[cmdlineIndex]) + cmdlineIndex++ + } + + if got := nthSectionData(t, f, i); !bytes.Equal(got, wantData) { + t.Fatalf("section %s: content changed", s.Name) + } + if int(s.VirtualSize) != len(wantData) { + t.Fatalf("section %s: VirtualSize %d, want %d", s.Name, s.VirtualSize, len(wantData)) + } + if s.Size%ukitest.FileAlignment != 0 || s.Size < s.VirtualSize { + t.Fatalf("section %s: raw size %d not aligned or too small", s.Name, s.Size) + } + if s.VirtualAddress%ukitest.SectionAlignment != 0 { + t.Fatalf("section %s: VirtualAddress %#x not aligned", s.Name, s.VirtualAddress) + } + if s.Offset%ukitest.FileAlignment != 0 { + t.Fatalf("section %s: offset %#x not aligned", s.Name, s.Offset) + } + if prev != nil { + if s.VirtualAddress < prev.VirtualAddress+prev.VirtualSize { + t.Fatalf("section %s: VirtualAddress %#x overlaps %s", s.Name, s.VirtualAddress, prev.Name) + } + if s.Offset != prev.Offset+prev.Size { + t.Fatalf("section %s: offset %#x not contiguous after %s", s.Name, s.Offset, prev.Name) + } + } + + prev = s + } + + last := f.Sections[len(f.Sections)-1] + wantImage := (uint64(last.VirtualAddress) + uint64(last.VirtualSize) + ukitest.SectionAlignment - 1) &^ (ukitest.SectionAlignment - 1) + if uint64(header.SizeOfImage) != wantImage { + t.Fatalf("SizeOfImage %#x, want %#x", header.SizeOfImage, wantImage) + } + if int64(last.Offset+last.Size) != st.Size() { + t.Fatalf("file size %d, want %d (no trailing data)", st.Size(), last.Offset+last.Size) + } + if header.CheckSum != 0 { + t.Fatalf("expected CheckSum to be zeroed, got %#x", header.CheckSum) + } + if sec := header.DataDirectory[pe.IMAGE_DIRECTORY_ENTRY_SECURITY]; sec.VirtualAddress != 0 || sec.Size != 0 { + t.Fatalf("expected security directory to be empty, got %+v", sec) + } +} + +func TestInspectReadsCmdline(t *testing.T) { + path := build(t, ukitest.TalosLike("talos.platform=metal console=ttyS0"), ukitest.Options{}) + + info, err := Inspect(path) + if err != nil { + t.Fatalf("Inspect() error: %v", err) + } + want := []string{"talos.platform=metal console=ttyS0", "talos.platform=metal console=ttyS0" + ukitest.ResetSuffix} + if len(info.Cmdlines) != 2 || info.Cmdlines[0] != want[0] || info.Cmdlines[1] != want[1] { + t.Fatalf("unexpected cmdlines %q, want %q", info.Cmdlines, want) + } + if info.Signed { + t.Fatal("expected an unsigned file") + } +} + +func TestInspectReportsSignature(t *testing.T) { + path := build(t, ukitest.TalosLike("a=1"), ukitest.Options{Signed: true}) + + info, err := Inspect(path) + if err != nil { + t.Fatalf("Inspect() error: %v", err) + } + if !info.Signed { + t.Fatal("expected Signed to be reported") + } +} + +func TestRewriteGrowsCmdlineAcrossAlignment(t *testing.T) { + original := ukitest.TalosLike(strings.Repeat("a", 100)) + path := build(t, original, ukitest.Options{}) + before := stubPrefix(t, path) + + newCmdlines := []string{strings.Repeat("b", 100) + " talos.config=" + strings.Repeat("c", 500), "reset=1 " + strings.Repeat("d", 700)} + if err := Rewrite(path, newCmdlines, Options{}); err != nil { + t.Fatalf("Rewrite() error: %v", err) + } + + assertLayout(t, path, newCmdlines, original) + + if !bytes.Equal(stubPrefix(t, path), before) { + t.Fatal("expected sections before .cmdline to be byte-identical") + } +} + +func TestRewriteShrinksCmdline(t *testing.T) { + original := ukitest.TalosLike(strings.Repeat("a", 1500)) + path := build(t, original, ukitest.Options{}) + + if err := Rewrite(path, []string{"short=1", "short=2"}, Options{}); err != nil { + t.Fatalf("Rewrite() error: %v", err) + } + + assertLayout(t, path, []string{"short=1", "short=2"}, original) +} + +func TestRewriteSameRawSizeKeepsLaterOffsets(t *testing.T) { + original := ukitest.TalosLike("a=1 b=2") + path := build(t, original, ukitest.Options{}) + before := openPE(t, path) + beforeInitrd := sectionOffset(before, ".initrd") + + if err := Rewrite(path, []string{"a=1 b=2 c=3", "a=1 b=2" + ukitest.ResetSuffix}, Options{}); err != nil { + t.Fatalf("Rewrite() error: %v", err) + } + + assertLayout(t, path, []string{"a=1 b=2 c=3", "a=1 b=2" + ukitest.ResetSuffix}, original) + + if got := sectionOffset(openPE(t, path), ".initrd"); got != beforeInitrd { + t.Fatalf(".initrd moved from %#x to %#x although the raw size did not change", beforeInitrd, got) + } +} + +func TestRewriteRefusesSignedWithoutStrip(t *testing.T) { + path := build(t, ukitest.TalosLike("a=1"), ukitest.Options{Signed: true}) + before, _ := os.ReadFile(path) + + err := Rewrite(path, []string{"a=2", "a=2" + ukitest.ResetSuffix}, Options{}) + if !errors.Is(err, ErrSigned) { + t.Fatalf("expected ErrSigned, got %v", err) + } + + after, _ := os.ReadFile(path) + if !bytes.Equal(before, after) { + t.Fatal("expected the file to be untouched") + } + assertNoTempFiles(t, filepath.Dir(path)) +} + +func TestRewriteStripsSignatureWhenAllowed(t *testing.T) { + original := ukitest.TalosLike("a=1") + path := build(t, original, ukitest.Options{Signed: true}) + + if err := Rewrite(path, []string{"a=2", "a=2" + ukitest.ResetSuffix}, Options{StripSignature: true}); err != nil { + t.Fatalf("Rewrite() error: %v", err) + } + + assertLayout(t, path, []string{"a=2", "a=2" + ukitest.ResetSuffix}, original) + + info, err := Inspect(path) + if err != nil || info.Signed { + t.Fatalf("expected an unsigned file after stripping, signed=%v err=%v", info.Signed, err) + } +} + +func TestRewriteRefusesCodeAfterCmdline(t *testing.T) { + sections := []ukitest.Section{ + {Name: ".osrel", Data: []byte("x"), Characteristics: ukitest.DataFlags}, + {Name: ".cmdline", Data: []byte("a=1"), Characteristics: ukitest.DataFlags}, + {Name: ".text", Data: ukitest.Pattern(1, 100), Characteristics: ukitest.CodeFlags}, + } + path := build(t, sections, ukitest.Options{}) + + if err := Rewrite(path, []string{"a=2"}, Options{}); err == nil { + t.Fatal("expected an error when code follows .cmdline") + } +} + +func TestRewriteRefusesMissingCmdline(t *testing.T) { + sections := []ukitest.Section{ + {Name: ".text", Data: ukitest.Pattern(1, 100), Characteristics: ukitest.CodeFlags}, + {Name: ".osrel", Data: []byte("x"), Characteristics: ukitest.DataFlags}, + } + path := build(t, sections, ukitest.Options{}) + + if err := Rewrite(path, []string{"a=2"}, Options{}); err == nil { + t.Fatal("expected an error when .cmdline is missing") + } + if _, err := Inspect(path); err == nil { + t.Fatal("expected Inspect to fail without .cmdline") + } +} + +func TestRewriteLeavesNoTempFiles(t *testing.T) { + path := build(t, ukitest.TalosLike("a=1"), ukitest.Options{}) + + if err := Rewrite(path, []string{"a=2", "a=2" + ukitest.ResetSuffix}, Options{}); err != nil { + t.Fatalf("Rewrite() error: %v", err) + } + + assertNoTempFiles(t, filepath.Dir(path)) +} + +func TestRewriteSingleCmdlineWithoutProfiles(t *testing.T) { + original := ukitest.SingleCmdline("a=1") + path := build(t, original, ukitest.Options{}) + + info, err := Inspect(path) + if err != nil || len(info.Cmdlines) != 1 || info.Cmdlines[0] != "a=1" { + t.Fatalf("unexpected inspect result %+v err=%v", info, err) + } + + if err := Rewrite(path, []string{"a=1 " + strings.Repeat("x", 900)}, Options{}); err != nil { + t.Fatalf("Rewrite() error: %v", err) + } + + assertLayout(t, path, []string{"a=1 " + strings.Repeat("x", 900)}, original) +} + +func TestRewriteRejectsWrongCmdlineCount(t *testing.T) { + path := build(t, ukitest.TalosLike("a=1"), ukitest.Options{}) + + if err := Rewrite(path, []string{"a=2"}, Options{}); err == nil { + t.Fatal("expected an error when the number of command lines does not match the sections") + } +} + +func stubPrefix(t *testing.T, path string) []byte { + t.Helper() + + f := openPE(t, path) + for _, s := range f.Sections { + if s.Name == ".cmdline" { + raw, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + // Skip the headers: they legitimately change (sizes, offsets). + return raw[f.Sections[0].Offset:s.Offset] + } + } + + t.Fatal(".cmdline not found") + + return nil +} + +func sectionOffset(f *pe.File, name string) uint32 { + for _, s := range f.Sections { + if s.Name == name { + return s.Offset + } + } + + return 0 +} + +func assertNoTempFiles(t *testing.T, dir string) { + t.Helper() + + entries, err := os.ReadDir(dir) + if err != nil { + t.Fatal(err) + } + for _, e := range entries { + if !strings.HasSuffix(e.Name(), ".efi") { + t.Fatalf("unexpected file left behind: %s", e.Name()) + } + } +} + +func TestInspectReportsCmdlineLocations(t *testing.T) { + path := build(t, ukitest.TalosLike("a=1"), ukitest.Options{}) + + info, err := Inspect(path) + if err != nil { + t.Fatalf("Inspect() error: %v", err) + } + + f := openPE(t, path) + + var want []Location + for i, s := range f.Sections { + if s.Name == SectionName { + want = append(want, Location{Section: i, Offset: int64(s.Offset), Size: int64(s.VirtualSize)}) + } + } + + if len(want) != 2 || !reflect.DeepEqual(info.Locations, want) { + t.Fatalf("Locations = %+v, want %+v", info.Locations, want) + } +} diff --git a/taloscmdline/uki/ukitest/build.go b/taloscmdline/uki/ukitest/build.go new file mode 100644 index 00000000..cd6a1746 --- /dev/null +++ b/taloscmdline/uki/ukitest/build.go @@ -0,0 +1,181 @@ +// Package ukitest builds small PE files shaped like a Talos UKI for tests: a +// stub-like code section and relocations followed by the data sections Talos +// appends. The layout rules mirror Talos' native assembler. +package ukitest + +import ( + "bytes" + "debug/pe" + "encoding/binary" + "fmt" + "os" +) + +const ( + SectionAlignment = 0x1000 + FileAlignment = 0x200 + + dosHeaderSize = 0x80 + firstVA = 0x1000 +) + +// DataFlags are the characteristics Talos gives appended sections. +const DataFlags = pe.IMAGE_SCN_CNT_INITIALIZED_DATA | pe.IMAGE_SCN_MEM_READ + +// CodeFlags are the characteristics of the stub's code section. +const CodeFlags = pe.IMAGE_SCN_CNT_CODE | pe.IMAGE_SCN_MEM_EXECUTE | pe.IMAGE_SCN_MEM_READ + +// Section is one section of the generated file. +type Section struct { + Name string + Data []byte + Characteristics uint32 +} + +// Options tweak the generated file. +type Options struct { + // Signed appends a fake Authenticode certificate table and points the + // security data directory at it. + Signed bool +} + +func align(v, a uint64) uint64 { + return (v + a - 1) &^ (a - 1) +} + +// Write generates a PE32+ file at path. +func Write(path string, sections []Section, opts Options) error { + var buf bytes.Buffer + + fileHeader := pe.FileHeader{ + Machine: pe.IMAGE_FILE_MACHINE_AMD64, + NumberOfSections: uint16(len(sections)), + SizeOfOptionalHeader: uint16(binary.Size(pe.OptionalHeader64{})), + Characteristics: pe.IMAGE_FILE_EXECUTABLE_IMAGE | pe.IMAGE_FILE_LARGE_ADDRESS_AWARE, + } + + headersSize := uint32(align(uint64(dosHeaderSize+4+binary.Size(fileHeader)+binary.Size(pe.OptionalHeader64{})+binary.Size(pe.SectionHeader32{})*len(sections)), FileAlignment)) + + headers := make([]pe.SectionHeader32, len(sections)) + va := uint64(firstVA) + offset := uint64(headersSize) + + var sizeOfCode, sizeOfData uint32 + + for i, s := range sections { + raw := uint32(align(uint64(len(s.Data)), FileAlignment)) + copy(headers[i].Name[:], s.Name) + headers[i].VirtualSize = uint32(len(s.Data)) + headers[i].VirtualAddress = uint32(va) + headers[i].SizeOfRawData = raw + headers[i].PointerToRawData = uint32(offset) + headers[i].Characteristics = s.Characteristics + + if s.Characteristics&pe.IMAGE_SCN_CNT_INITIALIZED_DATA != 0 { + sizeOfData += raw + } else { + sizeOfCode += raw + } + + va = align(va+uint64(len(s.Data)), SectionAlignment) + offset += uint64(raw) + } + + optional := pe.OptionalHeader64{ + Magic: 0x20b, + SizeOfCode: sizeOfCode, + SizeOfInitializedData: sizeOfData, + AddressOfEntryPoint: firstVA, + BaseOfCode: firstVA, + ImageBase: 0x140000000, + SectionAlignment: SectionAlignment, + FileAlignment: FileAlignment, + MajorSubsystemVersion: 6, + SizeOfImage: uint32(va), + SizeOfHeaders: headersSize, + Subsystem: pe.IMAGE_SUBSYSTEM_EFI_APPLICATION, + SizeOfStackReserve: 0x100000, + SizeOfStackCommit: 0x1000, + SizeOfHeapReserve: 0x100000, + SizeOfHeapCommit: 0x1000, + NumberOfRvaAndSizes: 16, + } + + signature := []byte("FAKE-AUTHENTICODE-CERTIFICATE-TABLE-PADDED-TO-SIXTY-FOUR-BYTES!!") + if opts.Signed { + optional.DataDirectory[pe.IMAGE_DIRECTORY_ENTRY_SECURITY] = pe.DataDirectory{VirtualAddress: uint32(offset), Size: uint32(len(signature))} + } + + // DOS header with e_lfanew pointing at the PE signature. + dos := make([]byte, dosHeaderSize) + copy(dos, "MZ") + binary.LittleEndian.PutUint32(dos[0x3c:], dosHeaderSize) + buf.Write(dos) + buf.WriteString("PE\x00\x00") + + if err := binary.Write(&buf, binary.LittleEndian, fileHeader); err != nil { + return err + } + if err := binary.Write(&buf, binary.LittleEndian, optional); err != nil { + return err + } + for _, h := range headers { + if err := binary.Write(&buf, binary.LittleEndian, h); err != nil { + return err + } + } + buf.Write(make([]byte, int(headersSize)-buf.Len())) + + for i, s := range sections { + buf.Write(s.Data) + buf.Write(make([]byte, int(headers[i].SizeOfRawData)-len(s.Data))) + } + + if opts.Signed { + buf.Write(signature) + } + + if err := os.WriteFile(path, buf.Bytes(), 0o600); err != nil { + return fmt.Errorf("writing %s: %w", path, err) + } + + return nil +} + +// Pattern returns n bytes of deterministic, non-repeating-looking content. +func Pattern(seed byte, n int) []byte { + out := make([]byte, n) + for i := range out { + out[i] = byte(int(seed) + i*7 + i/251) + } + + return out +} + +// ResetSuffix is what Talos appends to the command line of its "reset" boot profile. +const ResetSuffix = " talos.experimental.wipe=system:EPHEMERAL,STATE" + +// TalosLike returns the section list of a Talos 1.14 style UKI with the given +// command line: the base sections followed by two .profile sections and the +// .cmdline of the reset profile. +func TalosLike(cmdline string) []Section { + return append(SingleCmdline(cmdline), + Section{Name: ".profile", Data: []byte("ID=boot\n"), Characteristics: DataFlags}, + Section{Name: ".profile", Data: []byte("ID=reset\nTITLE=Reset Talos installation\n"), Characteristics: DataFlags}, + Section{Name: ".cmdline", Data: []byte(cmdline + ResetSuffix), Characteristics: DataFlags}, + ) +} + +// SingleCmdline returns the section list of a UKI without boot profiles. +func SingleCmdline(cmdline string) []Section { + return []Section{ + {Name: ".text", Data: Pattern(1, 300), Characteristics: CodeFlags}, + {Name: ".rodata", Data: Pattern(2, 100), Characteristics: DataFlags}, + {Name: ".reloc", Data: Pattern(3, 20), Characteristics: DataFlags | pe.IMAGE_SCN_MEM_DISCARDABLE}, + {Name: ".osrel", Data: []byte("NAME=Talos\nID=talos\n"), Characteristics: DataFlags}, + {Name: ".cmdline", Data: []byte(cmdline), Characteristics: DataFlags}, + {Name: ".uname", Data: []byte("6.12.0-talos"), Characteristics: DataFlags}, + {Name: ".linux", Data: Pattern(4, 5000), Characteristics: DataFlags}, + {Name: ".initrd", Data: Pattern(5, 9000), Characteristics: DataFlags}, + } +} diff --git a/talosmeta/Dockerfile b/talosmeta/Dockerfile new file mode 100644 index 00000000..af1259ed --- /dev/null +++ b/talosmeta/Dockerfile @@ -0,0 +1,13 @@ +FROM golang:1.26-alpine AS talosmeta +RUN apk add --no-cache ca-certificates +COPY . /src +WORKDIR /src/talosmeta +RUN --mount=type=cache,sharing=locked,id=gomod,target=/go/pkg/mod/cache \ + --mount=type=cache,sharing=locked,id=goroot,target=/root/.cache/go-build \ + CGO_ENABLED=0 GOOS=linux go build -ldflags "-s -w" -o talosmeta + +FROM scratch +# Add Certificates into the image, for anything that does HTTPS calls +COPY --from=talosmeta /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt +COPY --from=talosmeta /src/talosmeta/talosmeta /usr/bin/talosmeta +ENTRYPOINT ["/usr/bin/talosmeta"] diff --git a/talosmeta/README.md b/talosmeta/README.md new file mode 100644 index 00000000..887ddc99 --- /dev/null +++ b/talosmeta/README.md @@ -0,0 +1,168 @@ +``` +quay.io/tinkerbell/actions/talosmeta:latest +``` + +This action writes [Talos Linux](https://www.talos.dev) metal platform network +configuration into the `META` partition of a disk that already holds a Talos +image. It retrieves the machine's Hardware data from the Tinkerbell metadata +service the same way the `rootio` action does, maps it onto the Talos network +configuration document, and stores the YAML under META key `0xa` +(`MetalNetworkPlatformConfig`). This is what +`talosctl meta write 0xa "$(cat network.yaml)"` does on a running node, done +offline before the first boot. + +Talos applies the document before it fetches its machine configuration, so a +node gets a static address, default route, hostname, DNS and NTP even when the +Tinkerbell DHCP server no longer serves it after provisioning. + +```yaml +actions: +- name: "stream-talos-image" + image: quay.io/tinkerbell/actions/oci2disk:latest + timeout: 600 + environment: + DEST_DISK: /dev/sda + IMG_URL: "192.168.1.2:5000/talos/metal-amd64:v1.14.0" +- name: "write-talos-network-config" + image: quay.io/tinkerbell/actions/talosmeta:latest + timeout: 60 + environment: + DEST_DISK: /dev/sda + MIRROR_HOST: 192.168.1.2 +``` + +## Environment Variables + +| Variable | Required | Default | Description | +| --- | --- | --- | --- | +| `DEST_DISK` | yes | | Block device holding the Talos image, for example `/dev/sda`. The partition named `META` is located through the GPT, so partition device nodes are not needed. | +| `MIRROR_HOST` | unless `NETWORK_CONFIG` or `HARDWARE_SPEC` is set | | Host of the Tinkerbell metadata service. | +| `METADATA_SERVICE_PORT` | no | `7080` | Port of the consolidated Tinkerbell HTTP server that serves `/metadata`. | +| `HARDWARE_SPEC` | no | | The Hardware `spec` (or just its `interfaces` and `metadata.instance`) as JSON. Takes precedence over the metadata service; a Workflow template can render it from `.hardware.spec`, see below. | +| `NETWORK_CONFIG` | no | | A complete Talos network configuration document. When set nothing else is consulted and the YAML is written verbatim, exactly like `talosctl meta write 0xa`. | +| `LINK_NAMING` | no | `predictable` | How interface names are derived when the Hardware does not set `iface_name`; see below. | + +Re-running the action replaces key `0xa` and leaves every other META key intact. + +## Passing Hardware data from the Workflow template + +Tinkerbell exposes the Hardware object to Workflow templates as `.hardware`, +so the template can hand the relevant part of the spec straight to the action +without any metadata service round trip: + +```yaml +- name: "write-talos-network-config" + image: quay.io/tinkerbell/actions/talosmeta:latest + timeout: 120 + environment: + DEST_DISK: /dev/sda + HARDWARE_SPEC: {{ dict "interfaces" (dig "interfaces" (list) .hardware.spec) "metadata" (dict "instance" (dict "hostname" (dig "metadata" "instance" "hostname" "" .hardware.spec) "ips" (dig "metadata" "instance" "ips" (list) .hardware.spec))) | toJson | quote }} +``` + +`dig` keeps the render working when a field is absent, and `quote` produces +a YAML double-quoted scalar. Only `interfaces` and `metadata.instance` are +needed, so the machine configuration in `spec.userData` never leaves the +Hardware object. + +## Mapping + +The document follows the Talos +[metal network configuration](https://docs.siderolabs.com/talos/latest/platform-specific-installations/bare-metal-platforms/metal-network-configuration) +format. Every entry is written on the `platform` layer, so the machine +configuration can still override it. + +For each `spec.interfaces[]` entry with `dhcp.ip.address`: + +- a link `{name, up: true}`; with `dhcp.vlan_id` set, also a logical VLAN + link `.` that carries the address and routes, +- an address `
/` with `family`, `scope: global` and + `flags: permanent`; the prefix length comes from `dhcp.ip.netmask` (dotted + quad or a bare length), +- a default route through `dhcp.ip.gateway` (`table: main`, priority `1024` + for IPv4 and `2048` for IPv6), +- one route per `dhcp.classless_static_routes` entry. + +For the machine: + +- `hostnames` from `metadata.instance.hostname`, falling back to the first + `dhcp.hostname`; the domain comes from `dhcp.domain_name` or the FQDN, +- `resolvers` from the union of `dhcp.name_servers`, +- `timeServers` from the union of `dhcp.time_servers`, +- `externalIPs` from `metadata.instance.ips[]` entries marked `public`. + +Interfaces without a static address are left alone so Talos keeps using DHCP +on them. The action fails when no interface has a static address. + +Example output for one interface: + +```yaml +addresses: + - address: 10.0.80.10/24 + linkName: enp1s0 + family: inet4 + scope: global + flags: permanent + layer: platform +links: + - name: enp1s0 + up: true + layer: platform +routes: + - family: inet4 + gateway: 10.0.80.1 + outLinkName: enp1s0 + table: main + priority: 1024 + scope: global + type: unicast + protocol: static + layer: platform +hostnames: + - hostname: node1 + domainname: example.com + layer: platform +resolvers: + - dnsServers: + - 10.0.0.2 + layer: platform +timeServers: + - timeServers: + - 10.0.0.3 + layer: platform +operators: [] +externalIPs: [] +``` + +## Interface names + +Talos names interfaces with udev's predictable scheme (`enp1s0`, `eno1`, +`ens3`) unless it boots with `net.ifnames=0`, while HookOS uses kernel names +(`eth0`). The name written to META has to be the one Talos will see. + +1. `dhcp.iface_name` is used verbatim when set. Set it in the Hardware to pin + the name. +2. Otherwise the interface carrying `dhcp.mac` is looked up in sysfs. + - `LINK_NAMING=predictable` (default) derives the udev name: onboard + index (`eno`), then PCI hotplug slot (`ens`), then PCI path + (`enps[f]`). Non-PCI devices keep the kernel name + and a warning is logged. + - `LINK_NAMING=kernel` keeps the HookOS name, for Talos images booted with + `net.ifnames=0`. + +## Verifying on the node + +``` +talosctl -n get meta 0x0a -o yaml +talosctl -n get addresses +``` + +## Metadata service requirements + +The document is built from `interfaces[].dhcp` and `metadata.instance` in the +JSON returned by `/metadata`. Tinkerbell's tootles `/metadata` endpoint +currently reduces the Hardware spec to the storage fields the `rootio` action +needs, so the interfaces are missing and the action stops with a message +saying so. Extending tootles' `HackInstance` type with `interfaces` and +`metadata.instance.hostname`/`ips` (the JSON tags already match the Hardware +CRD) makes the data available. Until then, `NETWORK_CONFIG` can carry a +document rendered by the workflow template. diff --git a/talosmeta/hardware/hardware.go b/talosmeta/hardware/hardware.go new file mode 100644 index 00000000..3e30ad6e --- /dev/null +++ b/talosmeta/hardware/hardware.go @@ -0,0 +1,118 @@ +// Package hardware retrieves the Tinkerbell Hardware data for the machine an +// action runs on. It follows the pattern of the rootio action: an HTTP GET of +// /metadata on the Tinkerbell metadata service, which answers with the +// Hardware spec of the machine that made the request. +package hardware + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" +) + +// Spec mirrors the parts of the Tinkerbell Hardware spec that describe +// networking. Field names and JSON tags follow the Hardware CRD. +type Spec struct { + Interfaces []Interface `json:"interfaces,omitempty"` + Metadata *Metadata `json:"metadata,omitempty"` +} + +// Interface is one network interface of the machine. +type Interface struct { + DHCP *DHCP `json:"dhcp,omitempty"` + DisableDHCP bool `json:"disableDhcp,omitempty"` +} + +// DHCP holds the addressing Tinkerbell hands out for an interface. +type DHCP struct { + MAC string `json:"mac,omitempty"` + Hostname string `json:"hostname,omitempty"` + DomainName string `json:"domain_name,omitempty"` + NameServers []string `json:"name_servers,omitempty"` + TimeServers []string `json:"time_servers,omitempty"` + IfaceName string `json:"iface_name,omitempty"` + IP *IP `json:"ip,omitempty"` + VLANID string `json:"vlan_id,omitempty"` + ClasslessStaticRoutes []ClasslessStaticRoute `json:"classless_static_routes,omitempty"` +} + +// IP is a static address assignment. +type IP struct { + Address string `json:"address,omitempty"` + Netmask string `json:"netmask,omitempty"` + Gateway string `json:"gateway,omitempty"` + Family int64 `json:"family,omitempty"` +} + +// ClasslessStaticRoute is a DHCP option 121 route. +type ClasslessStaticRoute struct { + DestinationDescriptor string `json:"destination_descriptor"` + Router string `json:"router"` +} + +// Metadata is the Hardware metadata block. +type Metadata struct { + Instance *Instance `json:"instance,omitempty"` +} + +// Instance describes the provisioned instance. +type Instance struct { + ID string `json:"id,omitempty"` + Hostname string `json:"hostname,omitempty"` + IPs []InstanceIP `json:"ips,omitempty"` +} + +// InstanceIP is an address listed on the instance. +type InstanceIP struct { + Address string `json:"address,omitempty"` + Netmask string `json:"netmask,omitempty"` + Gateway string `json:"gateway,omitempty"` + Family int64 `json:"family,omitempty"` + Public bool `json:"public,omitempty"` + Management bool `json:"management,omitempty"` +} + +const ( + metadataPath = "/metadata" + userAgent = "talosmeta" + maxBody = 8 << 20 +) + +// Fetch retrieves the Hardware spec from the metadata service rooted at +// baseURL, for example http://192.168.1.2:7080. +func Fetch(ctx context.Context, client *http.Client, baseURL string) (*Spec, error) { + url := strings.TrimRight(baseURL, "/") + metadataPath + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, fmt.Errorf("building metadata request: %w", err) + } + + req.Header.Set("User-Agent", userAgent) + req.Header.Set("Accept", "application/json") + + res, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("requesting %s: %w", url, err) + } + defer res.Body.Close() + + body, err := io.ReadAll(io.LimitReader(res.Body, maxBody)) + if err != nil { + return nil, fmt.Errorf("reading metadata response: %w", err) + } + + if res.StatusCode != http.StatusOK { + return nil, fmt.Errorf("metadata service %s returned %s: %s", url, res.Status, strings.TrimSpace(string(body))) + } + + var spec Spec + if err := json.Unmarshal(body, &spec); err != nil { + return nil, fmt.Errorf("decoding metadata from %s: %w", url, err) + } + + return &spec, nil +} diff --git a/talosmeta/hardware/hardware_test.go b/talosmeta/hardware/hardware_test.go new file mode 100644 index 00000000..73e5be63 --- /dev/null +++ b/talosmeta/hardware/hardware_test.go @@ -0,0 +1,102 @@ +package hardware + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" +) + +const sampleMetadata = `{ + "interfaces": [ + { + "dhcp": { + "mac": "52:54:00:12:34:01", + "hostname": "node1", + "domain_name": "example.com", + "name_servers": ["10.0.0.2"], + "time_servers": ["10.0.0.3"], + "iface_name": "enp1s0", + "vlan_id": "100", + "ip": {"address": "10.0.80.10", "netmask": "255.255.255.0", "gateway": "10.0.80.1", "family": 4}, + "classless_static_routes": [{"destination_descriptor": "192.168.0.0/16", "router": "10.0.80.254"}] + } + } + ], + "metadata": { + "instance": { + "id": "i-1", + "hostname": "node1.example.com", + "ips": [{"address": "203.0.113.5", "family": 4, "public": true}], + "storage": {"disks": [{"device": "/dev/sda"}]} + } + } +}` + +func TestFetchDecodesInterfacesAndInstance(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/metadata" { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(sampleMetadata)) + })) + defer srv.Close() + + spec, err := Fetch(context.Background(), srv.Client(), srv.URL) + if err != nil { + t.Fatalf("Fetch() error: %v", err) + } + + if len(spec.Interfaces) != 1 { + t.Fatalf("expected 1 interface, got %d", len(spec.Interfaces)) + } + dhcp := spec.Interfaces[0].DHCP + if dhcp == nil { + t.Fatal("expected dhcp block") + } + if dhcp.MAC != "52:54:00:12:34:01" || dhcp.IfaceName != "enp1s0" || dhcp.VLANID != "100" { + t.Fatalf("unexpected dhcp: %+v", dhcp) + } + if dhcp.IP == nil || dhcp.IP.Address != "10.0.80.10" || dhcp.IP.Netmask != "255.255.255.0" || dhcp.IP.Gateway != "10.0.80.1" || dhcp.IP.Family != 4 { + t.Fatalf("unexpected ip: %+v", dhcp.IP) + } + if len(dhcp.ClasslessStaticRoutes) != 1 || dhcp.ClasslessStaticRoutes[0].DestinationDescriptor != "192.168.0.0/16" || dhcp.ClasslessStaticRoutes[0].Router != "10.0.80.254" { + t.Fatalf("unexpected routes: %+v", dhcp.ClasslessStaticRoutes) + } + if spec.Metadata == nil || spec.Metadata.Instance == nil { + t.Fatal("expected metadata.instance") + } + inst := spec.Metadata.Instance + if inst.ID != "i-1" || inst.Hostname != "node1.example.com" { + t.Fatalf("unexpected instance: %+v", inst) + } + if len(inst.IPs) != 1 || inst.IPs[0].Address != "203.0.113.5" || !inst.IPs[0].Public { + t.Fatalf("unexpected instance ips: %+v", inst.IPs) + } +} + +func TestFetchRejectsNon200(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + http.Error(w, "no hardware found for source ip", http.StatusNotFound) + })) + defer srv.Close() + + _, err := Fetch(context.Background(), srv.Client(), srv.URL) + if err == nil { + t.Fatal("expected an error for a 404 response") + } +} + +func TestFetchRejectsInvalidJSON(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte("not json")) + })) + defer srv.Close() + + _, err := Fetch(context.Background(), srv.Client(), srv.URL) + if err == nil { + t.Fatal("expected an error for invalid JSON") + } +} diff --git a/talosmeta/main.go b/talosmeta/main.go new file mode 100644 index 00000000..b4f1d483 --- /dev/null +++ b/talosmeta/main.go @@ -0,0 +1,153 @@ +package main + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "log/slog" + "net" + "net/http" + "os" + "time" + + "github.com/tinkerbell/actions/talosmeta/hardware" + "github.com/tinkerbell/actions/talosmeta/meta" + "github.com/tinkerbell/actions/talosmeta/talosnet" +) + +const ( + // defaultMetadataPort is the consolidated Tinkerbell HTTP server port that + // serves /metadata. + defaultMetadataPort = "7080" + metadataTimeout = 60 * time.Second + + linkNamingPredictable = "predictable" + linkNamingKernel = "kernel" +) + +func main() { + logger := slog.New(slog.NewTextHandler(os.Stdout, nil)) + logger.Info("TALOSMETA - Write Talos network configuration to the META partition") + + if err := run(logger); err != nil { + logger.Error(err.Error()) + os.Exit(1) + } +} + +func run(logger *slog.Logger) error { + disk := os.Getenv("DEST_DISK") + if disk == "" { + return errors.New("no block device specified with environment variable [DEST_DISK]") + } + + doc, err := networkDocument(context.Background(), logger) + if err != nil { + return err + } + + partition, err := meta.Locate(disk) + if err != nil { + return err + } + + logger.Info("Found META partition", "disk", disk, "offset", partition.Offset, "size", partition.Size) + + if err := meta.WriteTag(disk, meta.MetalNetworkPlatformConfig, doc); err != nil { + return err + } + + logger.Info("Wrote network configuration to META", "key", fmt.Sprintf("%#x", meta.MetalNetworkPlatformConfig), "bytes", len(doc)) + logger.Info("Network configuration written:\n" + string(doc)) + + return nil +} + +// networkDocument returns the YAML to store under META key 0xa. In order of +// precedence: the operator-supplied NETWORK_CONFIG document, a Hardware spec +// passed in HARDWARE_SPEC (JSON, as a Workflow template can render it), or the +// Hardware data served by the Tinkerbell metadata service. +func networkDocument(ctx context.Context, logger *slog.Logger) ([]byte, error) { + if raw := os.Getenv("NETWORK_CONFIG"); raw != "" { + if _, err := talosnet.Parse([]byte(raw)); err != nil { + return nil, fmt.Errorf("NETWORK_CONFIG: %w", err) + } + + logger.Info("Using network configuration from NETWORK_CONFIG") + + return []byte(raw), nil + } + + namer, err := newNamer(logger) + if err != nil { + return nil, err + } + + spec, source, err := hardwareSpec(ctx, logger) + if err != nil { + return nil, err + } + + if len(spec.Interfaces) == 0 { + return nil, fmt.Errorf("hardware data from %s has no interfaces; pass the Hardware spec.interfaces through HARDWARE_SPEC, expose them on /metadata, or provide NETWORK_CONFIG", source) + } + + cfg, err := talosnet.FromHardware(spec, namer) + if err != nil { + return nil, fmt.Errorf("mapping hardware data to Talos network configuration: %w", err) + } + + return cfg.Marshal() +} + +// hardwareSpec loads the Hardware spec from HARDWARE_SPEC or the metadata +// service, returning it with a description of where it came from. +func hardwareSpec(ctx context.Context, logger *slog.Logger) (*hardware.Spec, string, error) { + if raw := os.Getenv("HARDWARE_SPEC"); raw != "" { + var spec hardware.Spec + if err := json.Unmarshal([]byte(raw), &spec); err != nil { + return nil, "", fmt.Errorf("HARDWARE_SPEC is not valid Hardware spec JSON: %w", err) + } + + logger.Info("Using hardware data from HARDWARE_SPEC", "interfaces", len(spec.Interfaces)) + + return &spec, "HARDWARE_SPEC", nil + } + + host := os.Getenv("MIRROR_HOST") + if host == "" { + return nil, "", errors.New("no hardware data source: set [MIRROR_HOST] for the metadata service, or provide [HARDWARE_SPEC] or [NETWORK_CONFIG]") + } + + port, ok := os.LookupEnv("METADATA_SERVICE_PORT") + if !ok { + port = defaultMetadataPort + } + + baseURL := "http://" + net.JoinHostPort(host, port) + logger.Info("Retrieving hardware data", "url", baseURL+"/metadata") + + spec, err := hardware.Fetch(ctx, &http.Client{Timeout: metadataTimeout}, baseURL) + if err != nil { + return nil, "", err + } + + return spec, baseURL, nil +} + +func newNamer(logger *slog.Logger) (*talosnet.SysfsNamer, error) { + mode, ok := os.LookupEnv("LINK_NAMING") + if !ok || mode == "" { + mode = linkNamingPredictable + } + + switch mode { + case linkNamingPredictable: + return &talosnet.SysfsNamer{Predictable: true, Logger: logger}, nil + case linkNamingKernel: + return &talosnet.SysfsNamer{Predictable: false, Logger: logger}, nil + default: + return nil, fmt.Errorf("LINK_NAMING must be %q or %q, got %q", linkNamingPredictable, linkNamingKernel, mode) + } +} diff --git a/talosmeta/main_test.go b/talosmeta/main_test.go new file mode 100644 index 00000000..4f79609a --- /dev/null +++ b/talosmeta/main_test.go @@ -0,0 +1,216 @@ +package main + +import ( + "io" + "log/slog" + "net/http" + "net/http/httptest" + "net/url" + "path/filepath" + "strings" + "testing" + + diskfs "github.com/diskfs/go-diskfs" + "github.com/diskfs/go-diskfs/partition/gpt" + + "github.com/tinkerbell/actions/talosmeta/meta" +) + +func newTalosLikeDisk(t *testing.T) string { + t.Helper() + + path := filepath.Join(t.TempDir(), "disk.img") + d, err := diskfs.Create(path, 8*1024*1024, diskfs.SectorSize512) + if err != nil { + t.Fatal(err) + } + table := &gpt.Table{ + Partitions: []*gpt.Partition{ + {Index: 1, Start: 2048, End: 4095, Type: gpt.EFISystemPartition, Name: "EFI"}, + {Index: 2, Start: 4096, End: 6143, Type: gpt.LinuxFilesystem, Name: "META"}, + }, + LogicalSectorSize: 512, + PhysicalSectorSize: 512, + ProtectiveMBR: true, + } + if err := d.Partition(table); err != nil { + t.Fatal(err) + } + if err := d.Close(); err != nil { + t.Fatal(err) + } + + return path +} + +func quietLogger() *slog.Logger { + return slog.New(slog.NewTextHandler(io.Discard, nil)) +} + +func TestRunWritesNetworkConfigPassthrough(t *testing.T) { + disk := newTalosLikeDisk(t) + doc := "hostnames:\n - hostname: node1\n layer: platform\n" + + t.Setenv("DEST_DISK", disk) + t.Setenv("NETWORK_CONFIG", doc) + t.Setenv("MIRROR_HOST", "") + + if err := run(quietLogger()); err != nil { + t.Fatalf("run() error: %v", err) + } + + got, ok, err := meta.ReadTag(disk, meta.MetalNetworkPlatformConfig) + if err != nil || !ok { + t.Fatalf("expected tag 0xa to be written, ok=%v err=%v", ok, err) + } + if string(got) != doc { + t.Fatalf("unexpected document written:\n%s", got) + } +} + +func TestRunRejectsInvalidPassthrough(t *testing.T) { + t.Setenv("DEST_DISK", newTalosLikeDisk(t)) + t.Setenv("NETWORK_CONFIG", "addresses: [") + + if err := run(quietLogger()); err == nil || !strings.Contains(err.Error(), "NETWORK_CONFIG") { + t.Fatalf("expected a NETWORK_CONFIG error, got %v", err) + } +} + +func TestRunBuildsDocumentFromMetadataService(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/metadata" { + http.NotFound(w, r) + return + } + _, _ = w.Write([]byte(`{"interfaces":[{"dhcp":{"mac":"52:54:00:12:34:01","iface_name":"enp1s0","hostname":"node1", + "ip":{"address":"10.0.80.10","netmask":"255.255.255.0","gateway":"10.0.80.1","family":4}}}], + "metadata":{"instance":{"hostname":"node1.example.com"}}}`)) + })) + defer srv.Close() + + u, err := url.Parse(srv.URL) + if err != nil { + t.Fatal(err) + } + + disk := newTalosLikeDisk(t) + t.Setenv("DEST_DISK", disk) + t.Setenv("NETWORK_CONFIG", "") + t.Setenv("MIRROR_HOST", u.Hostname()) + t.Setenv("METADATA_SERVICE_PORT", u.Port()) + + if err := run(quietLogger()); err != nil { + t.Fatalf("run() error: %v", err) + } + + got, ok, err := meta.ReadTag(disk, meta.MetalNetworkPlatformConfig) + if err != nil || !ok { + t.Fatalf("expected tag 0xa to be written, ok=%v err=%v", ok, err) + } + for _, want := range []string{"address: 10.0.80.10/24", "linkName: enp1s0", "gateway: 10.0.80.1", "hostname: node1", "domainname: example.com"} { + if !strings.Contains(string(got), want) { + t.Fatalf("expected document to contain %q:\n%s", want, got) + } + } +} + +func TestRunFailsWhenMetadataHasNoInterfaces(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(`{"metadata":{"instance":{"storage":{"disks":[]}}}}`)) + })) + defer srv.Close() + + u, err := url.Parse(srv.URL) + if err != nil { + t.Fatal(err) + } + + t.Setenv("DEST_DISK", newTalosLikeDisk(t)) + t.Setenv("NETWORK_CONFIG", "") + t.Setenv("MIRROR_HOST", u.Hostname()) + t.Setenv("METADATA_SERVICE_PORT", u.Port()) + + err = run(quietLogger()) + if err == nil || !strings.Contains(err.Error(), "no interfaces") { + t.Fatalf("expected a no-interfaces error, got %v", err) + } +} + +func TestRunRequiresDestDisk(t *testing.T) { + t.Setenv("DEST_DISK", "") + + if err := run(quietLogger()); err == nil || !strings.Contains(err.Error(), "DEST_DISK") { + t.Fatalf("expected a DEST_DISK error, got %v", err) + } +} + +func TestRunRejectsUnknownLinkNaming(t *testing.T) { + t.Setenv("DEST_DISK", newTalosLikeDisk(t)) + t.Setenv("NETWORK_CONFIG", "") + t.Setenv("MIRROR_HOST", "127.0.0.1") + t.Setenv("LINK_NAMING", "random") + + if err := run(quietLogger()); err == nil || !strings.Contains(err.Error(), "LINK_NAMING") { + t.Fatalf("expected a LINK_NAMING error, got %v", err) + } +} + +const hardwareSpecJSON = `{"interfaces":[{"dhcp":{"mac":"52:54:00:12:34:01","iface_name":"enp1s0","hostname":"node1", +"name_servers":["10.0.0.2"],"ip":{"address":"10.0.80.10","netmask":"255.255.255.0","gateway":"10.0.80.1","family":4}}}], +"metadata":{"instance":{"hostname":"node1.example.com"}}}` + +func TestRunBuildsDocumentFromHardwareSpec(t *testing.T) { + disk := newTalosLikeDisk(t) + t.Setenv("DEST_DISK", disk) + t.Setenv("NETWORK_CONFIG", "") + t.Setenv("MIRROR_HOST", "") + t.Setenv("HARDWARE_SPEC", hardwareSpecJSON) + + if err := run(quietLogger()); err != nil { + t.Fatalf("run() error: %v", err) + } + + got, ok, err := meta.ReadTag(disk, meta.MetalNetworkPlatformConfig) + if err != nil || !ok { + t.Fatalf("expected tag 0xa to be written, ok=%v err=%v", ok, err) + } + for _, want := range []string{"address: 10.0.80.10/24", "linkName: enp1s0", "gateway: 10.0.80.1", "hostname: node1", "domainname: example.com", "- 10.0.0.2"} { + if !strings.Contains(string(got), want) { + t.Fatalf("expected document to contain %q:\n%s", want, got) + } + } +} + +func TestRunHardwareSpecTakesPrecedenceOverMetadataService(t *testing.T) { + disk := newTalosLikeDisk(t) + t.Setenv("DEST_DISK", disk) + t.Setenv("NETWORK_CONFIG", "") + t.Setenv("MIRROR_HOST", "192.0.2.1") // unreachable on purpose + t.Setenv("METADATA_SERVICE_PORT", "9") + t.Setenv("HARDWARE_SPEC", hardwareSpecJSON) + + if err := run(quietLogger()); err != nil { + t.Fatalf("run() error: %v", err) + } +} + +func TestRunRejectsInvalidHardwareSpec(t *testing.T) { + t.Setenv("DEST_DISK", newTalosLikeDisk(t)) + t.Setenv("NETWORK_CONFIG", "") + t.Setenv("HARDWARE_SPEC", "{not json") + + if err := run(quietLogger()); err == nil || !strings.Contains(err.Error(), "HARDWARE_SPEC") { + t.Fatalf("expected a HARDWARE_SPEC error, got %v", err) + } +} + +func TestRunRejectsHardwareSpecWithoutInterfaces(t *testing.T) { + t.Setenv("DEST_DISK", newTalosLikeDisk(t)) + t.Setenv("NETWORK_CONFIG", "") + t.Setenv("HARDWARE_SPEC", `{"metadata":{"instance":{"hostname":"x"}}}`) + + if err := run(quietLogger()); err == nil || !strings.Contains(err.Error(), "no interfaces") { + t.Fatalf("expected a no-interfaces error, got %v", err) + } +} diff --git a/talosmeta/meta/meta.go b/talosmeta/meta/meta.go new file mode 100644 index 00000000..4aa6625d --- /dev/null +++ b/talosmeta/meta/meta.go @@ -0,0 +1,188 @@ +// Package meta reads and writes tags in the Talos META partition. +// +// The on-disk layout is the one Talos' internal meta package uses: the Talos +// ADV (two redundant 256 KiB copies) at the start of the partition and the +// legacy syslinux ADV in the last 1 KiB. Both are handled by the +// github.com/siderolabs/go-adv module that Talos itself uses. +package meta + +import ( + "errors" + "fmt" + "io" + "os" + + diskfs "github.com/diskfs/go-diskfs" + "github.com/diskfs/go-diskfs/partition/gpt" + "github.com/siderolabs/go-adv/adv" + "github.com/siderolabs/go-adv/adv/syslinux" + advtalos "github.com/siderolabs/go-adv/adv/talos" +) + +// MetalNetworkPlatformConfig is the META key holding the metal platform +// network configuration; it matches meta.MetalNetworkPlatformConfig in +// github.com/siderolabs/talos/pkg/machinery/meta. +const MetalNetworkPlatformConfig uint8 = 0x0a + +// PartitionLabel is the GPT partition name Talos gives the META partition. +const PartitionLabel = "META" + +// Partition is the location of the META partition on a disk. +type Partition struct { + Path string + Offset int64 + Size int64 +} + +// Locate finds the META partition in the GPT of diskPath. +func Locate(diskPath string) (Partition, error) { + d, err := diskfs.Open(diskPath, diskfs.WithOpenMode(diskfs.ReadOnly)) + if err != nil { + return Partition{}, fmt.Errorf("opening %s: %w", diskPath, err) + } + defer d.Close() + + table, err := d.GetPartitionTable() + if err != nil { + return Partition{}, fmt.Errorf("reading partition table of %s: %w", diskPath, err) + } + + gptTable, ok := table.(*gpt.Table) + if !ok { + return Partition{}, fmt.Errorf("%s has a %s partition table, Talos requires GPT", diskPath, table.Type()) + } + + sectorSize := int64(gptTable.LogicalSectorSize) + if sectorSize == 0 { + sectorSize = d.LogicalBlocksize + } + + for _, p := range gptTable.Partitions { + if p == nil || p.Name != PartitionLabel { + continue + } + + return Partition{ + Path: diskPath, + Offset: int64(p.Start) * sectorSize, + Size: int64(p.End-p.Start+1) * sectorSize, + }, nil + } + + return Partition{}, fmt.Errorf("no partition labelled %s found on %s", PartitionLabel, diskPath) +} + +// ReadTag returns the value stored under tag, if any. +func ReadTag(diskPath string, tag uint8) ([]byte, bool, error) { + p, err := Locate(diskPath) + if err != nil { + return nil, false, err + } + + f, err := os.Open(diskPath) + if err != nil { + return nil, false, fmt.Errorf("opening %s: %w", diskPath, err) + } + defer f.Close() + + talosADV, legacyADV, err := load(f, p) + if err != nil { + return nil, false, err + } + + if value, ok := talosADV.ReadTagBytes(tag); ok { + return value, true, nil + } + + value, ok := legacyADV.ReadTagBytes(tag) + + return value, ok, nil +} + +// WriteTag stores value under tag, keeping every other tag intact, and flushes +// both ADV copies to the partition. +func WriteTag(diskPath string, tag uint8, value []byte) error { + p, err := Locate(diskPath) + if err != nil { + return err + } + + f, err := os.OpenFile(diskPath, os.O_RDWR, 0) + if err != nil { + return fmt.Errorf("opening %s for writing: %w", diskPath, err) + } + defer f.Close() + + talosADV, legacyADV, err := load(f, p) + if err != nil { + return err + } + + if !talosADV.SetTagBytes(tag, value) { + return fmt.Errorf("value of %d bytes does not fit in the META partition", len(value)) + } + + return flush(f, p, talosADV, legacyADV) +} + +func load(f *os.File, p Partition) (adv.ADV, adv.ADV, error) { + if p.Size < advtalos.Size+2*syslinux.AdvSize { + return nil, nil, fmt.Errorf("META partition is %d bytes, smaller than the %d bytes Talos needs", p.Size, advtalos.Size+2*syslinux.AdvSize) + } + + section := io.NewSectionReader(f, p.Offset, p.Size) + + talosADV, err := advtalos.NewADV(section) + if talosADV == nil && err != nil { + return nil, nil, fmt.Errorf("loading Talos ADV: %w", err) + } + + // A non-nil ADV with an error means the partition holds no valid ADV yet + // (fresh image); Talos starts from an empty one in that case, and so do we. + + legacyADV, err := syslinux.NewADV(section) + if err != nil { + return nil, nil, fmt.Errorf("loading syslinux ADV: %w", err) + } + + return talosADV, legacyADV, nil +} + +func flush(f *os.File, p Partition, talosADV, legacyADV adv.ADV) error { + serialized, err := talosADV.Bytes() + if err != nil { + return fmt.Errorf("serializing Talos ADV: %w", err) + } + + if err := writeAt(f, p.Offset, serialized); err != nil { + return err + } + + legacy, err := legacyADV.Bytes() + if err != nil { + return fmt.Errorf("serializing syslinux ADV: %w", err) + } + + if err := writeAt(f, p.Offset+p.Size-int64(len(legacy)), legacy); err != nil { + return err + } + + if err := f.Sync(); err != nil { + return fmt.Errorf("syncing %s: %w", f.Name(), err) + } + + return nil +} + +func writeAt(f *os.File, offset int64, data []byte) error { + n, err := f.WriteAt(data, offset) + if err != nil { + return fmt.Errorf("writing META at offset %d: %w", offset, err) + } + + if n != len(data) { + return errors.New("short write to META partition") + } + + return nil +} diff --git a/talosmeta/meta/meta_test.go b/talosmeta/meta/meta_test.go new file mode 100644 index 00000000..f9bbdc9d --- /dev/null +++ b/talosmeta/meta/meta_test.go @@ -0,0 +1,176 @@ +package meta + +import ( + "bytes" + "io" + "os" + "path/filepath" + "testing" + + diskfs "github.com/diskfs/go-diskfs" + "github.com/diskfs/go-diskfs/partition/gpt" + advtalos "github.com/siderolabs/go-adv/adv/talos" +) + +const sectorSize = 512 + +// newTalosLikeDisk creates a GPT image with an EFI partition followed by a +// 1 MiB META partition, like a Talos metal image. +func newTalosLikeDisk(t *testing.T, withMeta bool) string { + t.Helper() + + path := filepath.Join(t.TempDir(), "disk.img") + d, err := diskfs.Create(path, 8*1024*1024, diskfs.SectorSize512) + if err != nil { + t.Fatal(err) + } + + parts := []*gpt.Partition{ + {Index: 1, Start: 2048, End: 4095, Type: gpt.EFISystemPartition, Name: "EFI"}, + } + if withMeta { + parts = append(parts, &gpt.Partition{Index: 2, Start: 4096, End: 6143, Type: gpt.LinuxFilesystem, Name: "META"}) + } + if err := d.Partition(&gpt.Table{Partitions: parts, LogicalSectorSize: sectorSize, PhysicalSectorSize: sectorSize, ProtectiveMBR: true}); err != nil { + t.Fatal(err) + } + if err := d.Close(); err != nil { + t.Fatal(err) + } + + return path +} + +func readTalosADV(t *testing.T, path string, offset int64) *advtalos.ADV { + t.Helper() + + f, err := os.Open(path) + if err != nil { + t.Fatal(err) + } + defer f.Close() + + adv, err := advtalos.NewADV(io.NewSectionReader(f, offset, advtalos.Size)) + if err != nil { + t.Fatalf("loading ADV: %v", err) + } + + return adv +} + +func TestLocateFindsMetaPartition(t *testing.T) { + path := newTalosLikeDisk(t, true) + + p, err := Locate(path) + if err != nil { + t.Fatalf("Locate() error: %v", err) + } + if p.Offset != 4096*sectorSize { + t.Fatalf("expected offset %d, got %d", 4096*sectorSize, p.Offset) + } + if p.Size != 2048*sectorSize { + t.Fatalf("expected size %d, got %d", 2048*sectorSize, p.Size) + } +} + +func TestLocateFailsWithoutMetaPartition(t *testing.T) { + path := newTalosLikeDisk(t, false) + + if _, err := Locate(path); err == nil { + t.Fatal("expected an error when META is missing") + } +} + +func TestWriteTagOnBlankPartition(t *testing.T) { + path := newTalosLikeDisk(t, true) + value := []byte("hostnames:\n - hostname: node1\n layer: platform\n") + + if err := WriteTag(path, MetalNetworkPlatformConfig, value); err != nil { + t.Fatalf("WriteTag() error: %v", err) + } + + adv := readTalosADV(t, path, 4096*sectorSize) + got, ok := adv.ReadTagBytes(MetalNetworkPlatformConfig) + if !ok || !bytes.Equal(got, value) { + t.Fatalf("expected tag 0xa to hold the document, got ok=%v value=%q", ok, got) + } + + // Talos keeps two copies of the ADV back to back. + f, err := os.Open(path) + if err != nil { + t.Fatal(err) + } + defer f.Close() + first := make([]byte, advtalos.Length) + second := make([]byte, advtalos.Length) + if _, err := f.ReadAt(first, 4096*sectorSize); err != nil { + t.Fatal(err) + } + if _, err := f.ReadAt(second, 4096*sectorSize+advtalos.Length); err != nil { + t.Fatal(err) + } + if !bytes.Equal(first, second) { + t.Fatal("expected the second ADV copy to match the first") + } +} + +func TestWriteTagPreservesOtherTags(t *testing.T) { + path := newTalosLikeDisk(t, true) + + if err := WriteTag(path, 0x0c, []byte("keep me")); err != nil { + t.Fatalf("WriteTag() error: %v", err) + } + if err := WriteTag(path, MetalNetworkPlatformConfig, []byte("first")); err != nil { + t.Fatalf("WriteTag() error: %v", err) + } + if err := WriteTag(path, MetalNetworkPlatformConfig, []byte("second")); err != nil { + t.Fatalf("WriteTag() error: %v", err) + } + + adv := readTalosADV(t, path, 4096*sectorSize) + if got, ok := adv.ReadTag(MetalNetworkPlatformConfig); !ok || got != "second" { + t.Fatalf("expected tag 0xa to be overwritten, got ok=%v value=%q", ok, got) + } + if got, ok := adv.ReadTag(0x0c); !ok || got != "keep me" { + t.Fatalf("expected tag 0xc to survive, got ok=%v value=%q", ok, got) + } +} + +func TestReadTag(t *testing.T) { + path := newTalosLikeDisk(t, true) + + if _, ok, err := ReadTag(path, MetalNetworkPlatformConfig); err != nil || ok { + t.Fatalf("expected no tag on a blank partition, got ok=%v err=%v", ok, err) + } + if err := WriteTag(path, MetalNetworkPlatformConfig, []byte("doc")); err != nil { + t.Fatalf("WriteTag() error: %v", err) + } + got, ok, err := ReadTag(path, MetalNetworkPlatformConfig) + if err != nil || !ok || string(got) != "doc" { + t.Fatalf("expected doc, got ok=%v value=%q err=%v", ok, got, err) + } +} + +func TestWriteTagLeavesSyslinuxAreaValid(t *testing.T) { + path := newTalosLikeDisk(t, true) + + if err := WriteTag(path, MetalNetworkPlatformConfig, []byte("doc")); err != nil { + t.Fatalf("WriteTag() error: %v", err) + } + + f, err := os.Open(path) + if err != nil { + t.Fatal(err) + } + defer f.Close() + + // The legacy syslinux ADV occupies the last 1 KiB of the partition; Talos + // writes it back untouched, so a blank partition stays blank there. + tail := make([]byte, 1024) + if _, err := f.ReadAt(tail, 6144*sectorSize-1024); err != nil { + t.Fatal(err) + } + if !bytes.Equal(tail, make([]byte, 1024)) { + t.Fatal("expected the syslinux ADV area to remain zeroed") + } +} diff --git a/talosmeta/talosnet/config.go b/talosmeta/talosnet/config.go new file mode 100644 index 00000000..d11fee60 --- /dev/null +++ b/talosmeta/talosnet/config.go @@ -0,0 +1,148 @@ +// Package talosnet builds the Talos Linux metal platform network +// configuration document from Tinkerbell Hardware data. +// +// The document layout mirrors network.PlatformConfigSpec from +// github.com/siderolabs/talos/pkg/machinery, which is what the metal platform +// decodes from META key 0xa. Only the fields this action emits are modelled; +// Talos ignores absent fields. +package talosnet + +import ( + "fmt" + + "go.yaml.in/yaml/v3" +) + +const ( + layerPlatform = "platform" + familyInet4 = "inet4" + familyInet6 = "inet6" + scopeGlobal = "global" + flagsPermanent = "permanent" + tableMain = "main" + routeUnicast = "unicast" + protocolStatic = "static" + linkKindVLAN = "vlan" + linkTypeEther = "ether" + vlanProtocol = "802.1q" + + // Talos gives IPv4 and IPv6 default routes from platform config these + // priorities (see the metal network configuration example in the docs). + priorityInet4 = 1024 + priorityInet6 = 2048 +) + +// Config is the platform network configuration document. +type Config struct { + Addresses []Address `yaml:"addresses"` + Links []Link `yaml:"links"` + Routes []Route `yaml:"routes"` + Hostnames []Hostname `yaml:"hostnames"` + Resolvers []Resolver `yaml:"resolvers"` + TimeServers []TimeServer `yaml:"timeServers"` + Operators []Operator `yaml:"operators"` + ExternalIPs []string `yaml:"externalIPs"` +} + +// Address mirrors network.AddressSpecSpec. +type Address struct { + Address string `yaml:"address"` + LinkName string `yaml:"linkName"` + Family string `yaml:"family"` + Scope string `yaml:"scope"` + Flags string `yaml:"flags"` + Layer string `yaml:"layer"` +} + +// Link mirrors the subset of network.LinkSpecSpec used here. +type Link struct { + Name string `yaml:"name"` + Logical bool `yaml:"logical,omitempty"` + Up bool `yaml:"up"` + Kind string `yaml:"kind,omitempty"` + Type string `yaml:"type,omitempty"` + ParentName string `yaml:"parentName,omitempty"` + VLAN *VLAN `yaml:"vlan,omitempty"` + Layer string `yaml:"layer"` +} + +// VLAN mirrors network.VLANSpec. +type VLAN struct { + ID uint16 `yaml:"vlanID"` + Protocol string `yaml:"vlanProtocol"` +} + +// Route mirrors network.RouteSpecSpec. +type Route struct { + Family string `yaml:"family"` + Destination string `yaml:"dst,omitempty"` + Gateway string `yaml:"gateway"` + OutLinkName string `yaml:"outLinkName"` + Table string `yaml:"table"` + Priority uint32 `yaml:"priority,omitempty"` + Scope string `yaml:"scope"` + Type string `yaml:"type"` + Protocol string `yaml:"protocol"` + Layer string `yaml:"layer"` +} + +// Hostname mirrors network.HostnameSpecSpec. +type Hostname struct { + Hostname string `yaml:"hostname"` + Domainname string `yaml:"domainname,omitempty"` + Layer string `yaml:"layer"` +} + +// Resolver mirrors network.ResolverSpecSpec. +type Resolver struct { + DNSServers []string `yaml:"dnsServers"` + Layer string `yaml:"layer"` +} + +// TimeServer mirrors network.TimeServerSpecSpec. +type TimeServer struct { + Servers []string `yaml:"timeServers"` + Layer string `yaml:"layer"` +} + +// Operator mirrors network.OperatorSpecSpec. No operators are emitted; the +// type exists so the document carries an explicit empty list like Talos does. +type Operator struct { + Operator string `yaml:"operator"` + LinkName string `yaml:"linkName"` + Layer string `yaml:"layer"` +} + +func newConfig() *Config { + return &Config{ + Addresses: []Address{}, + Links: []Link{}, + Routes: []Route{}, + Hostnames: []Hostname{}, + Resolvers: []Resolver{}, + TimeServers: []TimeServer{}, + Operators: []Operator{}, + ExternalIPs: []string{}, + } +} + +// Marshal renders the document as YAML in the layout Talos expects. +func (c *Config) Marshal() ([]byte, error) { + out, err := yaml.Marshal(c) + if err != nil { + return nil, fmt.Errorf("marshalling network configuration: %w", err) + } + + return out, nil +} + +// Parse decodes a YAML document. It is used to validate operator-supplied +// configuration before it is written to META. +func Parse(doc []byte) (*Config, error) { + var cfg Config + if err := yaml.Unmarshal(doc, &cfg); err != nil { + return nil, fmt.Errorf("parsing network configuration: %w", err) + } + + return &cfg, nil +} diff --git a/talosmeta/talosnet/mapping.go b/talosmeta/talosnet/mapping.go new file mode 100644 index 00000000..4c8e098c --- /dev/null +++ b/talosmeta/talosnet/mapping.go @@ -0,0 +1,307 @@ +package talosnet + +import ( + "errors" + "fmt" + "net" + "net/netip" + "strconv" + "strings" + + "github.com/tinkerbell/actions/talosmeta/hardware" +) + +// Namer resolves the interface name Talos will use for a MAC address. +type Namer interface { + Name(mac string) (string, error) +} + +// FromHardware maps Tinkerbell Hardware data onto a Talos platform network +// configuration. Every interface with a static address becomes a link, an +// address and, when a gateway is set, a default route. Hostname, resolvers, +// time servers and external IPs are collected once for the machine. +func FromHardware(spec *hardware.Spec, namer Namer) (*Config, error) { + cfg := newConfig() + + var ( + hostname, domain string + nameServers, timeServers []string + configured int + ) + + for i, iface := range spec.Interfaces { + d := iface.DHCP + if d == nil || d.IP == nil || d.IP.Address == "" { + continue + } + + if err := addInterface(cfg, d, namer); err != nil { + return nil, fmt.Errorf("interface %d (%s): %w", i, d.MAC, err) + } + + if hostname == "" { + hostname = d.Hostname + } + + if domain == "" { + domain = d.DomainName + } + + nameServers = appendUnique(nameServers, d.NameServers...) + timeServers = appendUnique(timeServers, d.TimeServers...) + configured++ + } + + if configured == 0 { + return nil, errors.New("no interface with a static address (interfaces[].dhcp.ip) found in the hardware data") + } + + if spec.Metadata != nil && spec.Metadata.Instance != nil { + inst := spec.Metadata.Instance + + if inst.Hostname != "" { + hostname = inst.Hostname + } + + for _, ip := range inst.IPs { + if !ip.Public || ip.Address == "" { + continue + } + + addr, err := netip.ParseAddr(ip.Address) + if err != nil { + return nil, fmt.Errorf("instance ip: parsing address %q: %w", ip.Address, err) + } + + cfg.ExternalIPs = appendUnique(cfg.ExternalIPs, addr.String()) + } + } + + if hostname != "" { + host, dom := splitFQDN(hostname) + if domain == "" { + domain = dom + } + + cfg.Hostnames = append(cfg.Hostnames, Hostname{Hostname: host, Domainname: domain, Layer: layerPlatform}) + } + + if len(nameServers) > 0 { + cfg.Resolvers = append(cfg.Resolvers, Resolver{DNSServers: nameServers, Layer: layerPlatform}) + } + + if len(timeServers) > 0 { + cfg.TimeServers = append(cfg.TimeServers, TimeServer{Servers: timeServers, Layer: layerPlatform}) + } + + return cfg, nil +} + +func addInterface(cfg *Config, d *hardware.DHCP, namer Namer) error { + linkName, err := resolveLinkName(d, namer) + if err != nil { + return err + } + + addr, err := netip.ParseAddr(d.IP.Address) + if err != nil { + return fmt.Errorf("parsing address %q: %w", d.IP.Address, err) + } + + family := familyOf(addr) + + prefixLen, err := PrefixLength(d.IP.Netmask, familyNumber(addr)) + if err != nil { + return fmt.Errorf("netmask %q: %w", d.IP.Netmask, err) + } + + cfg.Links = append(cfg.Links, Link{Name: linkName, Up: true, Layer: layerPlatform}) + + attachTo := linkName + + if d.VLANID != "" { + vid, err := strconv.ParseUint(d.VLANID, 10, 12) + if err != nil { + return fmt.Errorf("parsing vlan_id %q: %w", d.VLANID, err) + } + + attachTo = fmt.Sprintf("%s.%d", linkName, vid) + + cfg.Links = append(cfg.Links, Link{ + Name: attachTo, + Logical: true, + Up: true, + Kind: linkKindVLAN, + Type: linkTypeEther, + ParentName: linkName, + VLAN: &VLAN{ID: uint16(vid), Protocol: vlanProtocol}, + Layer: layerPlatform, + }) + } + + cfg.Addresses = append(cfg.Addresses, Address{ + Address: netip.PrefixFrom(addr, prefixLen).String(), + LinkName: attachTo, + Family: family, + Scope: scopeGlobal, + Flags: flagsPermanent, + Layer: layerPlatform, + }) + + if d.IP.Gateway != "" { + gw, err := netip.ParseAddr(d.IP.Gateway) + if err != nil { + return fmt.Errorf("parsing gateway %q: %w", d.IP.Gateway, err) + } + + cfg.Routes = append(cfg.Routes, Route{ + Family: family, + Gateway: gw.String(), + OutLinkName: attachTo, + Table: tableMain, + Priority: defaultRoutePriority(addr), + Scope: scopeGlobal, + Type: routeUnicast, + Protocol: protocolStatic, + Layer: layerPlatform, + }) + } + + for _, r := range d.ClasslessStaticRoutes { + dst, err := netip.ParsePrefix(r.DestinationDescriptor) + if err != nil { + return fmt.Errorf("parsing static route destination %q: %w", r.DestinationDescriptor, err) + } + + gw, err := netip.ParseAddr(r.Router) + if err != nil { + return fmt.Errorf("parsing static route router %q: %w", r.Router, err) + } + + cfg.Routes = append(cfg.Routes, Route{ + Family: familyOf(dst.Addr()), + Destination: dst.Masked().String(), + Gateway: gw.String(), + OutLinkName: attachTo, + Table: tableMain, + Scope: scopeGlobal, + Type: routeUnicast, + Protocol: protocolStatic, + Layer: layerPlatform, + }) + } + + return nil +} + +func resolveLinkName(d *hardware.DHCP, namer Namer) (string, error) { + if d.IfaceName != "" { + return d.IfaceName, nil + } + + if d.MAC == "" { + return "", errors.New("resolving link name: neither iface_name nor mac is set") + } + + name, err := namer.Name(d.MAC) + if err != nil { + return "", fmt.Errorf("resolving link name: %w", err) + } + + return name, nil +} + +// PrefixLength converts a netmask to a prefix length. The netmask may be a +// dotted quad (255.255.255.0), an IPv6 mask, or a bare prefix length with or +// without a leading slash. family is 4 or 6. +func PrefixLength(netmask string, family int64) (int, error) { + netmask = strings.TrimPrefix(strings.TrimSpace(netmask), "/") + if netmask == "" { + return 0, errors.New("netmask is empty") + } + + maxBits := 32 + if family == 6 { + maxBits = 128 + } + + if n, err := strconv.Atoi(netmask); err == nil { + if n < 0 || n > maxBits { + return 0, fmt.Errorf("prefix length %d is out of range for IPv%d", n, family) + } + + return n, nil + } + + addr, err := netip.ParseAddr(netmask) + if err != nil { + return 0, fmt.Errorf("netmask %q is neither a prefix length nor an address", netmask) + } + + if addr.BitLen() != maxBits { + return 0, fmt.Errorf("netmask %q does not match IPv%d", netmask, family) + } + + ones, bits := net.IPMask(addr.AsSlice()).Size() + if bits == 0 { + return 0, fmt.Errorf("netmask %q is not contiguous", netmask) + } + + return ones, nil +} + +func familyOf(addr netip.Addr) string { + if addr.Is4() { + return familyInet4 + } + + return familyInet6 +} + +func familyNumber(addr netip.Addr) int64 { + if addr.Is4() { + return 4 + } + + return 6 +} + +func defaultRoutePriority(addr netip.Addr) uint32 { + if addr.Is4() { + return priorityInet4 + } + + return priorityInet6 +} + +// splitFQDN separates a host name from its domain at the first dot. +func splitFQDN(name string) (host, domain string) { + host, domain, _ = strings.Cut(name, ".") + + return host, domain +} + +func appendUnique(list []string, values ...string) []string { + for _, v := range values { + v = strings.TrimSpace(v) + if v == "" { + continue + } + + duplicate := false + + for _, existing := range list { + if existing == v { + duplicate = true + + break + } + } + + if !duplicate { + list = append(list, v) + } + } + + return list +} diff --git a/talosmeta/talosnet/mapping_test.go b/talosmeta/talosnet/mapping_test.go new file mode 100644 index 00000000..50252486 --- /dev/null +++ b/talosmeta/talosnet/mapping_test.go @@ -0,0 +1,320 @@ +package talosnet + +import ( + "errors" + "reflect" + "strings" + "testing" + + "go.yaml.in/yaml/v3" + + "github.com/tinkerbell/actions/talosmeta/hardware" +) + +type staticNamer map[string]string + +func (s staticNamer) Name(mac string) (string, error) { + if name, ok := s[mac]; ok { + return name, nil + } + return "", errors.New("unknown mac " + mac) +} + +func iface(mac, address, netmask, gateway string, family int64) hardware.Interface { + return hardware.Interface{DHCP: &hardware.DHCP{ + MAC: mac, + IP: &hardware.IP{Address: address, Netmask: netmask, Gateway: gateway, Family: family}, + }} +} + +func decodeYAML(t *testing.T, doc []byte) any { + t.Helper() + var out any + if err := yaml.Unmarshal(doc, &out); err != nil { + t.Fatalf("invalid yaml: %v\n%s", err, doc) + } + return out +} + +func assertYAML(t *testing.T, cfg *Config, want string) { + t.Helper() + got, err := cfg.Marshal() + if err != nil { + t.Fatalf("Marshal() error: %v", err) + } + if !reflect.DeepEqual(decodeYAML(t, got), decodeYAML(t, []byte(want))) { + t.Fatalf("unexpected document\n--- got ---\n%s\n--- want ---\n%s", got, want) + } +} + +func TestFromHardwareStaticIPv4Interface(t *testing.T) { + in := iface("52:54:00:12:34:01", "10.0.80.10", "255.255.255.0", "10.0.80.1", 4) + in.DHCP.Hostname = "node1" + in.DHCP.DomainName = "example.com" + in.DHCP.NameServers = []string{"10.0.0.2", "10.0.0.2"} + in.DHCP.TimeServers = []string{"10.0.0.3"} + in.DHCP.ClasslessStaticRoutes = []hardware.ClasslessStaticRoute{{DestinationDescriptor: "192.168.0.0/16", Router: "10.0.80.254"}} + + spec := &hardware.Spec{ + Interfaces: []hardware.Interface{in}, + Metadata: &hardware.Metadata{Instance: &hardware.Instance{ + IPs: []hardware.InstanceIP{{Address: "203.0.113.5", Family: 4, Public: true}, {Address: "10.0.80.10", Family: 4}}, + }}, + } + + cfg, err := FromHardware(spec, staticNamer{"52:54:00:12:34:01": "eth0"}) + if err != nil { + t.Fatalf("FromHardware() error: %v", err) + } + + assertYAML(t, cfg, ` +addresses: + - address: 10.0.80.10/24 + linkName: eth0 + family: inet4 + scope: global + flags: permanent + layer: platform +links: + - name: eth0 + up: true + layer: platform +routes: + - family: inet4 + gateway: 10.0.80.1 + outLinkName: eth0 + table: main + priority: 1024 + scope: global + type: unicast + protocol: static + layer: platform + - family: inet4 + dst: 192.168.0.0/16 + gateway: 10.0.80.254 + outLinkName: eth0 + table: main + scope: global + type: unicast + protocol: static + layer: platform +hostnames: + - hostname: node1 + domainname: example.com + layer: platform +resolvers: + - dnsServers: + - 10.0.0.2 + layer: platform +timeServers: + - timeServers: + - 10.0.0.3 + layer: platform +operators: [] +externalIPs: + - 203.0.113.5 +`) +} + +func TestFromHardwareIPv6UsesPrefixLengthAndHigherPriority(t *testing.T) { + spec := &hardware.Spec{Interfaces: []hardware.Interface{iface("52:54:00:12:34:01", "2001:db8::10", "64", "2001:db8::1", 6)}} + + cfg, err := FromHardware(spec, staticNamer{"52:54:00:12:34:01": "eth0"}) + if err != nil { + t.Fatalf("FromHardware() error: %v", err) + } + + assertYAML(t, cfg, ` +addresses: + - address: 2001:db8::10/64 + linkName: eth0 + family: inet6 + scope: global + flags: permanent + layer: platform +links: + - name: eth0 + up: true + layer: platform +routes: + - family: inet6 + gateway: 2001:db8::1 + outLinkName: eth0 + table: main + priority: 2048 + scope: global + type: unicast + protocol: static + layer: platform +hostnames: [] +resolvers: [] +timeServers: [] +operators: [] +externalIPs: [] +`) +} + +func TestFromHardwareVLANAttachesAddressToVLANLink(t *testing.T) { + in := iface("52:54:00:12:34:01", "10.0.80.10", "24", "", 4) + in.DHCP.VLANID = "100" + spec := &hardware.Spec{Interfaces: []hardware.Interface{in}} + + cfg, err := FromHardware(spec, staticNamer{"52:54:00:12:34:01": "eth0"}) + if err != nil { + t.Fatalf("FromHardware() error: %v", err) + } + + assertYAML(t, cfg, ` +addresses: + - address: 10.0.80.10/24 + linkName: eth0.100 + family: inet4 + scope: global + flags: permanent + layer: platform +links: + - name: eth0 + up: true + layer: platform + - name: eth0.100 + logical: true + up: true + kind: vlan + type: ether + parentName: eth0 + vlan: + vlanID: 100 + vlanProtocol: 802.1q + layer: platform +routes: [] +hostnames: [] +resolvers: [] +timeServers: [] +operators: [] +externalIPs: [] +`) +} + +func TestFromHardwarePrefersInstanceHostnameAndIfaceName(t *testing.T) { + in := iface("52:54:00:12:34:01", "10.0.80.10", "24", "", 4) + in.DHCP.Hostname = "dhcp-name" + in.DHCP.IfaceName = "enp1s0" + spec := &hardware.Spec{ + Interfaces: []hardware.Interface{in}, + Metadata: &hardware.Metadata{Instance: &hardware.Instance{Hostname: "instance-name"}}, + } + + cfg, err := FromHardware(spec, staticNamer{}) + if err != nil { + t.Fatalf("FromHardware() error: %v", err) + } + if len(cfg.Hostnames) != 1 || cfg.Hostnames[0].Hostname != "instance-name" { + t.Fatalf("expected instance hostname, got %+v", cfg.Hostnames) + } + if len(cfg.Links) != 1 || cfg.Links[0].Name != "enp1s0" { + t.Fatalf("expected iface_name to win, got %+v", cfg.Links) + } +} + +func TestFromHardwareSkipsInterfacesWithoutStaticIP(t *testing.T) { + spec := &hardware.Spec{Interfaces: []hardware.Interface{ + {DHCP: &hardware.DHCP{MAC: "52:54:00:12:34:02"}}, + {}, + iface("52:54:00:12:34:01", "10.0.80.10", "24", "", 4), + }} + + cfg, err := FromHardware(spec, staticNamer{"52:54:00:12:34:01": "eth0"}) + if err != nil { + t.Fatalf("FromHardware() error: %v", err) + } + if len(cfg.Addresses) != 1 || len(cfg.Links) != 1 { + t.Fatalf("expected a single address and link, got %+v / %+v", cfg.Addresses, cfg.Links) + } +} + +func TestFromHardwareErrors(t *testing.T) { + tests := []struct { + name string + spec *hardware.Spec + want string + }{ + {"no interfaces", &hardware.Spec{}, "no interface"}, + {"no static ip", &hardware.Spec{Interfaces: []hardware.Interface{{DHCP: &hardware.DHCP{MAC: "52:54:00:12:34:01"}}}}, "no interface"}, + {"missing netmask", &hardware.Spec{Interfaces: []hardware.Interface{iface("52:54:00:12:34:01", "10.0.80.10", "", "", 4)}}, "netmask"}, + {"bad address", &hardware.Spec{Interfaces: []hardware.Interface{iface("52:54:00:12:34:01", "nope", "24", "", 4)}}, "address"}, + {"bad gateway", &hardware.Spec{Interfaces: []hardware.Interface{iface("52:54:00:12:34:01", "10.0.80.10", "24", "nope", 4)}}, "gateway"}, + {"unknown mac", &hardware.Spec{Interfaces: []hardware.Interface{iface("52:54:00:ff:ff:ff", "10.0.80.10", "24", "", 4)}}, "link name"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := FromHardware(tt.spec, staticNamer{"52:54:00:12:34:01": "eth0"}) + if err == nil || !strings.Contains(err.Error(), tt.want) { + t.Fatalf("expected error containing %q, got %v", tt.want, err) + } + }) + } +} + +func TestPrefixLength(t *testing.T) { + tests := []struct { + netmask string + family int64 + want int + wantErr bool + }{ + {"255.255.255.0", 4, 24, false}, + {"255.255.255.255", 4, 32, false}, + {"0.0.0.0", 4, 0, false}, + {"24", 4, 24, false}, + {"/24", 4, 24, false}, + {"64", 6, 64, false}, + {"ffff:ffff:ffff:ffff::", 6, 64, false}, + {"255.255.0.255", 4, 0, true}, + {"33", 4, 0, true}, + {"129", 6, 0, true}, + {"", 4, 0, true}, + } + for _, tt := range tests { + t.Run(tt.netmask, func(t *testing.T) { + got, err := PrefixLength(tt.netmask, tt.family) + if (err != nil) != tt.wantErr { + t.Fatalf("PrefixLength(%q) error = %v, wantErr %v", tt.netmask, err, tt.wantErr) + } + if got != tt.want { + t.Fatalf("PrefixLength(%q) = %d, want %d", tt.netmask, got, tt.want) + } + }) + } +} + +func TestParseFailsOnInvalidYAML(t *testing.T) { + if _, err := Parse([]byte("addresses: [")); err == nil { + t.Fatal("expected an error") + } +} + +func TestParseRoundTrip(t *testing.T) { + doc := []byte("hostnames:\n - hostname: node1\n layer: platform\n") + cfg, err := Parse(doc) + if err != nil { + t.Fatalf("Parse() error: %v", err) + } + if len(cfg.Hostnames) != 1 || cfg.Hostnames[0].Hostname != "node1" { + t.Fatalf("unexpected config: %+v", cfg) + } +} + +func TestFromHardwareSplitsFQDNHostname(t *testing.T) { + spec := &hardware.Spec{ + Interfaces: []hardware.Interface{iface("52:54:00:12:34:01", "10.0.80.10", "24", "", 4)}, + Metadata: &hardware.Metadata{Instance: &hardware.Instance{Hostname: "node1.example.com"}}, + } + + cfg, err := FromHardware(spec, staticNamer{"52:54:00:12:34:01": "eth0"}) + if err != nil { + t.Fatalf("FromHardware() error: %v", err) + } + if len(cfg.Hostnames) != 1 || cfg.Hostnames[0].Hostname != "node1" || cfg.Hostnames[0].Domainname != "example.com" { + t.Fatalf("expected node1 / example.com, got %+v", cfg.Hostnames) + } +} diff --git a/talosmeta/talosnet/naming.go b/talosmeta/talosnet/naming.go new file mode 100644 index 00000000..47651ad2 --- /dev/null +++ b/talosmeta/talosnet/naming.go @@ -0,0 +1,216 @@ +package talosnet + +import ( + "fmt" + "log/slog" + "os" + "path/filepath" + "regexp" + "strconv" + "strings" +) + +// SysfsNamer finds the interface that carries a MAC address by reading sysfs. +// +// With Predictable set it derives the name udev's predictable naming scheme +// assigns (the scheme Talos uses unless booted with net.ifnames=0): onboard +// index (eno1), then PCI hotplug slot (ens3), then PCI path (enp1s0f1). When +// the device is not on PCI the kernel name is returned unchanged. +type SysfsNamer struct { + // Root is the sysfs mount point; defaults to /sys. + Root string + // Predictable selects udev-style names instead of kernel names. + Predictable bool + // Logger receives a warning when a predictable name cannot be derived. + Logger *slog.Logger +} + +const ( + defaultSysfsRoot = "/sys" + // onboardIndexMax matches systemd's ONBOARD_INDEX_MAX. + onboardIndexMax = 16383 +) + +var pciAddress = regexp.MustCompile(`^([0-9a-f]{4}):([0-9a-f]{2}):([0-9a-f]{2})\.([0-7])$`) + +// Name implements Namer. +func (n *SysfsNamer) Name(mac string) (string, error) { + root := n.Root + if root == "" { + root = defaultSysfsRoot + } + + netDir := filepath.Join(root, "class", "net") + + entries, err := os.ReadDir(netDir) + if err != nil { + return "", fmt.Errorf("listing %s: %w", netDir, err) + } + + want := strings.ToLower(strings.TrimSpace(mac)) + + for _, entry := range entries { + linkDir := filepath.Join(netDir, entry.Name()) + + address, err := os.ReadFile(filepath.Join(linkDir, "address")) + if err != nil { + continue + } + + if strings.ToLower(strings.TrimSpace(string(address))) != want { + continue + } + + kernelName := entry.Name() + if !n.Predictable { + return kernelName, nil + } + + if name, ok := predictableName(root, linkDir); ok { + return name, nil + } + + if n.Logger != nil { + n.Logger.Warn("Interface is not a PCI device, keeping the kernel name; set iface_name in the Hardware if Talos names it differently", "interface", kernelName, "mac", mac) + } + + return kernelName, nil + } + + return "", fmt.Errorf("no interface with MAC %s found under %s", mac, netDir) +} + +func predictableName(root, linkDir string) (string, bool) { + devicePath, err := filepath.EvalSymlinks(filepath.Join(linkDir, "device")) + if err != nil { + return "", false + } + + pciDev := nearestPCIDevice(devicePath) + if pciDev == "" { + return "", false + } + + for _, attr := range []string{"acpi_index", "index"} { + if idx, ok := readOnboardIndex(filepath.Join(pciDev, attr)); ok { + return "eno" + strconv.Itoa(idx), true + } + } + + if slot, ok := hotplugSlot(root, pciDev); ok { + return "ens" + strconv.Itoa(slot) + functionSuffix(pciDev), true + } + + return pciPathName(pciDev), true +} + +// nearestPCIDevice walks up from path until it reaches a directory named like +// a PCI address, which is how virtio and other bus children reach their PCI +// parent. +func nearestPCIDevice(path string) string { + for { + if pciAddress.MatchString(filepath.Base(path)) { + return path + } + + parent := filepath.Dir(path) + if parent == path { + return "" + } + + path = parent + } +} + +func readOnboardIndex(path string) (int, bool) { + raw, err := os.ReadFile(path) + if err != nil { + return 0, false + } + + idx, err := strconv.Atoi(strings.TrimSpace(string(raw))) + if err != nil || idx <= 0 || idx > onboardIndexMax { + return 0, false + } + + return idx, true +} + +// hotplugSlot reports the PCI hotplug slot number of the device or of one of +// its PCI ancestors, matching /sys/bus/pci/slots//address entries. +func hotplugSlot(root, pciDev string) (int, bool) { + slotsDir := filepath.Join(root, "bus", "pci", "slots") + + entries, err := os.ReadDir(slotsDir) + if err != nil { + return 0, false + } + + slots := map[string]int{} + + for _, entry := range entries { + slot, err := strconv.Atoi(entry.Name()) + if err != nil || slot < 0 { + continue + } + + raw, err := os.ReadFile(filepath.Join(slotsDir, entry.Name(), "address")) + if err != nil { + continue + } + + slots[strings.TrimSpace(string(raw))] = slot + } + + for dev := pciDev; pciAddress.MatchString(filepath.Base(dev)); dev = filepath.Dir(dev) { + address := strings.TrimSuffix(filepath.Base(dev), filepath.Ext(filepath.Base(dev))) + if slot, ok := slots[address]; ok { + return slot, true + } + } + + return 0, false +} + +func pciPathName(pciDev string) string { + m := pciAddress.FindStringSubmatch(filepath.Base(pciDev)) + domain, _ := strconv.ParseUint(m[1], 16, 16) + bus, _ := strconv.ParseUint(m[2], 16, 8) + slot, _ := strconv.ParseUint(m[3], 16, 8) + + var b strings.Builder + + b.WriteString("en") + + if domain != 0 { + fmt.Fprintf(&b, "P%d", domain) + } + + fmt.Fprintf(&b, "p%ds%d", bus, slot) + b.WriteString(functionSuffix(pciDev)) + + return b.String() +} + +// functionSuffix returns "f" when the PCI function is non-zero or the +// device is multi-function, matching systemd's net_id. +func functionSuffix(pciDev string) string { + m := pciAddress.FindStringSubmatch(filepath.Base(pciDev)) + function, _ := strconv.ParseUint(m[4], 16, 8) + + if function == 0 && !isMultifunction(pciDev) { + return "" + } + + return "f" + strconv.FormatUint(function, 10) +} + +func isMultifunction(pciDev string) bool { + config, err := os.ReadFile(filepath.Join(pciDev, "config")) + if err != nil || len(config) < 15 { + return false + } + + // PCI header type register, bit 7 flags a multi-function device. + return config[14]&0x80 != 0 +} diff --git a/talosmeta/talosnet/naming_test.go b/talosmeta/talosnet/naming_test.go new file mode 100644 index 00000000..b3e98c23 --- /dev/null +++ b/talosmeta/talosnet/naming_test.go @@ -0,0 +1,183 @@ +package talosnet + +import ( + "os" + "path/filepath" + "testing" +) + +// fakeSysfs builds the subset of sysfs that link naming looks at. +type fakeSysfs struct { + t *testing.T + root string +} + +func newFakeSysfs(t *testing.T) *fakeSysfs { + t.Helper() + return &fakeSysfs{t: t, root: t.TempDir()} +} + +// addLink registers a network interface. devicePath is relative to root, e.g. +// "devices/pci0000:00/0000:00:03.0/virtio0"; empty means no device link. +func (f *fakeSysfs) addLink(name, mac, devicePath string) { + f.t.Helper() + dir := filepath.Join(f.root, "class", "net", name) + if err := os.MkdirAll(dir, 0o755); err != nil { + f.t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "address"), []byte(mac+"\n"), 0o644); err != nil { + f.t.Fatal(err) + } + if devicePath == "" { + return + } + abs := filepath.Join(f.root, devicePath) + if err := os.MkdirAll(abs, 0o755); err != nil { + f.t.Fatal(err) + } + rel, err := filepath.Rel(dir, abs) + if err != nil { + f.t.Fatal(err) + } + if err := os.Symlink(rel, filepath.Join(dir, "device")); err != nil { + f.t.Fatal(err) + } +} + +func (f *fakeSysfs) writeAttr(devicePath, attr, value string) { + f.t.Helper() + if err := os.WriteFile(filepath.Join(f.root, devicePath, attr), []byte(value+"\n"), 0o644); err != nil { + f.t.Fatal(err) + } +} + +func (f *fakeSysfs) addSlot(slot int, address string) { + f.t.Helper() + dir := filepath.Join(f.root, "bus", "pci", "slots", itoa(slot)) + if err := os.MkdirAll(dir, 0o755); err != nil { + f.t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "address"), []byte(address+"\n"), 0o644); err != nil { + f.t.Fatal(err) + } +} + +func itoa(i int) string { + return string(rune('0' + i)) +} + +func TestSysfsNamerKernelName(t *testing.T) { + fs := newFakeSysfs(t) + fs.addLink("eth0", "52:54:00:12:34:01", "devices/pci0000:00/0000:00:03.0/virtio0") + fs.addLink("eth1", "52:54:00:12:34:02", "devices/pci0000:00/0000:00:04.0/virtio1") + + namer := &SysfsNamer{Root: fs.root, Predictable: false} + got, err := namer.Name("52:54:00:12:34:02") + if err != nil { + t.Fatalf("Name() error: %v", err) + } + if got != "eth1" { + t.Fatalf("expected eth1, got %q", got) + } +} + +func TestSysfsNamerMatchesMACCaseInsensitively(t *testing.T) { + fs := newFakeSysfs(t) + fs.addLink("eth0", "52:54:00:ab:cd:ef", "") + + namer := &SysfsNamer{Root: fs.root, Predictable: false} + got, err := namer.Name("52:54:00:AB:CD:EF") + if err != nil { + t.Fatalf("Name() error: %v", err) + } + if got != "eth0" { + t.Fatalf("expected eth0, got %q", got) + } +} + +func TestSysfsNamerUnknownMAC(t *testing.T) { + fs := newFakeSysfs(t) + fs.addLink("eth0", "52:54:00:12:34:01", "") + + namer := &SysfsNamer{Root: fs.root} + if _, err := namer.Name("52:54:00:ff:ff:ff"); err == nil { + t.Fatal("expected an error for an unknown MAC") + } +} + +func TestSysfsNamerPredictable(t *testing.T) { + tests := []struct { + name string + devicePath string + setup func(fs *fakeSysfs) + want string + }{ + { + name: "pci path for virtio child", + devicePath: "devices/pci0000:00/0000:00:03.0/virtio0", + want: "enp0s3", + }, + { + name: "pci path with function", + devicePath: "devices/pci0000:00/0000:00:1c.0/0000:01:00.1", + want: "enp1s0f1", + }, + { + name: "pci path with non-zero domain", + devicePath: "devices/pci0001:00/0001:02:00.0", + want: "enP1p2s0", + }, + { + name: "onboard acpi index wins", + devicePath: "devices/pci0000:00/0000:00:1f.6", + setup: func(fs *fakeSysfs) { fs.writeAttr("devices/pci0000:00/0000:00:1f.6", "acpi_index", "1") }, + want: "eno1", + }, + { + name: "onboard index attribute", + devicePath: "devices/pci0000:00/0000:00:19.0", + setup: func(fs *fakeSysfs) { fs.writeAttr("devices/pci0000:00/0000:00:19.0", "index", "2") }, + want: "eno2", + }, + { + name: "hotplug slot", + devicePath: "devices/pci0000:00/0000:00:1c.0/0000:03:00.0", + setup: func(fs *fakeSysfs) { fs.addSlot(3, "0000:03:00") }, + want: "ens3", + }, + { + name: "hotplug slot on parent bridge", + devicePath: "devices/pci0000:00/0000:00:1c.0/0000:03:00.0", + setup: func(fs *fakeSysfs) { fs.addSlot(5, "0000:00:1c") }, + want: "ens5", + }, + { + name: "non pci device keeps kernel name", + devicePath: "devices/platform/soc/fe300000.ethernet", + want: "eth0", + }, + { + name: "no device link keeps kernel name", + devicePath: "", + want: "eth0", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + fs := newFakeSysfs(t) + fs.addLink("eth0", "52:54:00:12:34:01", tt.devicePath) + if tt.setup != nil { + tt.setup(fs) + } + + namer := &SysfsNamer{Root: fs.root, Predictable: true} + got, err := namer.Name("52:54:00:12:34:01") + if err != nil { + t.Fatalf("Name() error: %v", err) + } + if got != tt.want { + t.Fatalf("expected %q, got %q", tt.want, got) + } + }) + } +} diff --git a/ubootenv/Dockerfile b/ubootenv/Dockerfile new file mode 100644 index 00000000..13cd73f2 --- /dev/null +++ b/ubootenv/Dockerfile @@ -0,0 +1,12 @@ +FROM golang:1.26-alpine AS ubootenv +RUN apk add --no-cache ca-certificates +COPY . /src +WORKDIR /src/ubootenv +RUN --mount=type=cache,sharing=locked,id=gomod,target=/go/pkg/mod/cache \ + --mount=type=cache,sharing=locked,id=goroot,target=/root/.cache/go-build \ + CGO_ENABLED=0 GOOS=linux go build -ldflags "-s -w" -o ubootenv + +FROM scratch +COPY --from=ubootenv /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt +COPY --from=ubootenv /src/ubootenv/ubootenv /usr/bin/ubootenv +ENTRYPOINT ["/usr/bin/ubootenv"] diff --git a/ubootenv/README.md b/ubootenv/README.md new file mode 100644 index 00000000..6918218c --- /dev/null +++ b/ubootenv/README.md @@ -0,0 +1,54 @@ +# UBoot Env + +``` +quay.io/tinkerbell/actions/ubootenv:latest +``` + +This action reads and modifies U-Boot environment variables stored in a `uboot.env` file +on a VFAT partition. It is a pure Go port of the U-Boot `fw_setenv` utility, supporting +the non-redundant VFAT file format (offset `0x0000`, default size `0x4000`). + +Environment variables are provided as a JSON object via the `ENV_VARS` environment variable. +Setting a variable's value to an empty string (`""`) deletes it from the environment. + +## Environment Variables + +| Variable | Description | Required | Default | +|---------------|--------------------------------------------------------|----------|------------------| +| `DEST_DISK` | Block device / partition to mount (e.g. `/dev/sda1`) | Yes | | +| `FS_TYPE` | Filesystem type for mounting | No | `vfat` | +| `ENV_FILE` | Path to `uboot.env` relative to mount root | No | `/boot/uboot.env`| +| `ENV_VARS` | JSON object of key/value pairs to set | Yes | | + +## Example Workflow Action + +```yaml +actions: + - name: "set u-boot environment" + image: quay.io/tinkerbell/actions/ubootenv:latest + timeout: 90 + environment: + DEST_DISK: /dev/sda1 + FS_TYPE: vfat + ENV_FILE: /boot/uboot.env + ENV_VARS: '{"bootcmd":"run distro_bootcmd","bootdelay":"3"}' +``` + +## Deleting Variables + +To delete a variable, set its value to an empty string: + +```yaml + ENV_VARS: '{"obsolete_var":""}' +``` + +## U-Boot Environment Format + +The action expects the standard non-redundant U-Boot environment file format: + +| Offset | Size | Field | +|--------|---------------|--------| +| 0x0000 | 4 bytes | CRC32 (IEEE, little-endian, computed over data) | +| 0x0004 | env_size - 4 | Data: null-terminated `key=value` pairs, double-null terminated, zero-padded | + +Default environment size: `0x4000` (16,384 bytes). diff --git a/ubootenv/main.go b/ubootenv/main.go new file mode 100644 index 00000000..7788c149 --- /dev/null +++ b/ubootenv/main.go @@ -0,0 +1,113 @@ +//go:build linux + +package main + +import ( + "encoding/json" + "errors" + "fmt" + "log/slog" + "os" + "path/filepath" + "syscall" + + "github.com/tinkerbell/actions/ubootenv/ubootenv" +) + +const mountAction = "/mountAction" + +func main() { + logger := slog.New(slog.NewTextHandler(os.Stdout, nil)) + logger.Info("UBOOTENV - U-Boot Environment Variable Writer") + + if err := run(logger); err != nil { + logger.Error(err.Error()) + os.Exit(1) + } +} + +func run(logger *slog.Logger) error { + blockDevice := os.Getenv("DEST_DISK") + fsType := os.Getenv("FS_TYPE") + envPath := os.Getenv("ENV_FILE") + envVarsJSON := os.Getenv("ENV_VARS") + + if blockDevice == "" { + return errors.New("DEST_DISK is required") + } + + if fsType == "" { + fsType = "vfat" + } + + if envPath == "" { + envPath = "/boot/uboot.env" + } + + if envVarsJSON == "" { + return errors.New("ENV_VARS is required (JSON object of key/value pairs)") + } + + var newVars map[string]string + if err := json.Unmarshal([]byte(envVarsJSON), &newVars); err != nil { + return fmt.Errorf("failed to parse ENV_VARS as JSON: %w", err) + } + + if len(newVars) == 0 { + logger.Info("No environment variables to set, nothing to do") + return nil + } + + if err := os.MkdirAll(mountAction, 0o755); err != nil { + return fmt.Errorf("error creating mountpoint %s: %w", mountAction, err) + } + + if err := syscall.Mount(blockDevice, mountAction, fsType, 0, ""); err != nil { + return fmt.Errorf("failed to mount block device %s on %s as %s: %w", blockDevice, mountAction, fsType, err) + } + defer func() { + if err := syscall.Unmount(mountAction, 0); err != nil { + logger.Error("Error unmounting device", "source", blockDevice, "destination", mountAction, "error", err) + } else { + logger.Info("Unmounted device", "source", blockDevice, "destination", mountAction) + } + }() + logger.Info("Mounted device", "source", blockDevice, "destination", mountAction) + + fullPath := filepath.Join(mountAction, envPath) + + data, err := os.ReadFile(fullPath) + if err != nil { + return fmt.Errorf("failed to read U-Boot environment file %s: %w", fullPath, err) + } + + env, err := ubootenv.Parse(data) + if err != nil { + return fmt.Errorf("failed to parse U-Boot environment: %w", err) + } + + logger.Info(fmt.Sprintf("Parsed U-Boot environment: %d existing variables", len(env.Vars))) + + for k, v := range newVars { + if v == "" { + logger.Info("Deleting variable", "key", k) + delete(env.Vars, k) + } else { + logger.Info("Setting variable", "key", k, "value", v) + env.Vars[k] = v + } + } + + out, err := env.Marshal() + if err != nil { + return fmt.Errorf("failed to marshal U-Boot environment: %w", err) + } + + if err := os.WriteFile(fullPath, out, 0o644); err != nil { //nolint:gosec // G306: the boot firmware reads this file. + return fmt.Errorf("failed to write U-Boot environment file %s: %w", fullPath, err) + } + + logger.Info("U-Boot environment updated successfully", "path", envPath, "variables_set", len(newVars)) + + return nil +} diff --git a/ubootenv/ubootenv/ubootenv.go b/ubootenv/ubootenv/ubootenv.go new file mode 100644 index 00000000..b9b29ff1 --- /dev/null +++ b/ubootenv/ubootenv/ubootenv.go @@ -0,0 +1,110 @@ +package ubootenv + +import ( + "encoding/binary" + "fmt" + "hash/crc32" + "sort" + "strings" +) + +// DefaultEnvSize is the default total size of a U-Boot environment file (0x4000 = 16384 bytes). +const DefaultEnvSize = 0x4000 + +// crcSize is the size of the CRC32 header in bytes. +const crcSize = 4 + +// Env represents a parsed U-Boot environment. +type Env struct { + Vars map[string]string + Size int +} + +// Parse reads a U-Boot environment from raw bytes. +// The expected format is: +// +// [4 bytes CRC32 little-endian][data...] +// +// Data consists of null-terminated "key=value" strings. +// The end of variables is marked by a double null byte. +func Parse(data []byte) (*Env, error) { + if len(data) < crcSize+1 { + return nil, fmt.Errorf("data too short: %d bytes", len(data)) + } + + storedCRC := binary.LittleEndian.Uint32(data[:crcSize]) + payload := data[crcSize:] + + computedCRC := crc32.ChecksumIEEE(payload) + if storedCRC != computedCRC { + return nil, fmt.Errorf("CRC mismatch: stored=0x%08x computed=0x%08x", storedCRC, computedCRC) + } + + vars := make(map[string]string) + pos := 0 + for pos < len(payload) { + if payload[pos] == 0 { + break // end of environment + } + + // Find the null terminator for this entry. + end := pos + for end < len(payload) && payload[end] != 0 { + end++ + } + + entry := string(payload[pos:end]) + k, v, ok := strings.Cut(entry, "=") + if !ok { + return nil, fmt.Errorf("malformed entry at offset %d: %q", crcSize+pos, entry) + } + vars[k] = v + + pos = end + 1 // skip null terminator + } + + return &Env{ + Vars: vars, + Size: len(data), + }, nil +} + +// Marshal serializes the environment back to the binary format. +// Keys are sorted for deterministic output. +func (e *Env) Marshal() ([]byte, error) { + if e.Size < crcSize+2 { + return nil, fmt.Errorf("env size too small: %d", e.Size) + } + + buf := make([]byte, e.Size) + dataSize := e.Size - crcSize + + // Build the payload: sorted key=value pairs separated by null bytes. + keys := make([]string, 0, len(e.Vars)) + for k := range e.Vars { + keys = append(keys, k) + } + sort.Strings(keys) + + pos := 0 + for _, k := range keys { + entry := k + "=" + e.Vars[k] + needed := len(entry) + 1 // +1 for null terminator + if pos+needed+1 > dataSize { + return nil, fmt.Errorf("environment data exceeds available space (%d bytes)", dataSize) + } + copy(buf[crcSize+pos:], entry) + pos += len(entry) + buf[crcSize+pos] = 0 // null terminator + pos++ + } + + // The double-null terminator is already present since the buffer is zero-initialized. + + // Compute and store CRC32 over the data portion. + payload := buf[crcSize:] + checksum := crc32.ChecksumIEEE(payload) + binary.LittleEndian.PutUint32(buf[:crcSize], checksum) + + return buf, nil +} diff --git a/ubootenv/ubootenv/ubootenv_test.go b/ubootenv/ubootenv/ubootenv_test.go new file mode 100644 index 00000000..7ef00b25 --- /dev/null +++ b/ubootenv/ubootenv/ubootenv_test.go @@ -0,0 +1,144 @@ +package ubootenv + +import ( + "encoding/binary" + "hash/crc32" + "testing" +) + +func makeEnv(vars map[string]string) []byte { + buf := make([]byte, DefaultEnvSize) + pos := crcSize + for k, v := range vars { + entry := k + "=" + v + copy(buf[pos:], entry) + pos += len(entry) + buf[pos] = 0 + pos++ + } + crc := crc32.ChecksumIEEE(buf[crcSize:]) + binary.LittleEndian.PutUint32(buf[:crcSize], crc) + return buf +} + +func TestParseAndMarshalRoundTrip(t *testing.T) { + original := map[string]string{ + "bootcmd": "run distro_bootcmd", + "bootdelay": "3", + "ethaddr": "00:11:22:33:44:55", + } + data := makeEnv(original) + env, err := Parse(data) + if err != nil { + t.Fatalf("Parse() error: %v", err) + } + if len(env.Vars) != len(original) { + t.Fatalf("expected %d vars, got %d", len(original), len(env.Vars)) + } + for k, want := range original { + got, ok := env.Vars[k] + if !ok { + t.Errorf("missing key %q", k) + continue + } + if got != want { + t.Errorf("key %q: got %q, want %q", k, got, want) + } + } + out, err := env.Marshal() + if err != nil { + t.Fatalf("Marshal() error: %v", err) + } + if len(out) != DefaultEnvSize { + t.Fatalf("expected output size %d, got %d", DefaultEnvSize, len(out)) + } + env2, err := Parse(out) + if err != nil { + t.Fatalf("Parse(round-trip) error: %v", err) + } + for k, want := range original { + got := env2.Vars[k] + if got != want { + t.Errorf("round-trip key %q: got %q, want %q", k, got, want) + } + } +} + +func TestParseInvalidCRC(t *testing.T) { + data := makeEnv(map[string]string{"foo": "bar"}) + data[0] ^= 0xFF + _, err := Parse(data) + if err == nil { + t.Fatal("expected CRC mismatch error") + } +} + +func TestParseTooShort(t *testing.T) { + _, err := Parse([]byte{0, 0, 0}) + if err == nil { + t.Fatal("expected error for short data") + } +} + +func TestParseEmptyEnvironment(t *testing.T) { + data := makeEnv(map[string]string{}) + env, err := Parse(data) + if err != nil { + t.Fatalf("Parse() error: %v", err) + } + if len(env.Vars) != 0 { + t.Fatalf("expected 0 vars, got %d", len(env.Vars)) + } +} + +func TestSetAndDeleteVars(t *testing.T) { + original := map[string]string{ + "bootcmd": "bootm", + "bootdelay": "5", + "toremove": "gone", + } + data := makeEnv(original) + env, err := Parse(data) + if err != nil { + t.Fatalf("Parse() error: %v", err) + } + env.Vars["newvar"] = "hello" + env.Vars["bootdelay"] = "1" + delete(env.Vars, "toremove") + out, err := env.Marshal() + if err != nil { + t.Fatalf("Marshal() error: %v", err) + } + env2, err := Parse(out) + if err != nil { + t.Fatalf("Parse(after modification) error: %v", err) + } + expected := map[string]string{ + "bootcmd": "bootm", + "bootdelay": "1", + "newvar": "hello", + } + if len(env2.Vars) != len(expected) { + t.Fatalf("expected %d vars, got %d", len(expected), len(env2.Vars)) + } + for k, want := range expected { + if got := env2.Vars[k]; got != want { + t.Errorf("key %q: got %q, want %q", k, got, want) + } + } + if _, ok := env2.Vars["toremove"]; ok { + t.Error("expected toremove to be deleted") + } +} + +func TestMarshalOverflow(t *testing.T) { + env := &Env{ + Vars: make(map[string]string), + Size: 20, + } + env.Vars["a_very_long_variable_name"] = "a_very_long_value_that_exceeds_capacity" + _, err := env.Marshal() + if err == nil { + t.Fatal("expected overflow error") + } +}