Skip to content

Commit ada7661

Browse files
authored
Merge pull request #1761 from rackerlabs/understackctl-deploy-image-set
feat(understackctl): add deploy image-set subcommand
2 parents 3c7d92b + 4f0708c commit ada7661

4 files changed

Lines changed: 200 additions & 27 deletions

File tree

go/understackctl/cmd/deploy/deploy.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ func NewCmdDeploy() *cobra.Command {
3636
cmd.AddCommand(newCmdDeployRender())
3737
cmd.AddCommand(newCmdDeployEnable())
3838
cmd.AddCommand(newCmdDeployDisable())
39+
cmd.AddCommand(newCmdDeployImageSet())
3940

4041
return cmd
4142
}
Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
package deploy
2+
3+
import (
4+
"fmt"
5+
"os"
6+
"path/filepath"
7+
"regexp"
8+
"strings"
9+
10+
"github.com/charmbracelet/log"
11+
"github.com/google/go-containerregistry/pkg/authn"
12+
"github.com/google/go-containerregistry/pkg/name"
13+
"github.com/google/go-containerregistry/pkg/v1/remote"
14+
"github.com/spf13/cobra"
15+
)
16+
17+
var (
18+
imageTagRe = regexp.MustCompile(`([^\s"']+/understack/[^:]+):v[0-9]+\.[0-9]+\.[0-9]+(?:@sha256:[a-f0-9]+)?`)
19+
refTagRe = regexp.MustCompile(`(\?ref=)v[0-9]+\.[0-9]+\.[0-9]+`)
20+
)
21+
22+
func newCmdDeployImageSet() *cobra.Command {
23+
var noDigest bool
24+
25+
cmd := &cobra.Command{
26+
Use: "image-set <cluster-name> <version>",
27+
Short: "Update UnderStack image tags to a new version",
28+
Long: `Walk all YAML files in the cluster directory and replace UnderStack image
29+
references of the form .*/understack/.*:vX.Y.Z[@sha256:...] with the given
30+
version tag. Also updates ?ref=vX.Y.Z in all kustomization.yaml files.
31+
32+
By default the sha256 digest is resolved from the registry and pinned
33+
alongside the tag. Use --no-digest to write the tag only.`,
34+
Args: cobra.ExactArgs(2),
35+
RunE: func(cmd *cobra.Command, args []string) error {
36+
return runDeployImageSet(args[0], args[1], noDigest)
37+
},
38+
}
39+
40+
cmd.Flags().BoolVar(&noDigest, "no-digest", false, "Write tag only, skip sha256 digest lookup")
41+
42+
return cmd
43+
}
44+
45+
func runDeployImageSet(clusterName, version string, noDigest bool) error {
46+
if !strings.HasPrefix(version, "v") {
47+
version = "v" + version
48+
}
49+
50+
digestCache := map[string]string{}
51+
52+
updated := 0
53+
err := filepath.WalkDir(clusterName, func(path string, d os.DirEntry, err error) error {
54+
if err != nil {
55+
return err
56+
}
57+
if d.IsDir() {
58+
return nil
59+
}
60+
ext := strings.ToLower(filepath.Ext(path))
61+
if ext != ".yaml" && ext != ".yml" {
62+
return nil
63+
}
64+
65+
data, err := os.ReadFile(path)
66+
if err != nil {
67+
return fmt.Errorf("failed to read %s: %w", path, err)
68+
}
69+
70+
original := string(data)
71+
result := replaceImageTags(original, version, noDigest, digestCache)
72+
if filepath.Base(path) == "kustomization.yaml" {
73+
result = refTagRe.ReplaceAllString(result, "${1}"+version)
74+
}
75+
76+
if result == original {
77+
return nil
78+
}
79+
80+
if err := os.WriteFile(path, []byte(result), d.Type().Perm()); err != nil {
81+
return fmt.Errorf("failed to write %s: %w", path, err)
82+
}
83+
84+
log.Infof("Updated %s", path)
85+
updated++
86+
return nil
87+
})
88+
if err != nil {
89+
return err
90+
}
91+
92+
log.Infof("image-set complete: %d file(s) updated to %s", updated, version)
93+
return nil
94+
}
95+
96+
// replaceImageTags rewrites all understack image references in content to the
97+
// given version, optionally pinning the sha256 digest fetched from the registry.
98+
func replaceImageTags(content, version string, noDigest bool, digestCache map[string]string) string {
99+
return imageTagRe.ReplaceAllStringFunc(content, func(match string) string {
100+
groups := imageTagRe.FindStringSubmatch(match)
101+
imageBase := groups[1]
102+
newRef := imageBase + ":" + version
103+
104+
if noDigest {
105+
return newRef
106+
}
107+
108+
if digest, ok := digestCache[newRef]; ok {
109+
if digest == "" {
110+
return newRef
111+
}
112+
return newRef + "@" + digest
113+
}
114+
115+
digest, err := resolveDigest(newRef)
116+
if err != nil {
117+
log.Warnf("Could not resolve digest for %s: %v", newRef, err)
118+
digestCache[newRef] = ""
119+
return newRef
120+
}
121+
122+
log.Debugf("Resolved %s -> %s", newRef, digest)
123+
digestCache[newRef] = digest
124+
return newRef + "@" + digest
125+
})
126+
}
127+
128+
// resolveDigest fetches the manifest digest for an image reference from the
129+
// registry using a lightweight HEAD request (no image data is pulled).
130+
func resolveDigest(imageRef string) (string, error) {
131+
ref, err := name.ParseReference(imageRef)
132+
if err != nil {
133+
return "", fmt.Errorf("invalid image reference %q: %w", imageRef, err)
134+
}
135+
136+
desc, err := remote.Head(ref, remote.WithAuthFromKeychain(authn.DefaultKeychain))
137+
if err != nil {
138+
return "", fmt.Errorf("registry lookup failed: %w", err)
139+
}
140+
141+
return desc.Digest.String(), nil
142+
}

go/understackctl/go.mod

Lines changed: 18 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
module github.com/rackerlabs/understack/go/understackctl
22

3-
go 1.23.6
3+
go 1.24.0
44

55
require (
66
github.com/Masterminds/sprig/v3 v3.3.0
77
github.com/charmbracelet/log v0.4.2
8+
github.com/google/go-containerregistry v0.19.2
89
github.com/gookit/goutil v0.7.3
910
github.com/santhosh-tekuri/jsonschema/v5 v5.3.1
1011
github.com/spf13/cobra v1.10.2
@@ -26,46 +27,56 @@ require (
2627
github.com/charmbracelet/x/ansi v0.8.0 // indirect
2728
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect
2829
github.com/charmbracelet/x/term v0.2.1 // indirect
30+
github.com/containerd/stargz-snapshotter/estargz v0.18.2 // indirect
2931
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
32+
github.com/docker/cli v29.2.1+incompatible // indirect
33+
github.com/docker/distribution v2.8.3+incompatible // indirect
34+
github.com/docker/docker-credential-helpers v0.9.3 // indirect
3035
github.com/emicklei/go-restful/v3 v3.11.0 // indirect
3136
github.com/fsnotify/fsnotify v1.8.0 // indirect
3237
github.com/fxamacker/cbor/v2 v2.7.0 // indirect
3338
github.com/go-logfmt/logfmt v0.6.0 // indirect
34-
github.com/go-logr/logr v1.4.2 // indirect
39+
github.com/go-logr/logr v1.4.3 // indirect
3540
github.com/go-openapi/jsonpointer v0.21.0 // indirect
3641
github.com/go-openapi/jsonreference v0.20.2 // indirect
3742
github.com/go-openapi/swag v0.23.0 // indirect
3843
github.com/go-viper/mapstructure/v2 v2.2.1 // indirect
3944
github.com/gogo/protobuf v1.3.2 // indirect
4045
github.com/golang/protobuf v1.5.4 // indirect
4146
github.com/google/gnostic-models v0.6.8 // indirect
42-
github.com/google/go-cmp v0.6.0 // indirect
47+
github.com/google/go-cmp v0.7.0 // indirect
4348
github.com/google/gofuzz v1.2.0 // indirect
4449
github.com/google/uuid v1.6.0 // indirect
4550
github.com/huandu/xstrings v1.5.0 // indirect
4651
github.com/inconshreveable/mousetrap v1.1.0 // indirect
4752
github.com/josharian/intern v1.0.0 // indirect
4853
github.com/json-iterator/go v1.1.12 // indirect
54+
github.com/klauspost/compress v1.18.4 // indirect
4955
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
5056
github.com/mailru/easyjson v0.7.7 // indirect
5157
github.com/mattn/go-isatty v0.0.20 // indirect
5258
github.com/mattn/go-runewidth v0.0.16 // indirect
5359
github.com/mitchellh/copystructure v1.2.0 // indirect
60+
github.com/mitchellh/go-homedir v1.1.0 // indirect
5461
github.com/mitchellh/reflectwalk v1.0.2 // indirect
5562
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
5663
github.com/modern-go/reflect2 v1.0.2 // indirect
5764
github.com/muesli/termenv v0.16.0 // indirect
5865
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
66+
github.com/opencontainers/go-digest v1.0.0 // indirect
67+
github.com/opencontainers/image-spec v1.1.1 // indirect
5968
github.com/pelletier/go-toml/v2 v2.2.3 // indirect
6069
github.com/pkg/errors v0.9.1 // indirect
6170
github.com/rivo/uniseg v0.4.7 // indirect
6271
github.com/sagikazarmark/locafero v0.7.0 // indirect
6372
github.com/shopspring/decimal v1.4.0 // indirect
73+
github.com/sirupsen/logrus v1.9.3 // indirect
6474
github.com/sourcegraph/conc v0.3.0 // indirect
6575
github.com/spf13/afero v1.12.0 // indirect
6676
github.com/spf13/cast v1.7.1 // indirect
6777
github.com/spf13/pflag v1.0.9 // indirect
6878
github.com/subosito/gotenv v1.6.0 // indirect
79+
github.com/vbatts/tar-split v0.12.2 // indirect
6980
github.com/x448/float16 v0.8.4 // indirect
7081
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
7182
go.uber.org/atomic v1.9.0 // indirect
@@ -74,13 +85,13 @@ require (
7485
golang.org/x/crypto v0.32.0 // indirect
7586
golang.org/x/exp v0.0.0-20231006140011-7918f672742d // indirect
7687
golang.org/x/net v0.33.0 // indirect
77-
golang.org/x/oauth2 v0.25.0 // indirect
78-
golang.org/x/sync v0.11.0 // indirect
79-
golang.org/x/sys v0.30.0 // indirect
88+
golang.org/x/oauth2 v0.35.0 // indirect
89+
golang.org/x/sync v0.19.0 // indirect
90+
golang.org/x/sys v0.41.0 // indirect
8091
golang.org/x/term v0.29.0 // indirect
8192
golang.org/x/text v0.22.0 // indirect
8293
golang.org/x/time v0.8.0 // indirect
83-
google.golang.org/protobuf v1.36.1 // indirect
94+
google.golang.org/protobuf v1.36.3 // indirect
8495
gopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect
8596
gopkg.in/inf.v0 v0.9.1 // indirect
8697
k8s.io/klog/v2 v2.130.1 // indirect

0 commit comments

Comments
 (0)