Skip to content
Merged
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
22 changes: 21 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,32 @@ GOOS=linux GOARCH=amd64 go build -o talos-meta-tool .
```

Usage:
```
```bash
# write config from file
talos-meta-tool -device /dev/sda -config config.yaml

# config from environment variable (plain text)
talos-meta-tool -device /dev/sda -config-env MY_CONFIG

# config from environment variable (base64-encoded, safe for multi-line YAML)
talos-meta-tool -device /dev/sda -config-env MY_CONFIG -config-env-base64
```

The `-device` flag accepts the full disk (e.g. `/dev/sda`); the META partition is discovered automatically via GPT.

The `-config` file is validated against the Talos v1.13 metal network configuration schema (`network.PlatformConfigSpec`) before writing: unknown fields, malformed addresses and invalid enum values are rejected.

To bypass validation — e.g. when the config uses fields that a newer Talos version has added but this tool's schema does not yet know about — pass `-skip-validation`.

Docker image:
```bash
# Linux (GNU base64)
docker run --rm --privileged -e MY_CONFIG="$(base64 -w0 config.yaml)" \
ghcr.io/cozystack/talos-meta-tool:latest \
-device /dev/sda -config-env MY_CONFIG -config-env-base64

# macOS/BSD
docker run --rm --privileged -e MY_CONFIG="$(base64 config.yaml | tr -d '\n')" \
ghcr.io/cozystack/talos-meta-tool:latest \
-device /dev/sda -config-env MY_CONFIG -config-env-base64
```
68 changes: 56 additions & 12 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,13 @@
package main

import (
"encoding/base64"
"flag"
"fmt"
"io"
"log"
"os"
"strings"

"github.com/siderolabs/go-adv/adv/talos"
"github.com/siderolabs/go-blockdevice/v2/block"
Expand Down Expand Up @@ -87,26 +89,57 @@ func writeConfig(dev interface{ io.ReaderAt; io.WriterAt }, configData []byte) e
return nil
}

func loadConfig(path, envVar string, b64 bool) ([]byte, error) {
if envVar != "" {
val, ok := os.LookupEnv(envVar)
if !ok {
return nil, fmt.Errorf("environment variable %q is not set", envVar)
}
if b64 {
stripped := strings.Map(func(r rune) rune {
if r == ' ' || r == '\t' || r == '\n' || r == '\r' {
return -1
}
return r
}, val)
data, err := base64.StdEncoding.DecodeString(stripped)
if err != nil {
return nil, fmt.Errorf("base64 decoding %q: %w", envVar, err)
}
return data, nil
}
return []byte(val), nil
}
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("reading configuration file: %w", err)
}
return data, nil
}

func main() {
devicePath := flag.String("device", "", "Path to the disk device (e.g., /dev/sda)")
configPath := flag.String("config", "", "Path to the configuration file (e.g., config.yaml)")
configEnv := flag.String("config-env", "", "Name of the environment variable containing the configuration")
configEnvBase64 := flag.Bool("config-env-base64", false, "Decode the -config-env value as base64 before use")
skipValidation := flag.Bool("skip-validation", false, "Skip schema validation of the configuration file")
flag.Parse()

if *devicePath == "" || *configPath == "" {
fmt.Println("Usage: talos-meta-tool -device <disk-device> -config <file>")
return
if *devicePath == "" {
fmt.Fprintln(os.Stderr, "Usage: talos-meta-tool -device <disk-device> (-config <file> | -config-env <VAR> [-config-env-base64])")
os.Exit(1)
}
Comment thread
mcanevet marked this conversation as resolved.

configData, err := os.ReadFile(*configPath)
if err != nil {
log.Fatalf("Error reading configuration file: %v", err)
if *configPath == "" && *configEnv == "" {
fmt.Fprintln(os.Stderr, "Usage: talos-meta-tool -device <disk-device> (-config <file> | -config-env <VAR> [-config-env-base64])")
os.Exit(1)
}

if !*skipValidation {
if err := validateConfig(configData); err != nil {
log.Fatalf("Invalid network configuration: %v", err)
}
if *configPath != "" && *configEnv != "" {
fmt.Fprintln(os.Stderr, "Error: -config and -config-env are mutually exclusive")
os.Exit(1)
}
if *configEnvBase64 && *configEnv == "" {
fmt.Fprintln(os.Stderr, "Error: -config-env-base64 requires -config-env")
os.Exit(1)
}

device, err := os.OpenFile(*devicePath, os.O_RDWR, 0)
Expand All @@ -120,6 +153,17 @@ func main() {
log.Fatalf("Error: %v", err)
}

configData, err := loadConfig(*configPath, *configEnv, *configEnvBase64)
if err != nil {
log.Fatalf("loading configuration: %v", err)
}

if !*skipValidation {
if err := validateConfig(configData); err != nil {
log.Fatalf("Invalid network configuration: %v", err)
}
}

if err := writeConfig(meta, configData); err != nil {
log.Fatalf("Error: %v", err)
}
Expand Down
84 changes: 84 additions & 0 deletions main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ package main

import (
"bytes"
"encoding/base64"
"errors"
"io"
"os"
Expand Down Expand Up @@ -272,6 +273,89 @@ func TestFindMetaPartitionMissing(t *testing.T) {
}
}

func TestLoadConfigFile(t *testing.T) {
f, err := os.CreateTemp("", "config-*.yaml")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { os.Remove(f.Name()) }) //nolint:errcheck

content := []byte("key: value\n")
if _, err := f.Write(content); err != nil {
t.Fatal(err)
}
if err := f.Close(); err != nil {
t.Fatal(err)
}

got, err := loadConfig(f.Name(), "", false)
if err != nil {
t.Fatalf("loadConfig: %v", err)
}
if !bytes.Equal(got, content) {
t.Fatalf("got %q, want %q", got, content)
}
}

func TestLoadConfigFileMissing(t *testing.T) {
if _, err := loadConfig("/nonexistent/config.yaml", "", false); err == nil {
t.Fatal("expected error for missing file, got nil")
}
}

func TestLoadConfigEnv(t *testing.T) {
content := "key: value\n"
t.Setenv("TEST_META_CONFIG", content)

got, err := loadConfig("", "TEST_META_CONFIG", false)
if err != nil {
t.Fatalf("loadConfig: %v", err)
}
if string(got) != content {
t.Fatalf("got %q, want %q", got, content)
}
}

func TestLoadConfigEnvNotSet(t *testing.T) {
if _, err := loadConfig("", "TEST_META_CONFIG_UNSET_XYZ", false); err == nil {
t.Fatal("expected error for unset env var, got nil")
}
}

func TestLoadConfigEnvBase64(t *testing.T) {
content := []byte("key: value\n")
encoded := base64.StdEncoding.EncodeToString(content)

tests := []struct {
name string
input string
}{
{"no whitespace", encoded},
{"trailing newline", encoded + "\n"},
{"embedded newlines", encoded[:len(encoded)/2] + "\n" + encoded[len(encoded)/2:]},
}

for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Setenv("TEST_META_CONFIG_B64", tc.input)
got, err := loadConfig("", "TEST_META_CONFIG_B64", true)
if err != nil {
t.Fatalf("loadConfig: %v", err)
}
if !bytes.Equal(got, content) {
t.Fatalf("got %q, want %q", got, content)
}
})
}
}

func TestLoadConfigEnvBase64Invalid(t *testing.T) {
t.Setenv("TEST_META_CONFIG_B64", "not-valid-base64!!!")
if _, err := loadConfig("", "TEST_META_CONFIG_B64", true); err == nil {
t.Fatal("expected error for invalid base64, got nil")
}
}

func TestWriteConfigFullDisk(t *testing.T) {
diskFile := newTestDisk(t)

Expand Down
Loading