Repository: openshift/oc
Pull request #2005: OCPBUGS-53106: Get installer image from configMap for node-image command
Author: login: bfournie
Labels: name: approved
description: Indicates a PR has been approved by an approver from all required OWNERS files.
name: lgtm
description: Indicates that a PR is ready to be merged.
name: jira/valid-bug
description: Indicates that a referenced Jira bug is valid for the branch this PR is targeting.
name: jira/valid-reference
description: Indicates that this PR references a valid Jira ticket of any type.
Created: 2025-04-09T17:53:39Z | Merged: 2025-04-25T03:46:08Z

In order to handle disconnected environments for the node-image command, get the installer image from the configMap created by the installer instead of extracting it from the releaseImage.


Diff:
diff --git a/pkg/cli/admin/nodeimage/basenodeimagecommand.go b/pkg/cli/admin/nodeimage/basenodeimagecommand.go
index bb8cfe0b8e..88dc905804 100644
--- a/pkg/cli/admin/nodeimage/basenodeimagecommand.go
+++ b/pkg/cli/admin/nodeimage/basenodeimagecommand.go
@@ -5,6 +5,7 @@ import (
 	"context"
 	"fmt"
 	"io"
+	"os"
 	"regexp"
 	"strings"
 	"time"
@@ -23,6 +24,7 @@ import (
 	"k8s.io/client-go/kubernetes"
 	"k8s.io/client-go/rest"
 	"k8s.io/kubectl/pkg/cmd/exec"
+	"sigs.k8s.io/yaml"
 
 	ocpv1 "github.com/openshift/api/config/v1"
 	configclient "github.com/openshift/client-go/config/clientset/versioned"
@@ -87,9 +89,43 @@ func (c *BaseNodeImageCommand) getNodeJoinerPullSpec(ctx context.Context) error
 	}
 
 	// Extract the baremetal-installer image pullspec, since it
-	// provide the node-joiner tool.
+	// provides the node-joiner tool.
+
+	// First attempt to get the installer image from the configMap created by installer. This will work in disconnected environments.
+	installerImagesConfigMap, err := c.Client.CoreV1().ConfigMaps("openshift-config").Get(ctx, "installer-images", metav1.GetOptions{})
+	if err == nil {
+		images := installerImagesConfigMap.Data["images.json"]
+		if len(images) > 0 {
+			imageMap := make(map[string]string)
+			err := yaml.Unmarshal([]byte(images), &imageMap)
+			if err == nil {
+				installer, exists := imageMap["installer"]
+				if exists {
+					// Found pullSpec from configMap
+					c.log("installer pullspec obtained from installer-images configMap %s", installer)
+					c.nodeJoinerImage = installer
+					return nil
+				}
+			}
+		}
+	}
+
+	// If cannot obtain installer image from configMap, get it from the releaseImage.
+	// Note that after OCP 4.20, this should not be necessary since the configMap was added to installer in 4.19.
+	c.log("configMap containing installer-images is not available, trying to get image from registry")
 	opts := ocrelease.NewInfoOptions(c.IOStreams)
 	opts.SecurityOptions = c.SecurityOptions
+	idmsFile, err := c.getIdmsFile(ctx)
+	if err != nil {
+		return err
+	}
+	opts.IDMSFile = idmsFile
+	defer func() {
+		if opts.IDMSFile != "" {
+			os.Remove(opts.IDMSFile)
+		}
+	}()
+
 	release, err := opts.LoadReleaseInfo(releaseImage, false)
 	if err != nil {
 		return err
@@ -409,3 +445,45 @@ func (o *BaseNodeImageCommand) runNodeJoinerPod(ctx context.Context, tasks []fun
 	}
 	return nil
 }
+
+func (c *BaseNodeImageCommand) getIdmsFile(ctx context.Context) (string, error) {
+	imageDigestMirrorSets, idmsErr := c.ConfigClient.ConfigV1().ImageDigestMirrorSets().List(ctx, metav1.ListOptions{})
+	if idmsErr != nil && !kapierrors.IsNotFound(idmsErr) {
+		return "", idmsErr
+	}
+	imageContentPolicies, icspErr := c.ConfigClient.ConfigV1().ImageContentPolicies().List(ctx, metav1.ListOptions{})
+	if icspErr != nil && !kapierrors.IsNotFound(icspErr) {
+		return "", icspErr
+	}
+	if idmsErr != nil || len(imageDigestMirrorSets.Items) == 0 {
+		imageDigestMirrorSets = nil // handle empty idms
+	}
+	if icspErr != nil || len(imageContentPolicies.Items) == 0 {
+		imageContentPolicies = nil // handle empty icsp
+	}
+	if imageDigestMirrorSets == nil && imageContentPolicies == nil {
+		return "", nil
+	}
+
+	// Build IDMS file from contents of the IDMS and ICSP mirror digests
+	contents, err := getIdmsContents(c.LogOut, imageDigestMirrorSets, imageContentPolicies)
+	if err != nil {
+		c.log("failure parsing imageDigestMirrorSet %s", err.Error())
+		return "", err
+	}
+
+	// create temp file which will be removed by caller
+	idmsFile, err := os.CreateTemp("", "idms-file")
+	if err != nil {
+		return "", err
+	}
+
+	c.log("Building IDMS file with contents %s", contents)
+	if _, err := idmsFile.Write(contents); err != nil {
+		idmsFile.Close()
+		os.Remove(idmsFile.Name())
+		return "", err
+	}
+	idmsFile.Close()
+	return idmsFile.Name(), nil
+}
diff --git a/pkg/cli/admin/nodeimage/mirrorbuilder.go b/pkg/cli/admin/nodeimage/mirrorbuilder.go
new file mode 100644
index 0000000000..ab95e9c9a1
--- /dev/null
+++ b/pkg/cli/admin/nodeimage/mirrorbuilder.go
@@ -0,0 +1,75 @@
+package nodeimage
+
+import (
+	"fmt"
+	"io"
+
+	ocpv1 "github.com/openshift/api/config/v1"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	"sigs.k8s.io/yaml"
+)
+
+func getIdmsContents(writer io.Writer, idmsMirrorSetList *ocpv1.ImageDigestMirrorSetList, icspMirrorSetList *ocpv1.ImageContentPolicyList) ([]byte, error) {
+	imageDigestSet := ocpv1.ImageDigestMirrorSet{
+		TypeMeta: metav1.TypeMeta{
+			APIVersion: ocpv1.GroupVersion.String(),
+			Kind:       "ImageDigestMirrorSet",
+		},
+		ObjectMeta: metav1.ObjectMeta{
+			Name: "image-digest",
+			// not namespaced
+		},
+	}
+
+	// Add mirrors from IDMS
+	if idmsMirrorSetList != nil {
+		log(writer, "Adding mirrors from ImageDigestMirrors")
+		for _, idms := range idmsMirrorSetList.Items {
+			for _, digestMirror := range idms.Spec.ImageDigestMirrors {
+				imageDigestSet.Spec.ImageDigestMirrors = append(imageDigestSet.Spec.ImageDigestMirrors, digestMirror)
+			}
+		}
+	}
+
+	// Add mirrors from ICSP
+	if icspMirrorSetList != nil {
+		log(writer, "Adding mirrors from ImageContentPolicies")
+		for _, icsp := range icspMirrorSetList.Items {
+			for _, digestMirror := range icsp.Spec.RepositoryDigestMirrors {
+				// Skip adding the mirror if the source already exists
+				if digestSourceExists(imageDigestSet.Spec.ImageDigestMirrors, digestMirror.Source) {
+					continue
+				}
+				mirror := ocpv1.ImageDigestMirrors{
+					Source: digestMirror.Source,
+				}
+				for _, m := range digestMirror.Mirrors {
+					mirror.Mirrors = append(mirror.Mirrors, ocpv1.ImageMirror(m))
+				}
+				imageDigestSet.Spec.ImageDigestMirrors = append(imageDigestSet.Spec.ImageDigestMirrors, mirror)
+			}
+		}
+	}
+
+	contents, err := yaml.Marshal(imageDigestSet)
+	if err != nil {
+		return nil, err
+	}
+
+	return contents, nil
+}
+
+func digestSourceExists(mirrors []ocpv1.ImageDigestMirrors, source string) bool {
+	for _, mirror := range mirrors {
+		if mirror.Source == source {
+			return true
+		}
+	}
+	return false
+}
+
+func log(writer io.Writer, format string, a ...interface{}) {
+	if writer != nil {
+		fmt.Fprintf(writer, format+"\n", a...)
+	}
+}
diff --git a/pkg/cli/admin/nodeimage/mirrorbuilder_test.go b/pkg/cli/admin/nodeimage/mirrorbuilder_test.go
new file mode 100644
index 0000000000..9f7b65f486
--- /dev/null
+++ b/pkg/cli/admin/nodeimage/mirrorbuilder_test.go
@@ -0,0 +1,187 @@
+package nodeimage
+
+import (
+	"reflect"
+	"testing"
+
+	ocpv1 "github.com/openshift/api/config/v1"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+)
+
+var defaultIdmsSetList = ocpv1.ImageDigestMirrorSetList{
+	TypeMeta: metav1.TypeMeta{},
+	ListMeta: metav1.ListMeta{
+		ResourceVersion: "15",
+	},
+	Items: []ocpv1.ImageDigestMirrorSet{
+		{
+			TypeMeta:   metav1.TypeMeta{},
+			ObjectMeta: metav1.ObjectMeta{Name: "image-digest-mirror", Namespace: "test"},
+			Spec: ocpv1.ImageDigestMirrorSetSpec{
+				ImageDigestMirrors: []ocpv1.ImageDigestMirrors{
+					{
+						Source: "quay.io/openshift-release-dev/ocp-v4.0-art-dev",
+						Mirrors: []ocpv1.ImageMirror{
+							"virthost.test:5000/localimages/local-release-image",
+						},
+					},
+					{
+						Source: "registry.ci.openshift.org/ocp/release",
+						Mirrors: []ocpv1.ImageMirror{
+							"virthost.test:5000/localimages/local-dev",
+						},
+					},
+				},
+			},
+		},
+	},
+}
+
+var defaultIdms = `apiVersion: config.openshift.io/v1
+kind: ImageDigestMirrorSet
+metadata:
+  creationTimestamp: null
+  name: image-digest
+spec:
+  imageDigestMirrors:
+  - mirrors:
+    - virthost.test:5000/localimages/local-release-image
+    source: quay.io/openshift-release-dev/ocp-v4.0-art-dev
+  - mirrors:
+    - virthost.test:5000/localimages/local-dev
+    source: registry.ci.openshift.org/ocp/release
+status: {}
+`
+
+var defaultIcspList = ocpv1.ImageContentPolicyList{
+	TypeMeta: metav1.TypeMeta{},
+	ListMeta: metav1.ListMeta{
+		ResourceVersion: "15",
+	},
+	Items: []ocpv1.ImageContentPolicy{
+		{
+			TypeMeta:   metav1.TypeMeta{},
+			ObjectMeta: metav1.ObjectMeta{Name: "image-policy", Namespace: "test"},
+			Spec: ocpv1.ImageContentPolicySpec{
+				RepositoryDigestMirrors: []ocpv1.RepositoryDigestMirrors{
+					{
+						Source: "quay.io/openshift-release-dev/ocp-v4.0-art-dev",
+						Mirrors: []ocpv1.Mirror{
+							"virthost.test:5000/localimages/local-release-image",
+						},
+					},
+					{
+						Source: "registry.ci.openshift.org/ocp/release",
+						Mirrors: []ocpv1.Mirror{
+							"virthost.test:5000/localimages/local-dev",
+						},
+					},
+				},
+			},
+		},
+	},
+}
+
+var icspListDifferentThanIdms = ocpv1.ImageContentPolicyList{
+	TypeMeta: metav1.TypeMeta{},
+	ListMeta: metav1.ListMeta{
+		ResourceVersion: "15",
+	},
+	Items: []ocpv1.ImageContentPolicy{
+		{
+			TypeMeta:   metav1.TypeMeta{},
+			ObjectMeta: metav1.ObjectMeta{Name: "image-policy", Namespace: "test"},
+			Spec: ocpv1.ImageContentPolicySpec{
+				RepositoryDigestMirrors: []ocpv1.RepositoryDigestMirrors{
+					{
+						Source: "example.com/ocp-v4.0-art-dev",
+						Mirrors: []ocpv1.Mirror{
+							"localimages/local-release-image",
+						},
+					},
+					{
+						Source: "example.com/ocp/release",
+						Mirrors: []ocpv1.Mirror{
+							"localimages/local-dev",
+						},
+					},
+				},
+			},
+		},
+	},
+}
+
+var icspPlusIdms = `apiVersion: config.openshift.io/v1
+kind: ImageDigestMirrorSet
+metadata:
+  creationTimestamp: null
+  name: image-digest
+spec:
+  imageDigestMirrors:
+  - mirrors:
+    - virthost.test:5000/localimages/local-release-image
+    source: quay.io/openshift-release-dev/ocp-v4.0-art-dev
+  - mirrors:
+    - virthost.test:5000/localimages/local-dev
+    source: registry.ci.openshift.org/ocp/release
+  - mirrors:
+    - localimages/local-release-image
+    source: example.com/ocp-v4.0-art-dev
+  - mirrors:
+    - localimages/local-dev
+    source: example.com/ocp/release
+status: {}
+`
+
+func TestGetIdmsContents(t *testing.T) {
+	testCases := []struct {
+		name          string
+		idmsSetList   *ocpv1.ImageDigestMirrorSetList
+		icspSetList   *ocpv1.ImageContentPolicyList
+		expectedIdms  []byte
+		expectedError string
+	}{
+		{
+			name:         "IDMS Only mirrors",
+			idmsSetList:  &defaultIdmsSetList,
+			icspSetList:  nil,
+			expectedIdms: []byte(defaultIdms),
+		},
+		{
+			name:         "ICSP Only mirrors",
+			idmsSetList:  nil,
+			icspSetList:  &defaultIcspList,
+			expectedIdms: []byte(defaultIdms),
+		},
+		{
+			name:         "IDMS and ICSP mirrors the same",
+			idmsSetList:  &defaultIdmsSetList,
+			icspSetList:  &defaultIcspList,
+			expectedIdms: []byte(defaultIdms),
+		},
+		{
+			name:         "IDMS and ICSP mirrors are different",
+			idmsSetList:  &defaultIdmsSetList,
+			icspSetList:  &icspListDifferentThanIdms,
+			expectedIdms: []byte(icspPlusIdms),
+		},
+	}
+	for _, tc := range testCases {
+		t.Run(tc.name, func(t *testing.T) {
+			idmsContents, err := getIdmsContents(nil, tc.idmsSetList, tc.icspSetList)
+
+			if tc.expectedError == "" {
+				if err != nil {
+					t.Fatalf("unexpected error: %v", err)
+				}
+				if !reflect.DeepEqual(tc.expectedIdms, idmsContents) {
+					t.Errorf("Expected:\n%s\ngot:\n%s", tc.expectedIdms, idmsContents)
+				}
+			} else {
+				if err == nil {
+					t.Fatalf("expected error not received: %s", tc.expectedError)
+				}
+			}
+		})
+	}
+}


Reviews:
user: login: andfasano
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-09T18:45:49Z
user: login: zaneb
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-10T01:52:23Z
user: login: andfasano
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-10T08:14:09Z
user: login: bfournie
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-10T23:16:50Z
user: login: bfournie
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-10T23:17:07Z
user: login: bfournie
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-12T21:16:03Z
user: login: zaneb
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-13T22:56:31Z
user: login: zaneb
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-13T23:17:19Z
user: login: zaneb
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-13T23:21:08Z
user: login: bfournie
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-13T23:59:50Z
user: login: bfournie
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-14T00:00:49Z
user: login: zaneb
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-14T10:01:50Z
user: login: bfournie
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-14T10:38:12Z
user: login: andfasano
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-17T07:53:01Z
user: login: bfournie
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-23T13:45:38Z

Review comments:
diff_hunk: @@ -87,7 +88,28 @@ func (c *BaseNodeImageCommand) getNodeJoinerPullSpec(ctx context.Context) error
 	}
 
 	// Extract the baremetal-installer image pullspec, since it
-	// provide the node-joiner tool.
+	// provides the node-joiner tool.
+
+	// First attempt to get the installer image from the configMap created by installer. This will work in disconnected environments.
+	installerImagesConfigMap, err := c.Client.CoreV1().ConfigMaps("openshift-config").Get(ctx, "installer-images", metav1.GetOptions{})
+	if err == nil {
+		images := installerImagesConfigMap.Data["images.json"]
+		if len(images) > 0 {
+			imageMap := make(map[string]string)
+			err := yaml.Unmarshal([]byte(images), &imageMap)
+			if err == nil {
+				installer, exists := imageMap["installer"]
+				if exists {
+					// Found pullSpec from configMap
+					c.log("installer pullspec obtained from installer-images configMap %s", installer)
+					c.nodeJoinerImage = installer
+					return nil
+				}
+			}
+		}
+	}
+
+	// If cannot obtain installer image from configMap, get it from the releaseImage
path: pkg/cli/admin/nodeimage/basenodeimagecommand.go
user: login: andfasano
body: Hi @bfournie that part looks fine to me, for when the configmap is available.

But for backward compatibility (oc has also a skew support of -1/+1), or in general for when it's not available, the below fallback mechanism should be fixed to use the mirror info if present in the cluster.

This means that `opts.IDMSFile` (or `opts.ICSPFile`) should be set in case such info were found. For that the existing node-joiner could be reused https://github.com/openshift/installer/blob/7867e67e84f38f33016c1506764770982cfee503/pkg/asset/agent/joiner/clusterinfo.go#L428
created_at: 2025-04-09T18:45:49Z
author_association: CONTRIBUTOR
diff_hunk: @@ -87,7 +88,28 @@ func (c *BaseNodeImageCommand) getNodeJoinerPullSpec(ctx context.Context) error
 	}
 
 	// Extract the baremetal-installer image pullspec, since it
-	// provide the node-joiner tool.
+	// provides the node-joiner tool.
+
+	// First attempt to get the installer image from the configMap created by installer. This will work in disconnected environments.
+	installerImagesConfigMap, err := c.Client.CoreV1().ConfigMaps("openshift-config").Get(ctx, "installer-images", metav1.GetOptions{})
+	if err == nil {
+		images := installerImagesConfigMap.Data["images.json"]
+		if len(images) > 0 {
+			imageMap := make(map[string]string)
+			err := yaml.Unmarshal([]byte(images), &imageMap)
+			if err == nil {
+				installer, exists := imageMap["installer"]
+				if exists {
+					// Found pullSpec from configMap
+					c.log("installer pullspec obtained from installer-images configMap %s", installer)
+					c.nodeJoinerImage = installer
+					return nil
+				}
+			}
+		}
+	}
+
+	// If cannot obtain installer image from configMap, get it from the releaseImage
path: pkg/cli/admin/nodeimage/basenodeimagecommand.go
user: login: zaneb
body: I'm reluctant to modify the code below when it won't be exercised in any tests.

For existing clusters that can't be upgraded, there is a workaround available: manually create the ConfigMap. It's not perfect but it's not nothing.
created_at: 2025-04-10T01:43:20Z
author_association: MEMBER
diff_hunk: @@ -87,7 +88,28 @@ func (c *BaseNodeImageCommand) getNodeJoinerPullSpec(ctx context.Context) error
 	}
 
 	// Extract the baremetal-installer image pullspec, since it
-	// provide the node-joiner tool.
+	// provides the node-joiner tool.
+
+	// First attempt to get the installer image from the configMap created by installer. This will work in disconnected environments.
+	installerImagesConfigMap, err := c.Client.CoreV1().ConfigMaps("openshift-config").Get(ctx, "installer-images", metav1.GetOptions{})
+	if err == nil {
+		images := installerImagesConfigMap.Data["images.json"]
+		if len(images) > 0 {
+			imageMap := make(map[string]string)
+			err := yaml.Unmarshal([]byte(images), &imageMap)
+			if err == nil {
+				installer, exists := imageMap["installer"]
+				if exists {
+					// Found pullSpec from configMap
+					c.log("installer pullspec obtained from installer-images configMap %s", installer)
+					c.nodeJoinerImage = installer
+					return nil
+				}
+			}
+		}
+	}
+
+	// If cannot obtain installer image from configMap, get it from the releaseImage
path: pkg/cli/admin/nodeimage/basenodeimagecommand.go
user: login: zaneb
body: Can we note in the comment what release we're able to delete this workaround in? (I think it's 4.20 right?)
created_at: 2025-04-10T01:43:57Z
author_association: MEMBER
diff_hunk: @@ -87,7 +88,28 @@ func (c *BaseNodeImageCommand) getNodeJoinerPullSpec(ctx context.Context) error
 	}
 
 	// Extract the baremetal-installer image pullspec, since it
-	// provide the node-joiner tool.
+	// provides the node-joiner tool.
+
+	// First attempt to get the installer image from the configMap created by installer. This will work in disconnected environments.
+	installerImagesConfigMap, err := c.Client.CoreV1().ConfigMaps("openshift-config").Get(ctx, "installer-images", metav1.GetOptions{})
+	if err == nil {
+		images := installerImagesConfigMap.Data["images.json"]
+		if len(images) > 0 {
+			imageMap := make(map[string]string)
+			err := yaml.Unmarshal([]byte(images), &imageMap)
+			if err == nil {
+				installer, exists := imageMap["installer"]
+				if exists {
+					// Found pullSpec from configMap
+					c.log("installer pullspec obtained from installer-images configMap %s", installer)
+					c.nodeJoinerImage = installer
+					return nil
+				}
+			}
+		}
+	}
path: pkg/cli/admin/nodeimage/basenodeimagecommand.go
user: login: zaneb
body: If there's an error other than NotFound, we should at least log it.
And if it _is_ NotFound, we should probably log that we're trying the registry.
created_at: 2025-04-10T01:45:13Z
author_association: MEMBER
diff_hunk: @@ -87,7 +88,28 @@ func (c *BaseNodeImageCommand) getNodeJoinerPullSpec(ctx context.Context) error
 	}
 
 	// Extract the baremetal-installer image pullspec, since it
-	// provide the node-joiner tool.
+	// provides the node-joiner tool.
+
+	// First attempt to get the installer image from the configMap created by installer. This will work in disconnected environments.
+	installerImagesConfigMap, err := c.Client.CoreV1().ConfigMaps("openshift-config").Get(ctx, "installer-images", metav1.GetOptions{})
+	if err == nil {
+		images := installerImagesConfigMap.Data["images.json"]
+		if len(images) > 0 {
+			imageMap := make(map[string]string)
+			err := yaml.Unmarshal([]byte(images), &imageMap)
+			if err == nil {
+				installer, exists := imageMap["installer"]
+				if exists {
+					// Found pullSpec from configMap
+					c.log("installer pullspec obtained from installer-images configMap %s", installer)
+					c.nodeJoinerImage = installer
+					return nil
+				}
+			}
+		}
+	}
+
+	// If cannot obtain installer image from configMap, get it from the releaseImage
path: pkg/cli/admin/nodeimage/basenodeimagecommand.go
user: login: andfasano
body: > I'm reluctant to modify the code below when it won't be exercised in any tests.
This is a good point, and on the CI side it could be tested by performing a disconnected installation (plus add node) in a fully disconnected environment (there's a pending [PR](https://github.com/openshift-metal3/dev-scripts/pull/1742) for enabling that).

On the QE side I think the new disconnected scenario will be covered after the initial discussion (IIRC it was also reproduced)? cc @mhanss @bmanzari  @zniu1011 for confirmation

> For existing clusters that can't be upgraded, there is a workaround available: manually create the ConfigMap. It's not perfect but it's not nothing.

Without the fix all the combinations of clusters/oc between 4.17 - 4.19 will be affected (oc must provide a version skew support of -1/+1), and there will be a non-zeo cost in troubleshooting them / applying the workaround. Given that the fix itself it's pretty straightforward (get the mirror info if present and set them for the request), it seems to me a relevant simplification - with the implicit assumption to have full test coverage for that.


created_at: 2025-04-10T08:14:09Z
author_association: CONTRIBUTOR
diff_hunk: @@ -87,7 +88,28 @@ func (c *BaseNodeImageCommand) getNodeJoinerPullSpec(ctx context.Context) error
 	}
 
 	// Extract the baremetal-installer image pullspec, since it
-	// provide the node-joiner tool.
+	// provides the node-joiner tool.
+
+	// First attempt to get the installer image from the configMap created by installer. This will work in disconnected environments.
+	installerImagesConfigMap, err := c.Client.CoreV1().ConfigMaps("openshift-config").Get(ctx, "installer-images", metav1.GetOptions{})
+	if err == nil {
+		images := installerImagesConfigMap.Data["images.json"]
+		if len(images) > 0 {
+			imageMap := make(map[string]string)
+			err := yaml.Unmarshal([]byte(images), &imageMap)
+			if err == nil {
+				installer, exists := imageMap["installer"]
+				if exists {
+					// Found pullSpec from configMap
+					c.log("installer pullspec obtained from installer-images configMap %s", installer)
+					c.nodeJoinerImage = installer
+					return nil
+				}
+			}
+		}
+	}
+
+	// If cannot obtain installer image from configMap, get it from the releaseImage
path: pkg/cli/admin/nodeimage/basenodeimagecommand.go
user: login: bfournie
body: Added comment
created_at: 2025-04-10T23:16:49Z
author_association: CONTRIBUTOR
diff_hunk: @@ -87,7 +88,28 @@ func (c *BaseNodeImageCommand) getNodeJoinerPullSpec(ctx context.Context) error
 	}
 
 	// Extract the baremetal-installer image pullspec, since it
-	// provide the node-joiner tool.
+	// provides the node-joiner tool.
+
+	// First attempt to get the installer image from the configMap created by installer. This will work in disconnected environments.
+	installerImagesConfigMap, err := c.Client.CoreV1().ConfigMaps("openshift-config").Get(ctx, "installer-images", metav1.GetOptions{})
+	if err == nil {
+		images := installerImagesConfigMap.Data["images.json"]
+		if len(images) > 0 {
+			imageMap := make(map[string]string)
+			err := yaml.Unmarshal([]byte(images), &imageMap)
+			if err == nil {
+				installer, exists := imageMap["installer"]
+				if exists {
+					// Found pullSpec from configMap
+					c.log("installer pullspec obtained from installer-images configMap %s", installer)
+					c.nodeJoinerImage = installer
+					return nil
+				}
+			}
+		}
+	}
path: pkg/cli/admin/nodeimage/basenodeimagecommand.go
user: login: bfournie
body: Added logging.
created_at: 2025-04-10T23:17:07Z
author_association: CONTRIBUTOR
diff_hunk: @@ -87,7 +88,28 @@ func (c *BaseNodeImageCommand) getNodeJoinerPullSpec(ctx context.Context) error
 	}
 
 	// Extract the baremetal-installer image pullspec, since it
-	// provide the node-joiner tool.
+	// provides the node-joiner tool.
+
+	// First attempt to get the installer image from the configMap created by installer. This will work in disconnected environments.
+	installerImagesConfigMap, err := c.Client.CoreV1().ConfigMaps("openshift-config").Get(ctx, "installer-images", metav1.GetOptions{})
+	if err == nil {
+		images := installerImagesConfigMap.Data["images.json"]
+		if len(images) > 0 {
+			imageMap := make(map[string]string)
+			err := yaml.Unmarshal([]byte(images), &imageMap)
+			if err == nil {
+				installer, exists := imageMap["installer"]
+				if exists {
+					// Found pullSpec from configMap
+					c.log("installer pullspec obtained from installer-images configMap %s", installer)
+					c.nodeJoinerImage = installer
+					return nil
+				}
+			}
+		}
+	}
+
+	// If cannot obtain installer image from configMap, get it from the releaseImage
path: pkg/cli/admin/nodeimage/basenodeimagecommand.go
user: login: bfournie
body: Added IDMS/ICSP support to handle backwards compatibility with installer versions that don't yet support the installer-images configMap.
created_at: 2025-04-12T21:16:03Z
author_association: CONTRIBUTOR
diff_hunk: @@ -87,7 +88,28 @@ func (c *BaseNodeImageCommand) getNodeJoinerPullSpec(ctx context.Context) error
 	}
 
 	// Extract the baremetal-installer image pullspec, since it
-	// provide the node-joiner tool.
+	// provides the node-joiner tool.
+
+	// First attempt to get the installer image from the configMap created by installer. This will work in disconnected environments.
+	installerImagesConfigMap, err := c.Client.CoreV1().ConfigMaps("openshift-config").Get(ctx, "installer-images", metav1.GetOptions{})
+	if err == nil {
+		images := installerImagesConfigMap.Data["images.json"]
+		if len(images) > 0 {
+			imageMap := make(map[string]string)
+			err := yaml.Unmarshal([]byte(images), &imageMap)
+			if err == nil {
+				installer, exists := imageMap["installer"]
+				if exists {
+					// Found pullSpec from configMap
+					c.log("installer pullspec obtained from installer-images configMap %s", installer)
+					c.nodeJoinerImage = installer
+					return nil
+				}
+			}
+		}
+	}
+
+	// If cannot obtain installer image from configMap, get it from the releaseImage
path: pkg/cli/admin/nodeimage/basenodeimagecommand.go
user: login: zaneb
body: > on the CI side it could be tested by performing a disconnected installation (plus add node) in a fully disconnected environment

We'd have to do that _and_ test the latest `oc` against 4.17/4.18 clusters to exercise the fallback.
And even that would only give us coverage until we backport the installer change. After that we'd have to test against old .z releases.
created_at: 2025-04-13T22:56:31Z
author_association: MEMBER
diff_hunk: @@ -87,9 +89,48 @@ func (c *BaseNodeImageCommand) getNodeJoinerPullSpec(ctx context.Context) error
 	}
 
 	// Extract the baremetal-installer image pullspec, since it
-	// provide the node-joiner tool.
+	// provides the node-joiner tool.
+
+	// First attempt to get the installer image from the configMap created by installer. This will work in disconnected environments.
+	installerImagesConfigMap, err := c.Client.CoreV1().ConfigMaps("openshift-config").Get(ctx, "installer-images", metav1.GetOptions{})
+	if err == nil {
+		images := installerImagesConfigMap.Data["images.json"]
+		if len(images) > 0 {
+			imageMap := make(map[string]string)
+			err := yaml.Unmarshal([]byte(images), &imageMap)
+			if err == nil {
+				installer, exists := imageMap["installer"]
+				if exists {
+					// Found pullSpec from configMap
+					c.log("installer pullspec obtained from installer-images configMap %s", installer)
+					c.nodeJoinerImage = installer
+					return nil
+				}
+			}
+		}
+	} else if !kapierrors.IsNotFound(err) {
+		c.log("failure retrieving installer-images configMap %s", err.Error())
+	} else {
+		c.log("configMap containing installer-images is not available, trying to get image from registry")
+	}
+
+	// If cannot obtain installer image from configMap, get it from the releaseImage.
+	// Note that after OCP 4.20, this should not be necessary since the configMap was added to installer in 4.19.
 	opts := ocrelease.NewInfoOptions(c.IOStreams)
 	opts.SecurityOptions = c.SecurityOptions
+	opts.IDMSFile = c.getIdmsFile(ctx)
+	if opts.IDMSFile == "" {
+		opts.ICSPFile = c.getIcspFile(ctx)
path: pkg/cli/admin/nodeimage/basenodeimagecommand.go
user: login: zaneb
body: Clusters are allowed to have both ICSP and IDMS objects. Ideally we would build a single IDMSFile from both sources rather than separate files from each and picking one.
created_at: 2025-04-13T23:17:19Z
author_association: MEMBER
diff_hunk: @@ -87,9 +89,48 @@ func (c *BaseNodeImageCommand) getNodeJoinerPullSpec(ctx context.Context) error
 	}
 
 	// Extract the baremetal-installer image pullspec, since it
-	// provide the node-joiner tool.
+	// provides the node-joiner tool.
+
+	// First attempt to get the installer image from the configMap created by installer. This will work in disconnected environments.
+	installerImagesConfigMap, err := c.Client.CoreV1().ConfigMaps("openshift-config").Get(ctx, "installer-images", metav1.GetOptions{})
+	if err == nil {
+		images := installerImagesConfigMap.Data["images.json"]
+		if len(images) > 0 {
+			imageMap := make(map[string]string)
+			err := yaml.Unmarshal([]byte(images), &imageMap)
+			if err == nil {
+				installer, exists := imageMap["installer"]
+				if exists {
+					// Found pullSpec from configMap
+					c.log("installer pullspec obtained from installer-images configMap %s", installer)
+					c.nodeJoinerImage = installer
+					return nil
+				}
+			}
+		}
+	} else if !kapierrors.IsNotFound(err) {
+		c.log("failure retrieving installer-images configMap %s", err.Error())
+	} else {
path: pkg/cli/admin/nodeimage/basenodeimagecommand.go
user: login: zaneb
body: On reflection, maybe just put this log outside of an `else` block, since there are other ways to get to the fallback code (e.g. ConfigMap has been incorrectly manually created on an old version, so it exists but does not have the expected keys).
created_at: 2025-04-13T23:21:08Z
author_association: MEMBER
diff_hunk: @@ -87,9 +89,48 @@ func (c *BaseNodeImageCommand) getNodeJoinerPullSpec(ctx context.Context) error
 	}
 
 	// Extract the baremetal-installer image pullspec, since it
-	// provide the node-joiner tool.
+	// provides the node-joiner tool.
+
+	// First attempt to get the installer image from the configMap created by installer. This will work in disconnected environments.
+	installerImagesConfigMap, err := c.Client.CoreV1().ConfigMaps("openshift-config").Get(ctx, "installer-images", metav1.GetOptions{})
+	if err == nil {
+		images := installerImagesConfigMap.Data["images.json"]
+		if len(images) > 0 {
+			imageMap := make(map[string]string)
+			err := yaml.Unmarshal([]byte(images), &imageMap)
+			if err == nil {
+				installer, exists := imageMap["installer"]
+				if exists {
+					// Found pullSpec from configMap
+					c.log("installer pullspec obtained from installer-images configMap %s", installer)
+					c.nodeJoinerImage = installer
+					return nil
+				}
+			}
+		}
+	} else if !kapierrors.IsNotFound(err) {
+		c.log("failure retrieving installer-images configMap %s", err.Error())
+	} else {
+		c.log("configMap containing installer-images is not available, trying to get image from registry")
+	}
+
+	// If cannot obtain installer image from configMap, get it from the releaseImage.
+	// Note that after OCP 4.20, this should not be necessary since the configMap was added to installer in 4.19.
 	opts := ocrelease.NewInfoOptions(c.IOStreams)
 	opts.SecurityOptions = c.SecurityOptions
+	opts.IDMSFile = c.getIdmsFile(ctx)
+	if opts.IDMSFile == "" {
+		opts.ICSPFile = c.getIcspFile(ctx)
path: pkg/cli/admin/nodeimage/basenodeimagecommand.go
user: login: bfournie
body: Are they both supported **at the same time**? I know there can be ICSP's after an upgrade but I thought it was [recommended](https://docs.redhat.com/en/documentation/openshift_container_platform/4.14/html/images/image-configuration#images-configuration-registry-mirror_image-configuration) to convert that to an IDMS.

I know with ABI only IDMS are created in the cluster, since 4.17 I believe - https://issues.redhat.com//browse/MGMT-14159. 

We could combine the mirrors but I assume that would just create duplicates, I don't think the intent is to split the mirroring config across the 2 objects.
created_at: 2025-04-13T23:59:50Z
author_association: CONTRIBUTOR
diff_hunk: @@ -87,9 +89,48 @@ func (c *BaseNodeImageCommand) getNodeJoinerPullSpec(ctx context.Context) error
 	}
 
 	// Extract the baremetal-installer image pullspec, since it
-	// provide the node-joiner tool.
+	// provides the node-joiner tool.
+
+	// First attempt to get the installer image from the configMap created by installer. This will work in disconnected environments.
+	installerImagesConfigMap, err := c.Client.CoreV1().ConfigMaps("openshift-config").Get(ctx, "installer-images", metav1.GetOptions{})
+	if err == nil {
+		images := installerImagesConfigMap.Data["images.json"]
+		if len(images) > 0 {
+			imageMap := make(map[string]string)
+			err := yaml.Unmarshal([]byte(images), &imageMap)
+			if err == nil {
+				installer, exists := imageMap["installer"]
+				if exists {
+					// Found pullSpec from configMap
+					c.log("installer pullspec obtained from installer-images configMap %s", installer)
+					c.nodeJoinerImage = installer
+					return nil
+				}
+			}
+		}
+	} else if !kapierrors.IsNotFound(err) {
+		c.log("failure retrieving installer-images configMap %s", err.Error())
+	} else {
path: pkg/cli/admin/nodeimage/basenodeimagecommand.go
user: login: bfournie
body: Sure, I'll move it so we log just one failure retrieving installer-images configMap.
created_at: 2025-04-14T00:00:49Z
author_association: CONTRIBUTOR
diff_hunk: @@ -87,9 +89,48 @@ func (c *BaseNodeImageCommand) getNodeJoinerPullSpec(ctx context.Context) error
 	}
 
 	// Extract the baremetal-installer image pullspec, since it
-	// provide the node-joiner tool.
+	// provides the node-joiner tool.
+
+	// First attempt to get the installer image from the configMap created by installer. This will work in disconnected environments.
+	installerImagesConfigMap, err := c.Client.CoreV1().ConfigMaps("openshift-config").Get(ctx, "installer-images", metav1.GetOptions{})
+	if err == nil {
+		images := installerImagesConfigMap.Data["images.json"]
+		if len(images) > 0 {
+			imageMap := make(map[string]string)
+			err := yaml.Unmarshal([]byte(images), &imageMap)
+			if err == nil {
+				installer, exists := imageMap["installer"]
+				if exists {
+					// Found pullSpec from configMap
+					c.log("installer pullspec obtained from installer-images configMap %s", installer)
+					c.nodeJoinerImage = installer
+					return nil
+				}
+			}
+		}
+	} else if !kapierrors.IsNotFound(err) {
+		c.log("failure retrieving installer-images configMap %s", err.Error())
+	} else {
+		c.log("configMap containing installer-images is not available, trying to get image from registry")
+	}
+
+	// If cannot obtain installer image from configMap, get it from the releaseImage.
+	// Note that after OCP 4.20, this should not be necessary since the configMap was added to installer in 4.19.
 	opts := ocrelease.NewInfoOptions(c.IOStreams)
 	opts.SecurityOptions = c.SecurityOptions
+	opts.IDMSFile = c.getIdmsFile(ctx)
+	if opts.IDMSFile == "" {
+		opts.ICSPFile = c.getIcspFile(ctx)
path: pkg/cli/admin/nodeimage/basenodeimagecommand.go
user: login: zaneb
body: > Are they both supported **at the same time**?

Looks like yes unless something has changed: https://issues.redhat.com/browse/OCPNODE-1771

From your doc link:

>  For new clusters, you can use IDMS, ITMS, and ICSP CRs objects as desired. However, using IDMS and ITMS is recommended.
>
> If you upgraded a cluster, any existing ICSP objects remain stable, and both IDMS and ICSP objects are supported.
created_at: 2025-04-14T10:01:50Z
author_association: MEMBER
diff_hunk: @@ -87,9 +89,48 @@ func (c *BaseNodeImageCommand) getNodeJoinerPullSpec(ctx context.Context) error
 	}
 
 	// Extract the baremetal-installer image pullspec, since it
-	// provide the node-joiner tool.
+	// provides the node-joiner tool.
+
+	// First attempt to get the installer image from the configMap created by installer. This will work in disconnected environments.
+	installerImagesConfigMap, err := c.Client.CoreV1().ConfigMaps("openshift-config").Get(ctx, "installer-images", metav1.GetOptions{})
+	if err == nil {
+		images := installerImagesConfigMap.Data["images.json"]
+		if len(images) > 0 {
+			imageMap := make(map[string]string)
+			err := yaml.Unmarshal([]byte(images), &imageMap)
+			if err == nil {
+				installer, exists := imageMap["installer"]
+				if exists {
+					// Found pullSpec from configMap
+					c.log("installer pullspec obtained from installer-images configMap %s", installer)
+					c.nodeJoinerImage = installer
+					return nil
+				}
+			}
+		}
+	} else if !kapierrors.IsNotFound(err) {
+		c.log("failure retrieving installer-images configMap %s", err.Error())
+	} else {
+		c.log("configMap containing installer-images is not available, trying to get image from registry")
+	}
+
+	// If cannot obtain installer image from configMap, get it from the releaseImage.
+	// Note that after OCP 4.20, this should not be necessary since the configMap was added to installer in 4.19.
 	opts := ocrelease.NewInfoOptions(c.IOStreams)
 	opts.SecurityOptions = c.SecurityOptions
+	opts.IDMSFile = c.getIdmsFile(ctx)
+	if opts.IDMSFile == "" {
+		opts.ICSPFile = c.getIcspFile(ctx)
path: pkg/cli/admin/nodeimage/basenodeimagecommand.go
user: login: bfournie
body: OK, I was under the assumption that the ICSP was removed after it was converted to IDMS. If they're both there I'll concatenate all of the mirrors into one IDMS.
created_at: 2025-04-14T10:38:12Z
author_association: CONTRIBUTOR
diff_hunk: @@ -409,3 +441,37 @@ func (o *BaseNodeImageCommand) runNodeJoinerPod(ctx context.Context, tasks []fun
 	}
 	return nil
 }
+
+func (c *BaseNodeImageCommand) getIdmsFile(ctx context.Context) string {
+	imageDigestMirrorSets, idmsErr := c.ConfigClient.ConfigV1().ImageDigestMirrorSets().List(ctx, metav1.ListOptions{})
+	imageContentPolicies, icspErr := c.ConfigClient.ConfigV1().ImageContentPolicies().List(ctx, metav1.ListOptions{})
+	if idmsErr != nil && icspErr != nil {
+		return ""
path: pkg/cli/admin/nodeimage/basenodeimagecommand.go
user: login: andfasano
body: Only if `errors.IsNotFound()` for both the errors is true then it's ok to return an empty string, otherwise it may be a genuine error and should be reported as usual
created_at: 2025-04-17T07:38:48Z
author_association: CONTRIBUTOR
diff_hunk: @@ -409,3 +441,37 @@ func (o *BaseNodeImageCommand) runNodeJoinerPod(ctx context.Context, tasks []fun
 	}
 	return nil
 }
+
+func (c *BaseNodeImageCommand) getIdmsFile(ctx context.Context) string {
+	imageDigestMirrorSets, idmsErr := c.ConfigClient.ConfigV1().ImageDigestMirrorSets().List(ctx, metav1.ListOptions{})
+	imageContentPolicies, icspErr := c.ConfigClient.ConfigV1().ImageContentPolicies().List(ctx, metav1.ListOptions{})
+	if idmsErr != nil && icspErr != nil {
+		return ""
+	}
+
+	// Build IDMS file from contents of the IDMS and ICSP mirror digests
+	var err error
+	var contents []byte
+	if idmsErr == nil && icspErr == nil {
+		contents, err = getIdmsContents(imageDigestMirrorSets, imageContentPolicies)
+	} else if idmsErr == nil && icspErr != nil {
+		contents, err = getIdmsContents(imageDigestMirrorSets, nil)
+	} else {
+		contents, err = getIdmsContents(nil, imageContentPolicies)
+	}
+	if err != nil {
+		c.log("failure parsing imageDigestMirrorSet %s", err.Error())
+		return ""
+	}
+
+	// create temp file which will be removed by caller
+	idmsFile, err := os.CreateTemp("", "idms-file")
+	c.log("Building IDMS file with contents %s", contents)
path: pkg/cli/admin/nodeimage/basenodeimagecommand.go
user: login: andfasano
body: This `err` seems to be ignored
created_at: 2025-04-17T07:41:12Z
author_association: CONTRIBUTOR
diff_hunk: @@ -409,3 +441,37 @@ func (o *BaseNodeImageCommand) runNodeJoinerPod(ctx context.Context, tasks []fun
 	}
 	return nil
 }
+
+func (c *BaseNodeImageCommand) getIdmsFile(ctx context.Context) string {
+	imageDigestMirrorSets, idmsErr := c.ConfigClient.ConfigV1().ImageDigestMirrorSets().List(ctx, metav1.ListOptions{})
+	imageContentPolicies, icspErr := c.ConfigClient.ConfigV1().ImageContentPolicies().List(ctx, metav1.ListOptions{})
+	if idmsErr != nil && icspErr != nil {
+		return ""
+	}
+
+	// Build IDMS file from contents of the IDMS and ICSP mirror digests
+	var err error
+	var contents []byte
+	if idmsErr == nil && icspErr == nil {
+		contents, err = getIdmsContents(imageDigestMirrorSets, imageContentPolicies)
+	} else if idmsErr == nil && icspErr != nil {
+		contents, err = getIdmsContents(imageDigestMirrorSets, nil)
+	} else {
+		contents, err = getIdmsContents(nil, imageContentPolicies)
+	}
+	if err != nil {
+		c.log("failure parsing imageDigestMirrorSet %s", err.Error())
+		return ""
+	}
+
+	// create temp file which will be removed by caller
+	idmsFile, err := os.CreateTemp("", "idms-file")
+	c.log("Building IDMS file with contents %s", contents)
+	if _, err := idmsFile.Write(contents); err != nil {
+		idmsFile.Close()
+		os.Remove(idmsFile.Name())
+		return ""
path: pkg/cli/admin/nodeimage/basenodeimagecommand.go
user: login: andfasano
body: In this case I'd expect

```suggestion
		return "", err
```
created_at: 2025-04-17T07:41:54Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,64 @@
+package nodeimage
+
+import (
+	ocpv1 "github.com/openshift/api/config/v1"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	"sigs.k8s.io/yaml"
+)
+
+func getIdmsContents(idmsMirrorSetList *ocpv1.ImageDigestMirrorSetList, icspMirrorSetList *ocpv1.ImageContentPolicyList) ([]byte, error) {
+	imageDigestSet := ocpv1.ImageDigestMirrorSet{
+		TypeMeta: metav1.TypeMeta{
+			APIVersion: ocpv1.GroupVersion.String(),
+			Kind:       "ImageDigestMirrorSet",
+		},
+		ObjectMeta: metav1.ObjectMeta{
+			Name: "image-digest",
+			// not namespaced
+		},
+	}
+
+	// Add mirrors from IDMS
+	if idmsMirrorSetList != nil {
+		for _, idms := range idmsMirrorSetList.Items {
+			for _, digestMirror := range idms.Spec.ImageDigestMirrors {
+				imageDigestSet.Spec.ImageDigestMirrors = append(imageDigestSet.Spec.ImageDigestMirrors, digestMirror)
+			}
+		}
+	}
+
+	// Add mirrors from ICSP
+	if icspMirrorSetList != nil {
path: pkg/cli/admin/nodeimage/mirrorbuilder.go
user: login: andfasano
body: I know this has been already previously discussed - and I keep finding a little bit awkward chaining both the sources. Since ICSP is deprecated,I think that at least logging its usage could make it more evident
created_at: 2025-04-17T07:52:45Z
author_association: CONTRIBUTOR
diff_hunk: @@ -409,3 +441,37 @@ func (o *BaseNodeImageCommand) runNodeJoinerPod(ctx context.Context, tasks []fun
 	}
 	return nil
 }
+
+func (c *BaseNodeImageCommand) getIdmsFile(ctx context.Context) string {
+	imageDigestMirrorSets, idmsErr := c.ConfigClient.ConfigV1().ImageDigestMirrorSets().List(ctx, metav1.ListOptions{})
+	imageContentPolicies, icspErr := c.ConfigClient.ConfigV1().ImageContentPolicies().List(ctx, metav1.ListOptions{})
+	if idmsErr != nil && icspErr != nil {
+		return ""
+	}
+
+	// Build IDMS file from contents of the IDMS and ICSP mirror digests
+	var err error
+	var contents []byte
+	if idmsErr == nil && icspErr == nil {
+		contents, err = getIdmsContents(imageDigestMirrorSets, imageContentPolicies)
+	} else if idmsErr == nil && icspErr != nil {
+		contents, err = getIdmsContents(imageDigestMirrorSets, nil)
+	} else {
+		contents, err = getIdmsContents(nil, imageContentPolicies)
+	}
+	if err != nil {
+		c.log("failure parsing imageDigestMirrorSet %s", err.Error())
+		return ""
+	}
+
+	// create temp file which will be removed by caller
+	idmsFile, err := os.CreateTemp("", "idms-file")
+	c.log("Building IDMS file with contents %s", contents)
path: pkg/cli/admin/nodeimage/basenodeimagecommand.go
user: login: bfournie
body: Fixed
created_at: 2025-04-23T13:45:38Z
author_association: CONTRIBUTOR

PR comments:
user: login: openshift-ci-robot
created_at: 2025-04-09T17:53:49Z
body: @bfournie: This pull request references [Jira Issue OCPBUGS-53106](https://issues.redhat.com//browse/OCPBUGS-53106), which is valid. The bug has been moved to the POST state.

<details><summary>3 validation(s) were run on this bug</summary>

* bug is open, matching expected state (open)
* bug target version (4.19.0) matches configured target version for branch (4.19.0)
* bug is in the state ASSIGNED, which is one of the valid states (NEW, ASSIGNED, POST)</details>

Requesting review from QA contact:
/cc @mhanss

The bug has been updated to refer to the pull request using the external bug tracker.

<details>

In response to [this](https://github.com/openshift/oc/pull/2005):

>In order to handle disconnected environments for the node-image command, get the installer image from the configMap created by the installer instead of extracting it from the releaseImage.


Instructions for interacting with me using PR comments are available [here](https://prow.ci.openshift.org/command-help?repo=openshift%2Foc).  If you have questions or suggestions related to my behavior, please file an issue against the [openshift-eng/jira-lifecycle-plugin](https://github.com/openshift-eng/jira-lifecycle-plugin/issues/new) repository.
</details>
author_association: NONE
user: login: bfournie
created_at: 2025-04-09T17:54:42Z
body: /cc @andfasano @zaneb 
author_association: CONTRIBUTOR
user: login: bfournie
created_at: 2025-04-13T12:25:38Z
body: /retest-required
author_association: CONTRIBUTOR
user: login: bfournie
created_at: 2025-04-17T23:00:59Z
body: /retest-required
author_association: CONTRIBUTOR
user: login: andfasano
created_at: 2025-04-22T09:26:07Z
body: /retest-required
author_association: CONTRIBUTOR
user: login: bfournie
created_at: 2025-04-22T15:11:18Z
body: /retest-required
author_association: CONTRIBUTOR
user: login: andfasano
created_at: 2025-04-23T14:55:07Z
body: /lgtm
author_association: CONTRIBUTOR
user: login: openshift-ci-robot
created_at: 2025-04-23T15:33:05Z
body: /retest-required

Remaining retests: 0 against base HEAD c651008361a39683fbb4faf91cc80c0d263e1c33 and 2 for PR HEAD 8f3af023d6a3a67e8744e182ab1531ad35477ff4 in total

author_association: NONE
user: login: bfournie
created_at: 2025-04-23T18:31:54Z
body: /retest-required
author_association: CONTRIBUTOR
user: login: bfournie
created_at: 2025-04-23T19:50:18Z
body: /retest-required
author_association: CONTRIBUTOR
user: login: zaneb
created_at: 2025-04-24T00:39:46Z
body: I assume you have tested this manually against a disconnected 4.18 cluster?

/lgtm
author_association: MEMBER
user: login: bfournie
created_at: 2025-04-24T01:01:03Z
body: > I assume you have tested this manually against a disconnected 4.18 cluster?
> 
> /lgtm

I tested it by removing the configMap settings so the IDMS was passed in to the command.
author_association: CONTRIBUTOR
user: login: openshift-ci-robot
created_at: 2025-04-24T01:23:05Z
body: /retest-required

Remaining retests: 0 against base HEAD c651008361a39683fbb4faf91cc80c0d263e1c33 and 2 for PR HEAD e7856845c306c0dd9f828cc50b91609bf577a6c0 in total

author_association: NONE
user: login: openshift-ci-robot
created_at: 2025-04-24T04:26:48Z
body: /retest-required

Remaining retests: 0 against base HEAD 306c7b382cea28852f7d41332d4a41aa04a30f08 and 1 for PR HEAD e7856845c306c0dd9f828cc50b91609bf577a6c0 in total

author_association: NONE
user: login: andfasano
created_at: 2025-04-24T09:29:05Z
body: /lgtm
author_association: CONTRIBUTOR
user: login: openshift-ci[bot]
created_at: 2025-04-24T09:30:18Z
body: [APPROVALNOTIFIER] This PR is **APPROVED**

This pull-request has been approved by: *<a href="https://github.com/openshift/oc/pull/2005#issuecomment-2826962554" title="LGTM">andfasano</a>*, *<a href="https://github.com/openshift/oc/pull/2005#" title="Author self-approved">bfournie</a>*, *<a href="https://github.com/openshift/oc/pull/2005#issuecomment-2825858185" title="LGTM">zaneb</a>*

The full list of commands accepted by this bot can be found [here](https://go.k8s.io/bot-commands?repo=openshift%2Foc).

The pull request process is described [here](https://git.k8s.io/community/contributors/guide/owners.md#the-code-review-process)

<details >
Needs approval from an approver in each of these files:

- ~~[pkg/cli/admin/nodeimage/OWNERS](https://github.com/openshift/oc/blob/master/pkg/cli/admin/nodeimage/OWNERS)~~ [andfasano,bfournie,zaneb]

Approvers can indicate their approval by writing `/approve` in a comment
Approvers can cancel approval by writing `/approve cancel` in a comment
</details>
<!-- META={"approvers":[]} -->
author_association: CONTRIBUTOR
user: login: bfournie
created_at: 2025-04-24T10:37:57Z
body: /retest-required
author_association: CONTRIBUTOR
user: login: openshift-ci-robot
created_at: 2025-04-24T12:36:15Z
body: /retest-required

Remaining retests: 0 against base HEAD 306c7b382cea28852f7d41332d4a41aa04a30f08 and 2 for PR HEAD e7856845c306c0dd9f828cc50b91609bf577a6c0 in total

author_association: NONE
user: login: bfournie
created_at: 2025-04-24T13:24:05Z
body: /retest-required
author_association: CONTRIBUTOR
user: login: openshift-ci-robot
created_at: 2025-04-24T16:51:59Z
body: /retest-required

Remaining retests: 0 against base HEAD 306c7b382cea28852f7d41332d4a41aa04a30f08 and 2 for PR HEAD e7856845c306c0dd9f828cc50b91609bf577a6c0 in total

author_association: NONE
user: login: bfournie
created_at: 2025-04-24T23:48:40Z
body: /retest-required
author_association: CONTRIBUTOR
user: login: openshift-ci[bot]
created_at: 2025-04-25T03:45:15Z
body: @bfournie: all tests **passed!**


[Full PR test history](https://prow.ci.openshift.org/pr-history?org=openshift&repo=oc&pr=2005). [Your PR dashboard](https://prow.ci.openshift.org/pr?query=is:pr+state:open+author:bfournie).

<details>

Instructions for interacting with me using PR comments are available [here](https://git.k8s.io/community/contributors/guide/pull-requests.md).  If you have questions or suggestions related to my behavior, please file an issue against the [kubernetes-sigs/prow](https://github.com/kubernetes-sigs/prow/issues/new?title=Prow%20issue:) repository. I understand the commands that are listed [here](https://go.k8s.io/bot-commands).
</details>
<!-- test report -->
author_association: CONTRIBUTOR
user: login: openshift-ci-robot
created_at: 2025-04-25T03:46:13Z
body: @bfournie: [Jira Issue OCPBUGS-53106](https://issues.redhat.com//browse/OCPBUGS-53106): All pull requests linked via external trackers have merged:
 * [openshift/installer#9563](https://github.com/openshift/installer/pull/9563)
 * [openshift/oc#2005](https://github.com/openshift/oc/pull/2005)

[Jira Issue OCPBUGS-53106](https://issues.redhat.com//browse/OCPBUGS-53106) has been moved to the MODIFIED state.

<details>

In response to [this](https://github.com/openshift/oc/pull/2005):

>In order to handle disconnected environments for the node-image command, get the installer image from the configMap created by the installer instead of extracting it from the releaseImage.


Instructions for interacting with me using PR comments are available [here](https://prow.ci.openshift.org/command-help?repo=openshift%2Foc).  If you have questions or suggestions related to my behavior, please file an issue against the [openshift-eng/jira-lifecycle-plugin](https://github.com/openshift-eng/jira-lifecycle-plugin/issues/new) repository.
</details>
author_association: NONE
user: login: openshift-merge-robot
created_at: 2025-04-25T06:02:00Z
body: Fix included in accepted release 4.19.0-0.nightly-2025-04-04-170728
author_association: CONTRIBUTOR
user: login: openshift-bot
created_at: 2025-04-25T10:47:48Z
body: **[ART PR BUILD NOTIFIER]**

Distgit: openshift-enterprise-cli
This PR has been included in build [openshift-enterprise-cli-container-v4.20.0-202504250541.p0.ge468aa6.assembly.stream.el9](https://brewweb.engineering.redhat.com/brew/buildinfo?buildID=3620203).
All builds following this will include this PR.
author_association: CONTRIBUTOR
user: login: openshift-bot
created_at: 2025-04-25T10:59:45Z
body: **[ART PR BUILD NOTIFIER]**

Distgit: ose-tools
This PR has been included in build [ose-tools-container-v4.20.0-202504250541.p0.ge468aa6.assembly.stream.el9](https://brewweb.engineering.redhat.com/brew/buildinfo?buildID=3620218).
All builds following this will include this PR.
author_association: CONTRIBUTOR
user: login: openshift-bot
created_at: 2025-04-25T13:07:02Z
body: **[ART PR BUILD NOTIFIER]**

Distgit: openshift-enterprise-deployer
This PR has been included in build [openshift-enterprise-deployer-container-v4.20.0-202504250541.p0.ge468aa6.assembly.stream.el9](https://brewweb.engineering.redhat.com/brew/buildinfo?buildID=3620415).
All builds following this will include this PR.
author_association: CONTRIBUTOR
user: login: openshift-bot
created_at: 2025-04-25T13:39:51Z
body: **[ART PR BUILD NOTIFIER]**

Distgit: ose-cli-artifacts
This PR has been included in build [ose-cli-artifacts-container-v4.20.0-202504250541.p0.ge468aa6.assembly.stream.el9](https://brewweb.engineering.redhat.com/brew/buildinfo?buildID=3620412).
All builds following this will include this PR.
author_association: CONTRIBUTOR
user: login: bfournie
created_at: 2025-04-28T21:28:54Z
body: /cherry-pick release-4.18
author_association: CONTRIBUTOR
user: login: openshift-cherrypick-robot
created_at: 2025-04-28T21:29:43Z
body: @bfournie: new pull request created: #2013

<details>

In response to [this](https://github.com/openshift/oc/pull/2005#issuecomment-2836757154):

>/cherry-pick release-4.18


Instructions for interacting with me using PR comments are available [here](https://git.k8s.io/community/contributors/guide/pull-requests.md).  If you have questions or suggestions related to my behavior, please file an issue against the [kubernetes-sigs/prow](https://github.com/kubernetes-sigs/prow/issues/new?title=Prow%20issue:) repository.
</details>
author_association: NONE