Skip to content

Commit e3fd19a

Browse files
mcanevetclaude
andcommitted
feat: add -config-env and -config-env-base64 flags
Allow the configuration to be read from an environment variable with -config-env, removing the need for a config file on disk. Add -config-env-base64 to decode the variable's value as base64 before parsing, which avoids YAML line-folding issues when the config is injected into a double-quoted scalar by a template engine (e.g. Tinkerbell). -config and -config-env are mutually exclusive; -config-env-base64 requires -config-env. Config loading is implemented in a loadConfig helper for testability; all whitespace is stripped before base64 decode to handle multi-line output from base64 command variants. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Signed-off-by: Mickaël Canévet <mickael.canevet@proton.ch>
1 parent 615c6eb commit e3fd19a

3 files changed

Lines changed: 159 additions & 13 deletions

File tree

README.md

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,12 +11,32 @@ GOOS=linux GOARCH=amd64 go build -o talos-meta-tool .
1111
```
1212

1313
Usage:
14-
```
14+
```bash
15+
# write config from file
1516
talos-meta-tool -device /dev/sda -config config.yaml
17+
18+
# config from environment variable (plain text)
19+
talos-meta-tool -device /dev/sda -config-env MY_CONFIG
20+
21+
# config from environment variable (base64-encoded, safe for multi-line YAML)
22+
talos-meta-tool -device /dev/sda -config-env MY_CONFIG -config-env-base64
1623
```
1724

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

2027
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.
2128

2229
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`.
30+
31+
Docker image:
32+
```bash
33+
# Linux (GNU base64)
34+
docker run --rm --privileged -e MY_CONFIG="$(base64 -w0 config.yaml)" \
35+
ghcr.io/cozystack/talos-meta-tool:latest \
36+
-device /dev/sda -config-env MY_CONFIG -config-env-base64
37+
38+
# macOS/BSD
39+
docker run --rm --privileged -e MY_CONFIG="$(base64 config.yaml | tr -d '\n')" \
40+
ghcr.io/cozystack/talos-meta-tool:latest \
41+
-device /dev/sda -config-env MY_CONFIG -config-env-base64
42+
```

main.go

Lines changed: 56 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,13 @@
33
package main
44

55
import (
6+
"encoding/base64"
67
"flag"
78
"fmt"
89
"io"
910
"log"
1011
"os"
12+
"strings"
1113

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

92+
func loadConfig(path, envVar string, b64 bool) ([]byte, error) {
93+
if envVar != "" {
94+
val, ok := os.LookupEnv(envVar)
95+
if !ok {
96+
return nil, fmt.Errorf("environment variable %q is not set", envVar)
97+
}
98+
if b64 {
99+
stripped := strings.Map(func(r rune) rune {
100+
if r == ' ' || r == '\t' || r == '\n' || r == '\r' {
101+
return -1
102+
}
103+
return r
104+
}, val)
105+
data, err := base64.StdEncoding.DecodeString(stripped)
106+
if err != nil {
107+
return nil, fmt.Errorf("base64 decoding %q: %w", envVar, err)
108+
}
109+
return data, nil
110+
}
111+
return []byte(val), nil
112+
}
113+
data, err := os.ReadFile(path)
114+
if err != nil {
115+
return nil, fmt.Errorf("reading configuration file: %w", err)
116+
}
117+
return data, nil
118+
}
119+
90120
func main() {
91121
devicePath := flag.String("device", "", "Path to the disk device (e.g., /dev/sda)")
92122
configPath := flag.String("config", "", "Path to the configuration file (e.g., config.yaml)")
123+
configEnv := flag.String("config-env", "", "Name of the environment variable containing the configuration")
124+
configEnvBase64 := flag.Bool("config-env-base64", false, "Decode the -config-env value as base64 before use")
93125
skipValidation := flag.Bool("skip-validation", false, "Skip schema validation of the configuration file")
94126
flag.Parse()
95127

96-
if *devicePath == "" || *configPath == "" {
97-
fmt.Println("Usage: talos-meta-tool -device <disk-device> -config <file>")
98-
return
128+
if *devicePath == "" {
129+
fmt.Fprintln(os.Stderr, "Usage: talos-meta-tool -device <disk-device> (-config <file> | -config-env <VAR> [-config-env-base64])")
130+
os.Exit(1)
99131
}
100-
101-
configData, err := os.ReadFile(*configPath)
102-
if err != nil {
103-
log.Fatalf("Error reading configuration file: %v", err)
132+
if *configPath == "" && *configEnv == "" {
133+
fmt.Fprintln(os.Stderr, "Usage: talos-meta-tool -device <disk-device> (-config <file> | -config-env <VAR> [-config-env-base64])")
134+
os.Exit(1)
104135
}
105-
106-
if !*skipValidation {
107-
if err := validateConfig(configData); err != nil {
108-
log.Fatalf("Invalid network configuration: %v", err)
109-
}
136+
if *configPath != "" && *configEnv != "" {
137+
fmt.Fprintln(os.Stderr, "Error: -config and -config-env are mutually exclusive")
138+
os.Exit(1)
139+
}
140+
if *configEnvBase64 && *configEnv == "" {
141+
fmt.Fprintln(os.Stderr, "Error: -config-env-base64 requires -config-env")
142+
os.Exit(1)
110143
}
111144

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

156+
configData, err := loadConfig(*configPath, *configEnv, *configEnvBase64)
157+
if err != nil {
158+
log.Fatalf("loading configuration: %v", err)
159+
}
160+
161+
if !*skipValidation {
162+
if err := validateConfig(configData); err != nil {
163+
log.Fatalf("Invalid network configuration: %v", err)
164+
}
165+
}
166+
123167
if err := writeConfig(meta, configData); err != nil {
124168
log.Fatalf("Error: %v", err)
125169
}

main_test.go

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ package main
44

55
import (
66
"bytes"
7+
"encoding/base64"
78
"errors"
89
"io"
910
"os"
@@ -272,6 +273,87 @@ func TestFindMetaPartitionMissing(t *testing.T) {
272273
}
273274
}
274275

276+
func TestLoadConfigFile(t *testing.T) {
277+
f, err := os.CreateTemp("", "config-*.yaml")
278+
if err != nil {
279+
t.Fatal(err)
280+
}
281+
t.Cleanup(func() { os.Remove(f.Name()) }) //nolint:errcheck
282+
283+
content := []byte("key: value\n")
284+
if _, err := f.Write(content); err != nil {
285+
t.Fatal(err)
286+
}
287+
f.Close()
288+
289+
got, err := loadConfig(f.Name(), "", false)
290+
if err != nil {
291+
t.Fatalf("loadConfig: %v", err)
292+
}
293+
if !bytes.Equal(got, content) {
294+
t.Fatalf("got %q, want %q", got, content)
295+
}
296+
}
297+
298+
func TestLoadConfigFileMissing(t *testing.T) {
299+
if _, err := loadConfig("/nonexistent/config.yaml", "", false); err == nil {
300+
t.Fatal("expected error for missing file, got nil")
301+
}
302+
}
303+
304+
func TestLoadConfigEnv(t *testing.T) {
305+
content := "key: value\n"
306+
t.Setenv("TEST_META_CONFIG", content)
307+
308+
got, err := loadConfig("", "TEST_META_CONFIG", false)
309+
if err != nil {
310+
t.Fatalf("loadConfig: %v", err)
311+
}
312+
if string(got) != content {
313+
t.Fatalf("got %q, want %q", got, content)
314+
}
315+
}
316+
317+
func TestLoadConfigEnvNotSet(t *testing.T) {
318+
if _, err := loadConfig("", "TEST_META_CONFIG_UNSET_XYZ", false); err == nil {
319+
t.Fatal("expected error for unset env var, got nil")
320+
}
321+
}
322+
323+
func TestLoadConfigEnvBase64(t *testing.T) {
324+
content := []byte("key: value\n")
325+
encoded := base64.StdEncoding.EncodeToString(content)
326+
327+
tests := []struct {
328+
name string
329+
input string
330+
}{
331+
{"no whitespace", encoded},
332+
{"trailing newline", encoded + "\n"},
333+
{"embedded newlines", encoded[:len(encoded)/2] + "\n" + encoded[len(encoded)/2:]},
334+
}
335+
336+
for _, tc := range tests {
337+
t.Run(tc.name, func(t *testing.T) {
338+
t.Setenv("TEST_META_CONFIG_B64", tc.input)
339+
got, err := loadConfig("", "TEST_META_CONFIG_B64", true)
340+
if err != nil {
341+
t.Fatalf("loadConfig: %v", err)
342+
}
343+
if !bytes.Equal(got, content) {
344+
t.Fatalf("got %q, want %q", got, content)
345+
}
346+
})
347+
}
348+
}
349+
350+
func TestLoadConfigEnvBase64Invalid(t *testing.T) {
351+
t.Setenv("TEST_META_CONFIG_B64", "not-valid-base64!!!")
352+
if _, err := loadConfig("", "TEST_META_CONFIG_B64", true); err == nil {
353+
t.Fatal("expected error for invalid base64, got nil")
354+
}
355+
}
356+
275357
func TestWriteConfigFullDisk(t *testing.T) {
276358
diskFile := newTestDisk(t)
277359

0 commit comments

Comments
 (0)