Skip to content

Commit 88fd6cb

Browse files
authored
Adds container image listing for releases (#561)
1 parent 2da9e23 commit 88fd6cb

File tree

9 files changed

+544
-19
lines changed

9 files changed

+544
-19
lines changed

cli/cmd/release_image_ls.go

Lines changed: 211 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,211 @@
1+
package cmd
2+
3+
import (
4+
"errors"
5+
"fmt"
6+
"strings"
7+
8+
"github.com/replicatedhq/replicated/cli/print"
9+
"github.com/replicatedhq/replicated/pkg/types"
10+
"github.com/spf13/cobra"
11+
)
12+
13+
func (r *runners) InitReleaseImageLS(parent *cobra.Command) {
14+
imageCmd := &cobra.Command{
15+
Use: "image",
16+
Short: "Manage release images",
17+
Long: "Manage release images",
18+
}
19+
20+
lsCmd := &cobra.Command{
21+
Use: "ls --channel CHANNEL_NAME_OR_ID [--version SEMVER] [--keep-proxy]",
22+
Short: "List images in a channel's current or specified release",
23+
Long: "List all container images in the current release or a specific version of a channel",
24+
Example: `# List images in current release of a channel by name
25+
replicated release image ls --channel Stable
26+
27+
# List images in a specific version of a channel
28+
replicated release image ls --channel Stable --version 1.2.1
29+
30+
# List images in a channel by ID
31+
replicated release image ls --channel 2abc123
32+
33+
# Keep proxy registry domains in the image names
34+
replicated release image ls --channel Stable --keep-proxy`,
35+
}
36+
37+
lsCmd.Flags().StringVar(&r.args.releaseImageLSChannel, "channel", "", "The channel name, slug, or ID (required)")
38+
lsCmd.Flags().StringVar(&r.args.releaseImageLSVersion, "version", "", "The specific semver version to get images for (optional, defaults to current release)")
39+
lsCmd.Flags().BoolVar(&r.args.releaseImageLSKeepProxy, "keep-proxy", false, "Keep proxy registry domain in image names instead of stripping it")
40+
lsCmd.MarkFlagRequired("channel")
41+
42+
parent.AddCommand(imageCmd)
43+
imageCmd.AddCommand(lsCmd)
44+
lsCmd.RunE = r.releaseImageLS
45+
}
46+
47+
func (r *runners) releaseImageLS(cmd *cobra.Command, args []string) error {
48+
if !r.hasApp() {
49+
return errors.New("no app specified")
50+
}
51+
52+
if r.args.releaseImageLSChannel == "" {
53+
return errors.New("channel is required")
54+
}
55+
56+
// Get the channel to find its current release
57+
channel, err := r.api.GetChannelByName(r.appID, r.appType, r.args.releaseImageLSChannel)
58+
if err != nil {
59+
return fmt.Errorf("failed to get channel: %w", err)
60+
}
61+
62+
var targetRelease *types.ChannelRelease
63+
var proxyDomain string
64+
65+
if r.args.releaseImageLSVersion != "" {
66+
// For specific versions, we need to get all releases
67+
channelReleases, err := r.api.ListChannelReleases(r.appID, r.appType, channel.ID)
68+
if err != nil {
69+
return fmt.Errorf("failed to list channel releases: %w", err)
70+
}
71+
72+
targetRelease, err = findTargetRelease(channelReleases, r.args.releaseImageLSVersion)
73+
if err != nil {
74+
return err
75+
}
76+
77+
// Get proxy domain for version-specific releases
78+
proxyDomain = targetRelease.ProxyRegistryDomain
79+
if proxyDomain == "" && r.appType == "kots" {
80+
customHostnames, err := r.api.GetCustomHostnames(r.appID, r.appType, channel.ID)
81+
if err == nil && customHostnames.Proxy.Hostname != "" {
82+
proxyDomain = customHostnames.Proxy.Hostname
83+
}
84+
// Check embedded cluster proxy domain if still empty
85+
if proxyDomain == "" && targetRelease.InstallationTypes.EmbeddedCluster.ProxyRegistryDomain != "" {
86+
proxyDomain = targetRelease.InstallationTypes.EmbeddedCluster.ProxyRegistryDomain
87+
}
88+
// If no explicit proxy domain is configured, fall back to default custom hostname
89+
if proxyDomain == "" {
90+
defaultProxy, err := r.api.GetDefaultProxyHostname(r.appID)
91+
if err == nil && defaultProxy != "" {
92+
proxyDomain = defaultProxy
93+
} else {
94+
// Final fallback to default Replicated proxy
95+
proxyDomain = "proxy.replicated.com"
96+
}
97+
}
98+
}
99+
} else {
100+
// For current release, use optimized method that tries to avoid extra API call
101+
var err error
102+
targetRelease, proxyDomain, err = r.api.GetCurrentChannelRelease(r.appID, r.appType, channel.ID)
103+
if err != nil {
104+
return fmt.Errorf("failed to get current channel release: %w", err)
105+
}
106+
}
107+
108+
// Extract and clean up image names
109+
images := make([]string, 0)
110+
111+
for _, image := range targetRelease.AirgapBundleImages {
112+
// Remove registry prefixes and clean up image names
113+
var cleanProxyDomain string
114+
if r.args.releaseImageLSKeepProxy {
115+
// Keep proxy domain - don't strip it
116+
cleanProxyDomain = ""
117+
} else {
118+
// Strip proxy domain
119+
cleanProxyDomain = proxyDomain
120+
}
121+
cleanImage := cleanImageName(image, cleanProxyDomain)
122+
if cleanImage != "" {
123+
images = append(images, cleanImage)
124+
}
125+
}
126+
127+
// Print images
128+
return print.ChannelImages(r.w, images)
129+
}
130+
131+
func cleanImageName(image string, proxyRegistryDomain string) string {
132+
cleaned := image
133+
134+
// Remove proxy registry domain if provided and present
135+
if proxyRegistryDomain != "" {
136+
// Handle proxy registry patterns like "proxyRegistryDomain/proxy/app-name/"
137+
proxyPrefix := proxyRegistryDomain + "/proxy/"
138+
if strings.HasPrefix(cleaned, proxyPrefix) {
139+
// Remove prefix and find first occurrence after app name
140+
withoutPrefix := strings.TrimPrefix(cleaned, proxyPrefix)
141+
parts := strings.SplitN(withoutPrefix, "/", 2)
142+
if len(parts) == 2 {
143+
cleaned = parts[1] // Take everything after the app name
144+
}
145+
}
146+
// Also handle anonymous proxy registry domain prefixes
147+
if strings.HasPrefix(cleaned, proxyRegistryDomain+"/anonymous/") {
148+
cleaned = strings.TrimPrefix(cleaned, proxyRegistryDomain+"/anonymous/")
149+
}
150+
}
151+
152+
// Remove other common registry prefixes
153+
prefixes := []string{
154+
"registry-1.docker.io/library/",
155+
"registry-1.docker.io/",
156+
"docker.io/library/",
157+
"docker.io/",
158+
"index.docker.io/library/",
159+
"index.docker.io/",
160+
"hub.docker.com/library/",
161+
"hub.docker.com/",
162+
"registry.hub.docker.com/library/",
163+
"registry.hub.docker.com/",
164+
}
165+
166+
for _, prefix := range prefixes {
167+
if strings.HasPrefix(cleaned, prefix) {
168+
cleaned = strings.TrimPrefix(cleaned, prefix)
169+
}
170+
}
171+
172+
return cleaned
173+
}
174+
175+
// findTargetRelease finds the target release from a list of releases
176+
// If requestedVersion is empty, returns the current release (highest channel sequence)
177+
// If requestedVersion is specified, returns the release with matching semver
178+
func findTargetRelease(releases []*types.ChannelRelease, requestedVersion string) (*types.ChannelRelease, error) {
179+
if len(releases) == 0 {
180+
return nil, errors.New("no releases found in channel")
181+
}
182+
183+
var targetRelease *types.ChannelRelease
184+
185+
if requestedVersion != "" {
186+
// Find release by semver
187+
for _, release := range releases {
188+
if release.Semver == requestedVersion {
189+
targetRelease = release
190+
break
191+
}
192+
}
193+
if targetRelease == nil {
194+
return nil, fmt.Errorf("no release found with version %q in channel", requestedVersion)
195+
}
196+
} else {
197+
// Find the current release (highest channel sequence)
198+
for _, release := range releases {
199+
if targetRelease == nil || release.ChannelSequence > targetRelease.ChannelSequence {
200+
targetRelease = release
201+
}
202+
}
203+
if targetRelease == nil {
204+
return nil, errors.New("no current release found")
205+
}
206+
}
207+
208+
return targetRelease, nil
209+
}
210+
211+

cli/cmd/release_image_ls_test.go

Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
package cmd
2+
3+
import (
4+
"testing"
5+
6+
"github.com/replicatedhq/replicated/pkg/types"
7+
"github.com/stretchr/testify/assert"
8+
)
9+
10+
func TestCleanImageName(t *testing.T) {
11+
tests := []struct {
12+
name string
13+
input string
14+
proxyRegistryDomain string
15+
expected string
16+
}{
17+
{
18+
name: "proxy registry with app name",
19+
input: "images.shortrib.io/proxy/testapp/ghcr.io/example/app:v1.0.0",
20+
proxyRegistryDomain: "images.shortrib.io",
21+
expected: "ghcr.io/example/app:v1.0.0",
22+
},
23+
{
24+
name: "docker hub registry prefix",
25+
input: "docker.io/library/postgres:14",
26+
proxyRegistryDomain: "",
27+
expected: "postgres:14",
28+
},
29+
{
30+
name: "index.docker.io prefix",
31+
input: "index.docker.io/replicated/replicated-sdk:1.0.0-beta.32",
32+
proxyRegistryDomain: "",
33+
expected: "replicated/replicated-sdk:1.0.0-beta.32",
34+
},
35+
{
36+
name: "no proxy registry domain provided",
37+
input: "images.shortrib.io/proxy/testapp/ghcr.io/example/app:v1.0.0",
38+
proxyRegistryDomain: "",
39+
expected: "images.shortrib.io/proxy/testapp/ghcr.io/example/app:v1.0.0",
40+
},
41+
{
42+
name: "proxy with replicated sdk (common SDK pattern)",
43+
input: "images.shortrib.io/proxy/testapp/proxy.replicated.com/library/replicated-sdk-image:1.7.1",
44+
proxyRegistryDomain: "images.shortrib.io",
45+
expected: "proxy.replicated.com/library/replicated-sdk-image:1.7.1",
46+
},
47+
{
48+
name: "no matching prefixes",
49+
input: "ghcr.io/myorg/myapp:v1.0.0",
50+
proxyRegistryDomain: "",
51+
expected: "ghcr.io/myorg/myapp:v1.0.0",
52+
},
53+
{
54+
name: "proxy registry with anonymous prefix",
55+
input: "myproxy.com/anonymous/redis:7.0",
56+
proxyRegistryDomain: "myproxy.com",
57+
expected: "redis:7.0",
58+
},
59+
{
60+
name: "proxy.replicated.com with library prefix (common SDK pattern)",
61+
input: "proxy.replicated.com/library/replicated-sdk-image:1.7.1",
62+
proxyRegistryDomain: "proxy.replicated.com",
63+
expected: "proxy.replicated.com/library/replicated-sdk-image:1.7.1",
64+
},
65+
{
66+
name: "proxy.replicated.com with proxy prefix and app name",
67+
input: "proxy.replicated.com/proxy/myapp/ghcr.io/example/app:v1.0",
68+
proxyRegistryDomain: "proxy.replicated.com",
69+
expected: "ghcr.io/example/app:v1.0",
70+
},
71+
}
72+
73+
for _, tt := range tests {
74+
t.Run(tt.name, func(t *testing.T) {
75+
result := cleanImageName(tt.input, tt.proxyRegistryDomain)
76+
assert.Equal(t, tt.expected, result)
77+
})
78+
}
79+
}
80+
81+
func TestFindTargetRelease(t *testing.T) {
82+
tests := []struct {
83+
name string
84+
releases []*types.ChannelRelease
85+
requestedVersion string
86+
expectedSequence int32
87+
expectError bool
88+
errorMsg string
89+
}{
90+
{
91+
name: "find current release (highest channel sequence)",
92+
releases: []*types.ChannelRelease{
93+
{ChannelSequence: 5, Semver: "1.0.0"},
94+
{ChannelSequence: 10, Semver: "1.1.0"},
95+
{ChannelSequence: 8, Semver: "1.0.5"},
96+
},
97+
requestedVersion: "",
98+
expectedSequence: 10,
99+
},
100+
{
101+
name: "find specific version",
102+
releases: []*types.ChannelRelease{
103+
{ChannelSequence: 5, Semver: "1.0.0"},
104+
{ChannelSequence: 10, Semver: "1.1.0"},
105+
{ChannelSequence: 8, Semver: "1.0.5"},
106+
},
107+
requestedVersion: "1.0.5",
108+
expectedSequence: 8,
109+
},
110+
{
111+
name: "version not found",
112+
releases: []*types.ChannelRelease{
113+
{ChannelSequence: 5, Semver: "1.0.0"},
114+
{ChannelSequence: 10, Semver: "1.1.0"},
115+
},
116+
requestedVersion: "2.0.0",
117+
expectError: true,
118+
errorMsg: "no release found with version \"2.0.0\"",
119+
},
120+
{
121+
name: "no releases",
122+
releases: []*types.ChannelRelease{},
123+
requestedVersion: "",
124+
expectError: true,
125+
errorMsg: "no releases found in channel",
126+
},
127+
}
128+
129+
for _, tt := range tests {
130+
t.Run(tt.name, func(t *testing.T) {
131+
targetRelease, err := findTargetRelease(tt.releases, tt.requestedVersion)
132+
133+
if tt.expectError {
134+
assert.Error(t, err)
135+
assert.Contains(t, err.Error(), tt.errorMsg)
136+
assert.Nil(t, targetRelease)
137+
} else {
138+
assert.NoError(t, err)
139+
assert.NotNil(t, targetRelease)
140+
assert.Equal(t, tt.expectedSequence, targetRelease.ChannelSequence)
141+
}
142+
})
143+
}
144+
}

cli/cmd/root.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,7 @@ func Execute(rootCmd *cobra.Command, stdin io.Reader, stdout io.Writer, stderr i
165165
runCmds.InitReleaseLint(releaseCmd)
166166
runCmds.InitReleaseTest(releaseCmd)
167167
runCmds.InitReleaseCompatibility(releaseCmd)
168+
runCmds.InitReleaseImageLS(releaseCmd)
168169

169170
collectorsCmd := runCmds.InitCollectorsCommand(runCmds.rootCmd)
170171
runCmds.InitCollectorList(collectorsCmd)

cli/cmd/runner.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,10 @@ type runnerArgs struct {
4040
channelCreateName string
4141
channelCreateDescription string
4242

43+
releaseImageLSChannel string
44+
releaseImageLSVersion string
45+
releaseImageLSKeepProxy bool
46+
4347
createCollectorName string
4448
createCollectorYaml string
4549
createCollectorYamlFile string

cli/print/channel_images.go

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
package print
2+
3+
import (
4+
"fmt"
5+
"sort"
6+
"text/tabwriter"
7+
)
8+
9+
func ChannelImages(w *tabwriter.Writer, images []string) error {
10+
// Sort images for consistent output
11+
sort.Strings(images)
12+
13+
// Print header
14+
fmt.Fprintln(w, "IMAGE")
15+
16+
// Print each image
17+
for _, image := range images {
18+
fmt.Fprintln(w, image)
19+
}
20+
21+
return w.Flush()
22+
}

0 commit comments

Comments
 (0)