Skip to content

Commit 7a21e6a

Browse files
authored
feat(init): add --image flag to override the preset values.yaml image (#150)
* feat(init): add --image flag to override the preset values.yaml image talm init writes the preset chart unchanged, including a hard-coded installer image (e.g. ghcr.io/cozystack/cozystack/talos:v1.12.6 in cozystack). Operators using a custom or factory-built Talos image have to edit values.yaml after every fresh init. The new --image flag makes the override declarative at init time: talm init --preset cozystack --name cluster --image \ factory.talos.dev/installer/<sha>:<version> Implementation is a minimal regex substitution on the preset values content before write. The helper applyImageOverride is line-anchored, returns the input unchanged for an empty override OR for a values file that does not declare image (so a preset without the field is not silently fabricated), and %q-quotes the override value so a reference with characters YAML would otherwise re-interpret stays parsed as a string. Tests in pkg/commands/init_test.go cover the four contract corners: empty override, present-image substitution with surrounding content preserved, missing-image short-circuit, and shell-meta safe quoting. Assisted-By: Claude <noreply@anthropic.com> Signed-off-by: Aleksei Sviridkin <f@lex.la> * docs(init): explain why applyImageOverride keeps a redundant match check Address review feedback from gemini-code-assist on pkg/commands/init.go:548: the inner imageLineRe.Match is redundant under the talm init flow because validateImageOverride runs first against the same bytes from presetFiles. Document that the guard is intentional defense in depth so the helper stays safe for direct callers (unit tests, future code paths that might skip the validator) instead of dropping it and creating a hidden coupling between the validator and the helper. Assisted-By: Claude <noreply@anthropic.com> Signed-off-by: Aleksei Sviridkin <f@lex.la> * refactor(init): use MatchString in validateImageOverride Address review feedback from gemini-code-assist on pkg/commands/init.go:574: the input is already a string, so call imageLineRe.MatchString(content) directly instead of forcing a []byte conversion. Tiny allocation saving and the idiomatic Go form when the regex input arrives as a string. Assisted-By: Claude <noreply@anthropic.com> Signed-off-by: Aleksei Sviridkin <f@lex.la> --------- Signed-off-by: Aleksei Sviridkin <f@lex.la>
1 parent a2abbf9 commit 7a21e6a

3 files changed

Lines changed: 366 additions & 2 deletions

File tree

README.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,14 @@ cd newcluster
5151
talm init -p cozystack -N myawesomecluster
5252
```
5353

54+
To pin a specific Talos installer image at init time (e.g. a [Talos Factory](https://factory.talos.dev/) image with extensions), pass `--image`:
55+
56+
```bash
57+
talm init -p cozystack -N myawesomecluster --image factory.talos.dev/installer/<sha256>:<version>
58+
```
59+
60+
`--image` rewrites the top-level `image:` field in the preset's `values.yaml` before write. The flag is honored on initial `init` only — for an existing project, edit `values.yaml` directly. The `cozystack` preset declares `image:`; the `generic` preset does not, so `--image --preset generic` is rejected up front.
61+
5462
Edit `values.yaml` to set your cluster's control-plane endpoint. This is the URL every node's kubelet and kube-proxy will dial. The chart leaves it empty on purpose so a missed override fails loudly instead of silently embedding a placeholder. For cozystack VIP setups set `endpoint` and `floatingIP` together (same IP, single shared VIP); for single-node clusters use that node's routable IP and leave `floatingIP` blank; for multi-node with an external load balancer use the LB URL and leave `floatingIP` blank. When the VIP must sit on a link that does not yet exist on the live system at first apply (typically a VLAN sub-interface), set `vipLink` to that link name — the chart pins `Layer2VIPConfig.link` to it instead of the default-gateway link that discovery would otherwise pick, and emits the document even on a totally fresh node where no default-gateway link has been discovered yet. The chart does not auto-emit a `LinkConfig` or `VLANConfig` for the override link; the operator is responsible for ensuring the link comes up, typically by adding a `LinkConfig` or `VLANConfig` for that link to the per-node body overlay alongside `vipLink`. Subnet-selector fields (`kubelet.validSubnets`, `etcd.advertisedSubnets`) are derived automatically from the node's default-gateway-bearing link, so no override is needed unless you have a multi-homed node that requires a specific subnet pinned.
5563

5664
Boot Talos Linux node, let's say it has address `192.0.2.4`. Then:

pkg/commands/init.go

Lines changed: 107 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import (
2020
"io"
2121
"os"
2222
"path/filepath"
23+
"regexp"
2324
"slices"
2425
"strings"
2526
"time"
@@ -41,6 +42,7 @@ var initCmdFlags struct {
4142
preset string
4243
name string
4344
talosVersion string
45+
image string
4446
update bool
4547
encrypt bool
4648
decrypt bool
@@ -57,6 +59,14 @@ var initCmd = &cobra.Command{
5759
initCmdFlags.talosVersion = Config.TemplateOptions.TalosVersion
5860
}
5961

62+
// --image rewrites the preset's values.yaml at write time, so it
63+
// only makes sense on initial init. Combining it with --encrypt /
64+
// --decrypt / --update would let the flag silently disappear —
65+
// surface the mismatch up front instead.
66+
if initCmdFlags.image != "" && (initCmdFlags.encrypt || initCmdFlags.decrypt || initCmdFlags.update) {
67+
return fmt.Errorf("--image is honored on initial init only; not valid with --encrypt, --decrypt, or --update")
68+
}
69+
6070
// For -e, -d, and -u flags, always check that we're in a project root
6171
if initCmdFlags.encrypt || initCmdFlags.decrypt || initCmdFlags.update {
6272
// Verify that Config.RootDir is actually a project root
@@ -410,15 +420,29 @@ var initCmd = &cobra.Command{
410420
return fmt.Errorf("failed to get preset files: %w", err)
411421
}
412422

423+
// Validate --image up-front so a flag/preset mismatch fails
424+
// the command before any file is written.
425+
if err := validateImageOverride(presetFiles, initCmdFlags.preset, initCmdFlags.image); err != nil {
426+
return err
427+
}
428+
413429
for path, content := range presetFiles {
414430
parts := strings.SplitN(path, "/", 2)
415431
chartName := parts[0]
416432
// Write preset files
417433
if chartName == initCmdFlags.preset {
418434
file := filepath.Join(Config.RootDir, filepath.Join(parts[1:]...))
419-
if parts[len(parts)-1] == "Chart.yaml" {
435+
switch parts[len(parts)-1] {
436+
case "Chart.yaml":
420437
err = writeToDestination(fmt.Appendf(nil, content, clusterName, Config.InitOptions.Version), file, 0o644)
421-
} else {
438+
case "values.yaml":
439+
var rendered []byte
440+
rendered, err = applyImageOverride([]byte(content), initCmdFlags.image)
441+
if err != nil {
442+
return err
443+
}
444+
err = writeToDestination(rendered, file, 0o644)
445+
default:
422446
err = writeToDestination([]byte(content), file, 0o644)
423447
}
424448
if err != nil {
@@ -489,6 +513,78 @@ func readChartYamlPreset() (string, error) {
489513
return "", fmt.Errorf("preset not found in Chart.yaml dependencies")
490514
}
491515

516+
// imageLineRe matches the top-level `image:` line in a preset
517+
// values.yaml regardless of YAML serialization style — double-quoted,
518+
// single-quoted, unquoted, with or without a trailing comment.
519+
// Line-anchored (?m)^image:…$ so a nested key, indented entry, or
520+
// commented `# image:` line is never substituted.
521+
var imageLineRe = regexp.MustCompile(`(?m)^image:.*$`)
522+
523+
// applyImageOverride returns values with the top-level `image:` line
524+
// replaced so it points at override. An empty override returns values
525+
// unchanged. When override is non-empty but values has no top-level
526+
// `image:` line, the helper returns an error rather than silently
527+
// dropping the user's flag — a preset that does not declare an image
528+
// field cannot be customized through this path, and the caller must
529+
// surface that to the user before any file is written.
530+
//
531+
// The override is %q-quoted, which Go-escapes special characters and
532+
// emits a double-quoted string. The substitution goes through
533+
// ReplaceAllFunc rather than ReplaceAll because the latter expands
534+
// `$0` / `$1` / `$name` / `${name}` sequences in the replacement —
535+
// an image reference like `foo/$tenant/bar` would otherwise be
536+
// rewritten to a different image, silently. ReplaceAllFunc returns
537+
// the byte slice verbatim with no $-expansion.
538+
//
539+
// The regex matches every top-level `image:` line via ReplaceAllFunc,
540+
// so a preset that ever declares two top-level image fields would have
541+
// both rewritten to the same value. Today only `cozystack` has one
542+
// occurrence; a future preset that breaks this assumption surfaces
543+
// here as a behaviour change worth catching at review.
544+
func applyImageOverride(values []byte, override string) ([]byte, error) {
545+
if override == "" {
546+
return values, nil
547+
}
548+
// In the talm init flow this guard is redundant: RunE invokes
549+
// validateImageOverride against the same byte content from
550+
// presetFiles before this loop runs, so a missing image: field
551+
// fails the command up front. The check is kept as defense in
552+
// depth for direct callers (unit tests, future code paths that
553+
// might skip the validator) so the helper is safe in isolation.
554+
if !imageLineRe.Match(values) {
555+
return nil, fmt.Errorf("--image was set but the preset values.yaml does not declare a top-level image: field; remove --image, choose a different preset, or add the image field manually")
556+
}
557+
replacement := fmt.Appendf(nil, "image: %q", override)
558+
return imageLineRe.ReplaceAllFunc(values, func([]byte) []byte {
559+
return replacement
560+
}), nil
561+
}
562+
563+
// validateImageOverride scans presetFiles for the chosen preset's
564+
// values.yaml and confirms a top-level image line is present when
565+
// the user passed --image. The check runs before any file is written
566+
// so a flag-vs-preset mismatch fails the command up front instead of
567+
// leaving a half-initialized project on disk.
568+
func validateImageOverride(presetFiles map[string]string, presetName, override string) error {
569+
if override == "" {
570+
return nil
571+
}
572+
for path, content := range presetFiles {
573+
parts := strings.SplitN(path, "/", 2)
574+
if len(parts) != 2 || parts[0] != presetName {
575+
continue
576+
}
577+
if parts[1] != "values.yaml" {
578+
continue
579+
}
580+
if !imageLineRe.MatchString(content) {
581+
return fmt.Errorf("--image was set but preset %q does not declare a top-level image: field in values.yaml; remove --image or choose a preset that exposes it (e.g. cozystack)", presetName)
582+
}
583+
return nil
584+
}
585+
return fmt.Errorf("--image was set but preset %q has no values.yaml in the embedded chart files", presetName)
586+
}
587+
492588
// askUserOverwrite asks user if they want to overwrite a file
493589
func askUserOverwrite(filePath string) (bool, error) {
494590
// Show relative path from project root
@@ -586,6 +682,14 @@ func updateFileWithConfirmation(filePath string, newContent []byte, permissions
586682
}
587683

588684
func updateTalmLibraryChart() error {
685+
// --image is only honored on initial init (it customizes the
686+
// preset's values.yaml at write time). Refusing it on --update
687+
// surfaces the no-op trap explicitly instead of letting the
688+
// user's flag silently disappear.
689+
if initCmdFlags.image != "" {
690+
return fmt.Errorf("--image is honored on initial init only; for an existing project, edit the image field in values.yaml directly")
691+
}
692+
589693
// Determine preset: use -p flag if provided, otherwise try to read from Chart.yaml
590694
var presetName string
591695

@@ -680,6 +784,7 @@ func init() {
680784
initCmd.Flags().StringVar(&initCmdFlags.talosVersion, "talos-version", "", "the desired Talos version to generate config for (backwards compatibility, e.g. v0.8)")
681785
initCmd.Flags().StringVarP(&initCmdFlags.preset, "preset", "p", "", "preset for file generation (not required with --encrypt, --decrypt, or --update)")
682786
initCmd.Flags().StringVarP(&initCmdFlags.name, "name", "N", "", "cluster name (not required with --encrypt, --decrypt, or --update)")
787+
initCmd.Flags().StringVar(&initCmdFlags.image, "image", "", "override the Talos installer image written to the preset's values.yaml (e.g. factory.talos.dev/installer/<sha256>:<version>)")
683788
initCmd.Flags().BoolVar(&initCmdFlags.force, "force", false, "will overwrite existing files")
684789
initCmd.Flags().BoolVarP(&initCmdFlags.update, "update", "u", false, "update Talm library chart")
685790
// Override persistent -e flag for init command to use for encrypt

0 commit comments

Comments
 (0)