diff --git a/README.md b/README.md index 6a56bb7..525e6db 100644 --- a/README.md +++ b/README.md @@ -11,8 +11,15 @@ 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. @@ -20,3 +27,16 @@ The `-device` flag accepts the full disk (e.g. `/dev/sda`); the META partition i 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 +``` diff --git a/main.go b/main.go index 6c818f1..c26731d 100644 --- a/main.go +++ b/main.go @@ -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" @@ -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 -config ") - return + if *devicePath == "" { + fmt.Fprintln(os.Stderr, "Usage: talos-meta-tool -device (-config | -config-env [-config-env-base64])") + os.Exit(1) } - - 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 (-config | -config-env [-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) @@ -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) } diff --git a/main_test.go b/main_test.go index 234f5b4..de05676 100644 --- a/main_test.go +++ b/main_test.go @@ -4,6 +4,7 @@ package main import ( "bytes" + "encoding/base64" "errors" "io" "os" @@ -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)