Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .claude/settings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"includeCoAuthoredBy": false
}
5 changes: 5 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -54,6 +56,9 @@ jobs:
- rootio
- slurp
- syslinux
- taloscmdline
- talosmeta
- ubootenv
- writefile
steps:
- uses: actions/checkout@v7
Expand Down
33 changes: 26 additions & 7 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 }}
7 changes: 5 additions & 2 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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.
Expand Down
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand All @@ -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
Expand Down
13 changes: 13 additions & 0 deletions cidataio/Dockerfile
Original file line number Diff line number Diff line change
@@ -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"]
47 changes: 47 additions & 0 deletions cidataio/README.md
Original file line number Diff line number Diff line change
@@ -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
```
128 changes: 128 additions & 0 deletions cidataio/main.go
Original file line number Diff line number Diff line change
@@ -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.")
}
Loading