Skip to content

Commit 8e3f280

Browse files
davidleerhAjpantuso
authored andcommitted
OCM-17876 | feat: IDMS support for ROSA-HCP
1 parent 8059531 commit 8e3f280

File tree

32 files changed

+2200
-3
lines changed

32 files changed

+2200
-3
lines changed

cmd/create/cmd.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import (
2929
"github.com/openshift/rosa/cmd/create/externalauthprovider"
3030
"github.com/openshift/rosa/cmd/create/iamserviceaccount"
3131
"github.com/openshift/rosa/cmd/create/idp"
32+
"github.com/openshift/rosa/cmd/create/imagemirror"
3233
"github.com/openshift/rosa/cmd/create/kubeletconfig"
3334
"github.com/openshift/rosa/cmd/create/machinepool"
3435
"github.com/openshift/rosa/cmd/create/network"
@@ -71,6 +72,8 @@ func init() {
7172
Cmd.AddCommand(autoscalerCommand)
7273
kubeletConfig := kubeletconfig.NewCreateKubeletConfigCommand()
7374
Cmd.AddCommand(kubeletConfig)
75+
imageMirrorCommand := imagemirror.NewCreateImageMirrorCommand()
76+
Cmd.AddCommand(imageMirrorCommand)
7477
Cmd.AddCommand(externalauthprovider.Cmd)
7578
Cmd.AddCommand(breakglasscredential.Cmd)
7679
decisionCommand := decision.NewCreateDecisionCommand()

cmd/create/imagemirror/cmd.go

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
/*
2+
Copyright (c) 2025 Red Hat, Inc.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
package imagemirror
18+
19+
import (
20+
"context"
21+
"fmt"
22+
23+
cmv1 "github.com/openshift-online/ocm-sdk-go/clustersmgmt/v1"
24+
"github.com/spf13/cobra"
25+
26+
"github.com/openshift/rosa/pkg/arguments"
27+
"github.com/openshift/rosa/pkg/ocm"
28+
"github.com/openshift/rosa/pkg/rosa"
29+
)
30+
31+
const (
32+
use = "image-mirror"
33+
short = "Create image mirror for a cluster"
34+
long = "Create an image mirror configuration for a Hosted Control Plane cluster. The image mirror ID will be auto-generated."
35+
example = ` # Create an image mirror for cluster "mycluster"
36+
rosa create image-mirror --cluster=mycluster \
37+
--source=registry.example.com/team \
38+
--mirrors=mirror.corp.com/team,backup.corp.com/team
39+
40+
# Create with a specific type (digest is default and only supported type)
41+
rosa create image-mirror --cluster=mycluster \
42+
--type=digest --source=docker.io/library \
43+
--mirrors=internal-registry.company.com/dockerhub`
44+
)
45+
46+
var (
47+
aliases = []string{"image-mirrors"}
48+
)
49+
50+
func NewCreateImageMirrorCommand() *cobra.Command {
51+
options := NewCreateImageMirrorOptions()
52+
cmd := &cobra.Command{
53+
Use: use,
54+
Short: short,
55+
Long: long,
56+
Aliases: aliases,
57+
Example: example,
58+
Args: cobra.NoArgs,
59+
Run: rosa.DefaultRunner(rosa.RuntimeWithOCM(), CreateImageMirrorRunner(options)),
60+
}
61+
62+
flags := cmd.Flags()
63+
64+
flags.StringVar(
65+
&options.Args().Type,
66+
"type",
67+
"digest",
68+
"Type of image mirror (default: digest)",
69+
)
70+
71+
flags.StringVar(
72+
&options.Args().Source,
73+
"source",
74+
"",
75+
"Source registry that will be mirrored (required)",
76+
)
77+
78+
flags.StringSliceVar(
79+
&options.Args().Mirrors,
80+
"mirrors",
81+
[]string{},
82+
"List of mirror registries (comma-separated, required)",
83+
)
84+
85+
_ = cmd.MarkFlagRequired("source")
86+
_ = cmd.MarkFlagRequired("mirrors")
87+
88+
ocm.AddClusterFlag(cmd)
89+
arguments.AddProfileFlag(cmd.Flags())
90+
arguments.AddRegionFlag(cmd.Flags())
91+
return cmd
92+
}
93+
94+
func CreateImageMirrorRunner(options *CreateImageMirrorOptions) rosa.CommandRunner {
95+
return func(_ context.Context, runtime *rosa.Runtime, cmd *cobra.Command, _ []string) error {
96+
clusterKey := runtime.GetClusterKey()
97+
args := options.Args()
98+
99+
cluster, err := runtime.OCMClient.GetCluster(clusterKey, runtime.Creator)
100+
if err != nil {
101+
return err
102+
}
103+
if cluster.State() != cmv1.ClusterStateReady {
104+
return fmt.Errorf("Cluster '%s' is not ready. Image mirrors can only be created on ready clusters", clusterKey)
105+
}
106+
107+
if !cluster.Hypershift().Enabled() {
108+
return fmt.Errorf("Image mirrors are only supported on Hosted Control Plane clusters")
109+
}
110+
111+
if len(args.Mirrors) == 0 {
112+
return fmt.Errorf("At least one mirror registry must be specified")
113+
}
114+
115+
createdMirror, err := runtime.OCMClient.CreateImageMirror(
116+
cluster.ID(), args.Type, args.Source, args.Mirrors)
117+
if err != nil {
118+
return fmt.Errorf("Failed to create image mirror: %v", err)
119+
}
120+
runtime.Reporter.Infof("Image mirror with ID '%s' has been created on cluster '%s'",
121+
createdMirror.ID(), clusterKey)
122+
runtime.Reporter.Infof("Source: %s", createdMirror.Source())
123+
runtime.Reporter.Infof("Mirrors: %v", createdMirror.Mirrors())
124+
125+
return nil
126+
}
127+
}

cmd/create/imagemirror/cmd_test.go

Lines changed: 217 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,217 @@
1+
package imagemirror
2+
3+
import (
4+
"context"
5+
"net/http"
6+
7+
. "github.com/onsi/ginkgo/v2"
8+
. "github.com/onsi/gomega"
9+
cmv1 "github.com/openshift-online/ocm-sdk-go/clustersmgmt/v1"
10+
. "github.com/openshift-online/ocm-sdk-go/testing"
11+
12+
"github.com/openshift/rosa/pkg/test"
13+
)
14+
15+
const (
16+
clusterId = "24vf9iitg3p6tlml88iml6j6mu095mh8"
17+
)
18+
19+
var _ = Describe("Create image mirror", func() {
20+
Context("Create image mirror command", func() {
21+
mockHCPClusterReady := test.MockCluster(func(c *cmv1.ClusterBuilder) {
22+
c.AWS(cmv1.NewAWS().SubnetIDs("subnet-0b761d44d3d9a4663", "subnet-0f87f640e56934cbc"))
23+
c.Region(cmv1.NewCloudRegion().ID("us-east-1"))
24+
c.State(cmv1.ClusterStateReady)
25+
c.Hypershift(cmv1.NewHypershift().Enabled(true))
26+
})
27+
28+
mockClassicCluster := test.MockCluster(func(c *cmv1.ClusterBuilder) {
29+
c.AWS(cmv1.NewAWS().SubnetIDs("subnet-0b761d44d3d9a4663", "subnet-0f87f640e56934cbc"))
30+
c.Region(cmv1.NewCloudRegion().ID("us-east-1"))
31+
c.State(cmv1.ClusterStateReady)
32+
c.Hypershift(cmv1.NewHypershift().Enabled(false))
33+
})
34+
35+
mockClusterNotReady := test.MockCluster(func(c *cmv1.ClusterBuilder) {
36+
c.AWS(cmv1.NewAWS().SubnetIDs("subnet-0b761d44d3d9a4663", "subnet-0f87f640e56934cbc"))
37+
c.Region(cmv1.NewCloudRegion().ID("us-east-1"))
38+
c.State(cmv1.ClusterStateInstalling)
39+
c.Hypershift(cmv1.NewHypershift().Enabled(true))
40+
})
41+
42+
hcpClusterReady := test.FormatClusterList([]*cmv1.Cluster{mockHCPClusterReady})
43+
classicClusterReady := test.FormatClusterList([]*cmv1.Cluster{mockClassicCluster})
44+
clusterNotReady := test.FormatClusterList([]*cmv1.Cluster{mockClusterNotReady})
45+
46+
var t *test.TestingRuntime
47+
48+
BeforeEach(func() {
49+
t = test.NewTestRuntime()
50+
})
51+
52+
Context("Success scenarios", func() {
53+
It("Creates image mirror successfully with all required parameters", func() {
54+
t.ApiServer.AppendHandlers(RespondWithJSON(http.StatusOK, hcpClusterReady))
55+
t.ApiServer.AppendHandlers(RespondWithJSON(http.StatusCreated, formatCreatedImageMirror()))
56+
options := NewCreateImageMirrorOptions()
57+
options.Args().Source = "registry.redhat.io"
58+
options.Args().Mirrors = []string{"mirror.example.com", "backup.example.com"}
59+
runner := CreateImageMirrorRunner(options)
60+
err := t.StdOutReader.Record()
61+
Expect(err).ToNot(HaveOccurred())
62+
cmd := NewCreateImageMirrorCommand()
63+
err = cmd.Flag("cluster").Value.Set(clusterId)
64+
Expect(err).ToNot(HaveOccurred())
65+
err = runner(context.Background(), t.RosaRuntime, cmd, []string{})
66+
Expect(err).ToNot(HaveOccurred())
67+
stdout, err := t.StdOutReader.Read()
68+
Expect(err).ToNot(HaveOccurred())
69+
Expect(stdout).To(ContainSubstring("Image mirror with ID 'test-mirror-123' has been created on cluster"))
70+
Expect(stdout).To(ContainSubstring("Source: registry.redhat.io"))
71+
Expect(stdout).To(ContainSubstring("Mirrors: [mirror.example.com backup.example.com]"))
72+
})
73+
74+
It("Creates image mirror with custom type", func() {
75+
t.ApiServer.AppendHandlers(RespondWithJSON(http.StatusOK, hcpClusterReady))
76+
t.ApiServer.AppendHandlers(RespondWithJSON(http.StatusCreated, formatCreatedImageMirror()))
77+
options := NewCreateImageMirrorOptions()
78+
options.Args().Type = "digest"
79+
options.Args().Source = "quay.io/openshift"
80+
options.Args().Mirrors = []string{"internal.corp.com/openshift"}
81+
runner := CreateImageMirrorRunner(options)
82+
cmd := NewCreateImageMirrorCommand()
83+
err := cmd.Flag("cluster").Value.Set(clusterId)
84+
Expect(err).ToNot(HaveOccurred())
85+
err = runner(context.Background(), t.RosaRuntime, cmd, []string{})
86+
Expect(err).ToNot(HaveOccurred())
87+
})
88+
89+
It("Creates image mirror with single mirror", func() {
90+
t.ApiServer.AppendHandlers(RespondWithJSON(http.StatusOK, hcpClusterReady))
91+
t.ApiServer.AppendHandlers(RespondWithJSON(http.StatusCreated, formatCreatedImageMirror()))
92+
options := NewCreateImageMirrorOptions()
93+
options.Args().Source = "docker.io/library"
94+
options.Args().Mirrors = []string{"mirror.company.com/dockerhub"}
95+
runner := CreateImageMirrorRunner(options)
96+
cmd := NewCreateImageMirrorCommand()
97+
err := cmd.Flag("cluster").Value.Set(clusterId)
98+
Expect(err).ToNot(HaveOccurred())
99+
err = runner(context.Background(), t.RosaRuntime, cmd, []string{})
100+
Expect(err).ToNot(HaveOccurred())
101+
})
102+
})
103+
104+
Context("Validation error scenarios", func() {
105+
It("Returns error when cluster is not ready", func() {
106+
t.ApiServer.AppendHandlers(RespondWithJSON(http.StatusOK, clusterNotReady))
107+
options := NewCreateImageMirrorOptions()
108+
options.Args().Source = "registry.redhat.io"
109+
options.Args().Mirrors = []string{"mirror.example.com"}
110+
runner := CreateImageMirrorRunner(options)
111+
cmd := NewCreateImageMirrorCommand()
112+
err := cmd.Flag("cluster").Value.Set(clusterId)
113+
Expect(err).ToNot(HaveOccurred())
114+
err = runner(context.Background(), t.RosaRuntime, cmd, []string{})
115+
Expect(err).To(HaveOccurred())
116+
Expect(err.Error()).To(ContainSubstring("is not ready. Image mirrors can only be created on ready clusters"))
117+
})
118+
119+
It("Returns error when cluster is not Hosted Control Plane", func() {
120+
t.ApiServer.AppendHandlers(RespondWithJSON(http.StatusOK, classicClusterReady))
121+
options := NewCreateImageMirrorOptions()
122+
options.Args().Source = "registry.redhat.io"
123+
options.Args().Mirrors = []string{"mirror.example.com"}
124+
runner := CreateImageMirrorRunner(options)
125+
cmd := NewCreateImageMirrorCommand()
126+
err := cmd.Flag("cluster").Value.Set(clusterId)
127+
Expect(err).ToNot(HaveOccurred())
128+
err = runner(context.Background(), t.RosaRuntime, cmd, []string{})
129+
Expect(err).To(HaveOccurred())
130+
Expect(err.Error()).To(ContainSubstring("Image mirrors are only supported on Hosted Control Plane clusters"))
131+
})
132+
133+
It("Returns error when cluster fetch fails", func() {
134+
t.ApiServer.AppendHandlers(RespondWithJSON(http.StatusNotFound, "{}"))
135+
options := NewCreateImageMirrorOptions()
136+
options.Args().Source = "registry.redhat.io"
137+
options.Args().Mirrors = []string{"mirror.example.com"}
138+
runner := CreateImageMirrorRunner(options)
139+
cmd := NewCreateImageMirrorCommand()
140+
err := cmd.Flag("cluster").Value.Set("nonexistent-cluster")
141+
Expect(err).ToNot(HaveOccurred())
142+
err = runner(context.Background(), t.RosaRuntime, cmd, []string{})
143+
Expect(err).To(HaveOccurred())
144+
Expect(err.Error()).To(ContainSubstring("status is 404"))
145+
})
146+
147+
It("Returns error when CreateImageMirror API call fails", func() {
148+
t.ApiServer.AppendHandlers(RespondWithJSON(http.StatusOK, hcpClusterReady))
149+
t.ApiServer.AppendHandlers(RespondWithJSON(http.StatusInternalServerError, "{}"))
150+
options := NewCreateImageMirrorOptions()
151+
options.Args().Source = "registry.redhat.io"
152+
options.Args().Mirrors = []string{"mirror.example.com"}
153+
runner := CreateImageMirrorRunner(options)
154+
cmd := NewCreateImageMirrorCommand()
155+
err := cmd.Flag("cluster").Value.Set(clusterId)
156+
Expect(err).ToNot(HaveOccurred())
157+
err = runner(context.Background(), t.RosaRuntime, cmd, []string{})
158+
Expect(err).To(HaveOccurred())
159+
Expect(err.Error()).To(ContainSubstring("Failed to create image mirror"))
160+
})
161+
})
162+
163+
Context("Runtime validation", func() {
164+
It("Returns error when mirrors array is empty", func() {
165+
// Test the runtime validation that checks if mirrors slice is empty
166+
t.ApiServer.AppendHandlers(RespondWithJSON(http.StatusOK, hcpClusterReady))
167+
options := NewCreateImageMirrorOptions()
168+
options.Args().Source = "registry.redhat.io"
169+
options.Args().Mirrors = []string{} // Empty array
170+
runner := CreateImageMirrorRunner(options)
171+
cmd := NewCreateImageMirrorCommand()
172+
err := cmd.Flag("cluster").Value.Set(clusterId)
173+
Expect(err).ToNot(HaveOccurred())
174+
err = runner(context.Background(), t.RosaRuntime, cmd, []string{})
175+
Expect(err).To(HaveOccurred())
176+
Expect(err.Error()).To(ContainSubstring("At least one mirror registry must be specified"))
177+
})
178+
179+
It("Has default type as digest", func() {
180+
cmd := NewCreateImageMirrorCommand()
181+
typeFlag := cmd.Flag("type")
182+
Expect(typeFlag.DefValue).To(Equal("digest"))
183+
})
184+
})
185+
186+
Context("Command structure", func() {
187+
It("Has correct command properties", func() {
188+
cmd := NewCreateImageMirrorCommand()
189+
Expect(cmd.Use).To(Equal("image-mirror"))
190+
Expect(cmd.Short).To(Equal("Create image mirror for a cluster"))
191+
Expect(cmd.Aliases).To(ContainElement("image-mirrors"))
192+
Expect(cmd.Args).ToNot(BeNil())
193+
})
194+
195+
It("Has expected flags", func() {
196+
cmd := NewCreateImageMirrorCommand()
197+
flags := []string{"cluster", "type", "source", "mirrors", "profile", "region"}
198+
for _, flagName := range flags {
199+
flag := cmd.Flag(flagName)
200+
Expect(flag).ToNot(BeNil(), "Flag %s should exist", flagName)
201+
}
202+
})
203+
})
204+
})
205+
})
206+
207+
// formatCreatedImageMirror simulates the response from creating an image mirror
208+
func formatCreatedImageMirror() string {
209+
imageMirror, err := cmv1.NewImageMirror().
210+
ID("test-mirror-123").
211+
Type("digest").
212+
Source("registry.redhat.io").
213+
Mirrors("mirror.example.com", "backup.example.com").
214+
Build()
215+
Expect(err).ToNot(HaveOccurred())
216+
return test.FormatResource(imageMirror)
217+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
package imagemirror
2+
3+
import (
4+
"testing"
5+
6+
. "github.com/onsi/ginkgo/v2"
7+
. "github.com/onsi/gomega"
8+
)
9+
10+
func TestImageMirror(t *testing.T) {
11+
RegisterFailHandler(Fail)
12+
RunSpecs(t, "Create ImageMirror suite")
13+
}

0 commit comments

Comments
 (0)