diff --git a/README.md b/README.md index ee0f6fc5b..91489ae94 100644 --- a/README.md +++ b/README.md @@ -241,6 +241,12 @@ The `delete` sub-command has its own set of flags: ### Other Features * [Enclave support](v2/docs/features/enclave_support.md) +#### Catalog Pinning + +When running mirror-to-disk or mirror-to-mirror workflows, oc-mirror automatically generates pinned configuration files where operator catalogs are referenced by SHA256 digests instead of tags. This ensures reproducible mirroring operations since digest references always point to the exact same image content, while tag-based references can change over time. + +Two files are generated in the working directory: `isc_pinned_{timestamp}.yaml` contains a pinned ImageSetConfiguration with catalogs referenced by digest, and `disc_pinned_{timestamp}.yaml` contains a corresponding DeleteImageSetConfiguration. The pinned ISC can be used for reproducible mirrors of the exact same content, while the pinned DISC can be used later with the delete sub-command to remove precisely what was mirrored. + ## Testing It is possible to run in this module unit tests. diff --git a/internal/pkg/cli/executor.go b/internal/pkg/cli/executor.go index 59f5e1b2f..b310fa8ea 100644 --- a/internal/pkg/cli/executor.go +++ b/internal/pkg/cli/executor.go @@ -888,6 +888,8 @@ func (o *ExecutorSchema) RunMirrorToDisk(cmd *cobra.Command, args []string) erro return batchError } + o.createConfigsWithPinnedCatalogs(collectorSchema) + o.Log.Info(emoji.Package + " Preparing the tarball archive...") return o.MirrorArchiver.BuildArchive(cmd.Context(), copiedSchema.AllImages) } @@ -934,6 +936,8 @@ func (o *ExecutorSchema) RunMirrorToMirror(cmd *cobra.Command, args []string) er // NOTE: we will check for batch errors at the end copiedSchema, batchError := o.Batch.Worker(cmd.Context(), collectorSchema, *o.Opts) + o.createConfigsWithPinnedCatalogs(collectorSchema) + // create IDMS/ITMS forceRepositoryScope := o.Opts.Global.MaxNestedPaths > 0 if err := o.ClusterResources.IDMS_ITMSGenerator(copiedSchema.AllImages, forceRepositoryScope); err != nil { @@ -1337,3 +1341,20 @@ func removeDuplicatedImages(allRelatedImages []v2alpha1.CopyImageSchema, mode st } }) } + +// createConfigsWithPinnedCatalogs generates and writes pinned ISC and DISC configurations. +func (o *ExecutorSchema) createConfigsWithPinnedCatalogs(collectorSchema v2alpha1.CollectorSchema) { + o.Log.Info("Generating pinned configurations...") + iscPath, discPath, err := config.PinAndWriteISCAndDSC( + o.Config, + collectorSchema.CatalogToFBCMap, + o.Opts.Global.WorkingDir, + o.Log, + ) + if err != nil { + o.Log.Warn("Failed to generate pinned configs: %v", err) + } else { + o.Log.Info("Pinned ISC written to: %s", iscPath) + o.Log.Info("Pinned DISC written to: %s", discPath) + } +} diff --git a/internal/pkg/config/pin_catalogs.go b/internal/pkg/config/pin_catalogs.go new file mode 100644 index 000000000..208c832e6 --- /dev/null +++ b/internal/pkg/config/pin_catalogs.go @@ -0,0 +1,181 @@ +package config + +import ( + "fmt" + "os" + "path/filepath" + "strings" + "time" + + "sigs.k8s.io/yaml" + + "github.com/openshift/oc-mirror/v2/internal/pkg/api/v2alpha1" + "github.com/openshift/oc-mirror/v2/internal/pkg/image" + clog "github.com/openshift/oc-mirror/v2/internal/pkg/log" +) + +// copyISC creates a shallow copy of ImageSetConfiguration with a deep copy of the Operators slice. +// This ensures we don't mutate the original configuration when pinning catalog digests. +func copyISC(cfg v2alpha1.ImageSetConfiguration) v2alpha1.ImageSetConfiguration { + copied := cfg + + // Deep copy operators slice (only part we modify) + if len(cfg.Mirror.Operators) > 0 { + copied.Mirror.Operators = make([]v2alpha1.Operator, len(cfg.Mirror.Operators)) + copy(copied.Mirror.Operators, cfg.Mirror.Operators) + } + + return copied +} + +// pinCatalogDigests creates a copy of ImageSetConfiguration with catalog references +// pinned to SHA256 digests instead of tags. +// +// For each operator catalog in cfg.Mirror.Operators: +// - Parses the catalog reference +// - Looks up the digest in catalogToFBCMap +// - Replaces the catalog field with digest format: {registry}/{path}@sha256:{digest} +// +// Returns a new ImageSetConfiguration with pinned catalogs and any errors encountered. +func pinCatalogDigests( + cfg v2alpha1.ImageSetConfiguration, + catalogToFBCMap map[string]v2alpha1.CatalogFilterResult, + log clog.PluggableLoggerInterface, +) (v2alpha1.ImageSetConfiguration, error) { + // Create copy to avoid mutating original + pinnedCfg := copyISC(cfg) + + // Iterate through operator catalogs by index to modify in place + for i := range pinnedCfg.Mirror.Operators { + op := &pinnedCfg.Mirror.Operators[i] + + // Parse catalog reference + imgSpec, err := image.ParseRef(op.Catalog) + if err != nil { + return v2alpha1.ImageSetConfiguration{}, + fmt.Errorf("failed to parse catalog %s: %w", op.Catalog, err) + } + + // Skip if already digest-pinned + if imgSpec.IsImageByDigest() { + log.Debug("Catalog %s is already digest-pinned, skipping", op.Catalog) + continue + } + + // Look up digest in map + filterResult, ok := catalogToFBCMap[imgSpec.ReferenceWithTransport] + if !ok { + // Log warning but continue (non-fatal) + log.Warn("Catalog %s not found in CatalogToFBCMap, skipping pin", op.Catalog) + continue + } + + // Check for empty digest + if filterResult.Digest == "" { + log.Warn("Empty digest for catalog %s, skipping pin", op.Catalog) + continue + } + + // Build pinned reference: {registry}/{path}@sha256:{digest} + pinnedRef := fmt.Sprintf("%s@sha256:%s", imgSpec.Name, filterResult.Digest) + + // Add transport prefix if non-docker (docker:// is default and can be omitted) + if imgSpec.Transport != "" && imgSpec.Transport != "docker://" { + pinnedRef = imgSpec.Transport + pinnedRef + } + + log.Debug("Pinning catalog %s to %s", op.Catalog, pinnedRef) + op.Catalog = pinnedRef + } + + return pinnedCfg, nil +} + +// writeConfigToFile writes a config object (ISC or DISC) to a YAML file with timestamp naming. +// Returns the absolute path to the written file. +func writeConfigToFile(obj interface{}, configType, workingDir string) (string, error) { + filename := fmt.Sprintf("%s_pinned_%s.yaml", configType, time.Now().UTC().Format(time.RFC3339)) + filePath := filepath.Join(workingDir, filename) + + yamlData, err := yaml.Marshal(obj) + if err != nil { + return "", fmt.Errorf("failed to marshal %s to YAML: %w", strings.ToUpper(configType), err) + } + + if err := os.WriteFile(filePath, yamlData, 0o600); err != nil { + return "", fmt.Errorf("failed to write pinned %s to %s: %w", strings.ToUpper(configType), filePath, err) + } + + return filePath, nil +} + +// writePinnedISC writes an ImageSetConfiguration to a YAML file with timestamp naming. +// +// The file is written to: {workingDir}/isc_pinned_{timestamp}.yaml +// e.g., isc_pinned_2025-12-31T11:37:17Z.yaml +// +// Returns the absolute path to the written file. +func writePinnedISC( + cfg v2alpha1.ImageSetConfiguration, + workingDir string, +) (string, error) { + cfg.SetGroupVersionKind(v2alpha1.GroupVersion.WithKind(v2alpha1.ImageSetConfigurationKind)) + return writeConfigToFile(cfg, "isc", workingDir) +} + +// createDISCFromISC creates a DeleteImageSetConfiguration from an already-pinned ImageSetConfiguration. +// It simply converts the Mirror section to a Delete section. +// +// The file is written to: {workingDir}/disc_pinned_{timestamp}.yaml +// e.g., disc_pinned_2025-12-31T11:37:17Z.yaml +// +// Returns the absolute path to the written pinned DISC file. +func createDISCFromISC( + pinnedISC v2alpha1.ImageSetConfiguration, + workingDir string, +) (string, error) { + disc := v2alpha1.DeleteImageSetConfiguration{ + DeleteImageSetConfigurationSpec: v2alpha1.DeleteImageSetConfigurationSpec{ + Delete: v2alpha1.Delete{ + Platform: pinnedISC.Mirror.Platform, + Operators: pinnedISC.Mirror.Operators, + AdditionalImages: pinnedISC.Mirror.AdditionalImages, + Helm: pinnedISC.Mirror.Helm, + }, + }, + } + + // Set TypeMeta for DeleteImageSetConfiguration + disc.SetGroupVersionKind(v2alpha1.GroupVersion.WithKind(v2alpha1.DeleteImageSetConfigurationKind)) + + return writeConfigToFile(disc, "disc", workingDir) +} + +// PinAndWriteISCAndDSC pins catalogs and writes both ISC and DISC files. +// Returns paths to both written files (ISC path, DISC path). +func PinAndWriteISCAndDSC( + cfg v2alpha1.ImageSetConfiguration, + catalogToFBCMap map[string]v2alpha1.CatalogFilterResult, + workingDir string, + log clog.PluggableLoggerInterface, +) (string, string, error) { + // Pin catalog digests + pinnedISC, err := pinCatalogDigests(cfg, catalogToFBCMap, log) + if err != nil { + return "", "", fmt.Errorf("failed to pin catalog digests: %w", err) + } + + // Write pinned ISC + iscPath, err := writePinnedISC(pinnedISC, workingDir) + if err != nil { + return "", "", fmt.Errorf("failed to write pinned ISC: %w", err) + } + + // Write pinned DISC + discPath, err := createDISCFromISC(pinnedISC, workingDir) + if err != nil { + return iscPath, "", fmt.Errorf("failed to create pinned DISC: %w", err) + } + + return iscPath, discPath, nil +} diff --git a/internal/pkg/config/pin_catalogs_test.go b/internal/pkg/config/pin_catalogs_test.go new file mode 100644 index 000000000..b041d8f06 --- /dev/null +++ b/internal/pkg/config/pin_catalogs_test.go @@ -0,0 +1,606 @@ +package config + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "sigs.k8s.io/yaml" + + "github.com/openshift/oc-mirror/v2/internal/pkg/api/v2alpha1" + clog "github.com/openshift/oc-mirror/v2/internal/pkg/log" +) + +const ( + // Valid SHA256 digests for testing (64 hex characters) + testDigest1 = "1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef" + testDigest2 = "fedcba0987654321fedcba0987654321fedcba0987654321fedcba0987654321" + + // Catalog references + redhatIndexTag = "registry.redhat.io/redhat/redhat-operator-index:v4.12" + redhatIndexTagDocker = "docker://registry.redhat.io/redhat/redhat-operator-index:v4.12" + certifiedIndexTag = "registry.redhat.io/redhat/certified-operator-index:v4.12" + certifiedIndexTagDocker = "docker://registry.redhat.io/redhat/certified-operator-index:v4.12" + communityIndexBase = "registry.redhat.io/redhat/community-operator-index" + redhatIndexBase = "registry.redhat.io/redhat/redhat-operator-index" + + // Test digests + testDigestShort1 = "abc123def456789" + testDigestShort2 = "digest1" + testDigestShort3 = "digest2" + testDigestShort4 = "abc123" + + // Kind constants + kindISC = "ImageSetConfiguration" + kindDISC = "DeleteImageSetConfiguration" + + // API version + apiVersion = "mirror.openshift.io/v2alpha1" + + // Filename patterns + filenamePrefixISC = "isc_pinned_" + filenamePrefixDISC = "disc_pinned_" + + // Error messages + errMsgParseCatalog = "failed to parse catalog" + errMsgWriteISC = "failed to write pinned ISC" + errMsgWriteDISC = "failed to write pinned DISC" + nonexistentDirectory = "/nonexistent/directory/path" +) + +func TestPinCatalogDigests_Success(t *testing.T) { + logger := clog.New("trace") + + cfg := v2alpha1.ImageSetConfiguration{ + ImageSetConfigurationSpec: v2alpha1.ImageSetConfigurationSpec{ + Mirror: v2alpha1.Mirror{ + Operators: []v2alpha1.Operator{ + { + Catalog: redhatIndexTag, + }, + }, + }, + }, + } + + catalogMap := map[string]v2alpha1.CatalogFilterResult{ + redhatIndexTagDocker: { + Digest: testDigestShort1, + }, + } + + pinnedCfg, err := pinCatalogDigests(cfg, catalogMap, logger) + require.NoError(t, err) + assert.Equal(t, + redhatIndexBase+"@sha256:"+testDigestShort1, + pinnedCfg.Mirror.Operators[0].Catalog, + ) + + // Verify original config is unchanged + assert.Equal(t, + redhatIndexTag, + cfg.Mirror.Operators[0].Catalog, + ) +} + +func TestPinCatalogDigests_AlreadyPinned(t *testing.T) { + logger := clog.New("trace") + + cfg := v2alpha1.ImageSetConfiguration{ + ImageSetConfigurationSpec: v2alpha1.ImageSetConfigurationSpec{ + Mirror: v2alpha1.Mirror{ + Operators: []v2alpha1.Operator{ + { + Catalog: redhatIndexBase + "@sha256:" + testDigest1, + }, + }, + }, + }, + } + + catalogMap := map[string]v2alpha1.CatalogFilterResult{ + "docker://" + redhatIndexBase + "@sha256:" + testDigest1: { + Digest: "newdigest123", + }, + } + + pinnedCfg, err := pinCatalogDigests(cfg, catalogMap, logger) + require.NoError(t, err) + + // Should remain unchanged since it's already pinned + assert.Equal(t, + redhatIndexBase+"@sha256:"+testDigest1, + pinnedCfg.Mirror.Operators[0].Catalog, + ) +} + +func TestPinCatalogDigests_CatalogNotInMap(t *testing.T) { + logger := clog.New("trace") + + cfg := v2alpha1.ImageSetConfiguration{ + ImageSetConfigurationSpec: v2alpha1.ImageSetConfigurationSpec{ + Mirror: v2alpha1.Mirror{ + Operators: []v2alpha1.Operator{ + { + Catalog: redhatIndexTag, + }, + }, + }, + }, + } + + // Empty catalog map + catalogMap := map[string]v2alpha1.CatalogFilterResult{} + + pinnedCfg, err := pinCatalogDigests(cfg, catalogMap, logger) + require.NoError(t, err) + + // Should remain unchanged when not found in map + assert.Equal(t, + redhatIndexTag, + pinnedCfg.Mirror.Operators[0].Catalog, + ) +} + +func TestPinCatalogDigests_EmptyDigest(t *testing.T) { + logger := clog.New("trace") + + cfg := v2alpha1.ImageSetConfiguration{ + ImageSetConfigurationSpec: v2alpha1.ImageSetConfigurationSpec{ + Mirror: v2alpha1.Mirror{ + Operators: []v2alpha1.Operator{ + { + Catalog: redhatIndexTag, + }, + }, + }, + }, + } + + catalogMap := map[string]v2alpha1.CatalogFilterResult{ + redhatIndexTagDocker: { + Digest: "", // Empty digest + }, + } + + pinnedCfg, err := pinCatalogDigests(cfg, catalogMap, logger) + require.NoError(t, err) + + // Should remain unchanged when digest is empty + assert.Equal(t, + redhatIndexTag, + pinnedCfg.Mirror.Operators[0].Catalog, + ) +} + +func TestPinCatalogDigests_InvalidCatalog(t *testing.T) { + logger := clog.New("trace") + + cfg := v2alpha1.ImageSetConfiguration{ + ImageSetConfigurationSpec: v2alpha1.ImageSetConfigurationSpec{ + Mirror: v2alpha1.Mirror{ + Operators: []v2alpha1.Operator{ + { + // Empty catalog reference should fail parsing + Catalog: "", + }, + }, + }, + }, + } + + catalogMap := map[string]v2alpha1.CatalogFilterResult{} + + _, err := pinCatalogDigests(cfg, catalogMap, logger) + require.Error(t, err) + assert.Contains(t, err.Error(), errMsgParseCatalog) +} + +func TestPinCatalogDigests_NoOperators(t *testing.T) { + logger := clog.New("trace") + + cfg := v2alpha1.ImageSetConfiguration{ + ImageSetConfigurationSpec: v2alpha1.ImageSetConfigurationSpec{ + Mirror: v2alpha1.Mirror{ + Operators: []v2alpha1.Operator{}, + }, + }, + } + + catalogMap := map[string]v2alpha1.CatalogFilterResult{} + + pinnedCfg, err := pinCatalogDigests(cfg, catalogMap, logger) + require.NoError(t, err) + assert.Empty(t, pinnedCfg.Mirror.Operators) +} + +func TestPinCatalogDigests_MultipleOperators(t *testing.T) { + logger := clog.New("trace") + + cfg := v2alpha1.ImageSetConfiguration{ + ImageSetConfigurationSpec: v2alpha1.ImageSetConfigurationSpec{ + Mirror: v2alpha1.Mirror{ + Operators: []v2alpha1.Operator{ + { + Catalog: redhatIndexTag, + }, + { + Catalog: certifiedIndexTag, + }, + { + Catalog: communityIndexBase + "@sha256:" + testDigest2, + }, + }, + }, + }, + } + + catalogMap := map[string]v2alpha1.CatalogFilterResult{ + redhatIndexTagDocker: { + Digest: testDigestShort2, + }, + certifiedIndexTagDocker: { + Digest: testDigestShort3, + }, + } + + pinnedCfg, err := pinCatalogDigests(cfg, catalogMap, logger) + require.NoError(t, err) + + // First operator should be pinned + assert.Equal(t, + redhatIndexBase+"@sha256:"+testDigestShort2, + pinnedCfg.Mirror.Operators[0].Catalog, + ) + + // Second operator should be pinned + assert.Equal(t, + "registry.redhat.io/redhat/certified-operator-index@sha256:"+testDigestShort3, + pinnedCfg.Mirror.Operators[1].Catalog, + ) + + // Third operator already pinned, should remain unchanged + assert.Equal(t, + communityIndexBase+"@sha256:"+testDigest2, + pinnedCfg.Mirror.Operators[2].Catalog, + ) +} + +func TestPinCatalogDigests_OCITransport(t *testing.T) { + logger := clog.New("trace") + + cfg := v2alpha1.ImageSetConfiguration{ + ImageSetConfigurationSpec: v2alpha1.ImageSetConfigurationSpec{ + Mirror: v2alpha1.Mirror{ + Operators: []v2alpha1.Operator{ + { + Catalog: "oci:///path/to/catalog", + }, + }, + }, + }, + } + + catalogMap := map[string]v2alpha1.CatalogFilterResult{ + "oci:///path/to/catalog": { + Digest: "ocidigests123", + }, + } + + pinnedCfg, err := pinCatalogDigests(cfg, catalogMap, logger) + require.NoError(t, err) + + // OCI transport should be preserved + assert.Equal(t, + "oci:///path/to/catalog@sha256:ocidigests123", + pinnedCfg.Mirror.Operators[0].Catalog, + ) +} + +func TestWritePinnedISC_Success(t *testing.T) { + tmpDir := t.TempDir() + + cfg := v2alpha1.ImageSetConfiguration{ + ImageSetConfigurationSpec: v2alpha1.ImageSetConfigurationSpec{ + Mirror: v2alpha1.Mirror{ + Operators: []v2alpha1.Operator{ + { + Catalog: redhatIndexBase + "@sha256:" + testDigest1, + }, + }, + }, + }, + } + + filePath, err := writePinnedISC(cfg, tmpDir) + require.NoError(t, err) + + // Verify file was created + assert.FileExists(t, filePath) + + // Verify file is in the correct directory + assert.Equal(t, tmpDir, filepath.Dir(filePath)) + + // Verify filename format + filename := filepath.Base(filePath) + assert.True(t, strings.HasPrefix(filename, filenamePrefixISC)) + assert.True(t, strings.HasSuffix(filename, ".yaml")) + + // Verify file contents can be read back + data, err := os.ReadFile(filePath) + require.NoError(t, err) + + var loadedCfg v2alpha1.ImageSetConfiguration + err = yaml.Unmarshal(data, &loadedCfg) + require.NoError(t, err) + + // Verify TypeMeta is set + assert.Equal(t, kindISC, loadedCfg.Kind) + assert.Equal(t, apiVersion, loadedCfg.APIVersion) + + // Verify operator catalog is preserved + assert.Equal(t, + redhatIndexBase+"@sha256:"+testDigest1, + loadedCfg.Mirror.Operators[0].Catalog, + ) +} + +func TestWritePinnedISC_InvalidDirectory(t *testing.T) { + cfg := v2alpha1.ImageSetConfiguration{} + + // Use a non-existent directory + _, err := writePinnedISC(cfg, nonexistentDirectory) + require.Error(t, err) + assert.Contains(t, err.Error(), errMsgWriteISC) +} + +func TestPinAndWriteConfigs(t *testing.T) { + tmpDir := t.TempDir() + logger := clog.New("trace") + + cfg := v2alpha1.ImageSetConfiguration{ + ImageSetConfigurationSpec: v2alpha1.ImageSetConfigurationSpec{ + Mirror: v2alpha1.Mirror{ + Operators: []v2alpha1.Operator{ + { + Catalog: redhatIndexTag, + }, + }, + }, + }, + } + + catalogMap := map[string]v2alpha1.CatalogFilterResult{ + redhatIndexTagDocker: { + Digest: testDigestShort1, + }, + } + + iscPath, discPath, err := PinAndWriteISCAndDSC(cfg, catalogMap, tmpDir, logger) + require.NoError(t, err) + assert.FileExists(t, iscPath) + assert.FileExists(t, discPath) + + // Verify ISC file + iscData, err := os.ReadFile(iscPath) + require.NoError(t, err) + var loadedISC v2alpha1.ImageSetConfiguration + err = yaml.Unmarshal(iscData, &loadedISC) + require.NoError(t, err) + assert.Equal(t, kindISC, loadedISC.Kind) + assert.Equal(t, + redhatIndexBase+"@sha256:"+testDigestShort1, + loadedISC.Mirror.Operators[0].Catalog, + ) + + // Verify DISC file + discData, err := os.ReadFile(discPath) + require.NoError(t, err) + var loadedDISC v2alpha1.DeleteImageSetConfiguration + err = yaml.Unmarshal(discData, &loadedDISC) + require.NoError(t, err) + assert.Equal(t, kindDISC, loadedDISC.Kind) + assert.Equal(t, + redhatIndexBase+"@sha256:"+testDigestShort1, + loadedDISC.Delete.Operators[0].Catalog, + ) +} + +func TestCopyISC(t *testing.T) { + original := v2alpha1.ImageSetConfiguration{ + ImageSetConfigurationSpec: v2alpha1.ImageSetConfigurationSpec{ + Mirror: v2alpha1.Mirror{ + Operators: []v2alpha1.Operator{ + { + Catalog: redhatIndexTag, + }, + }, + }, + }, + } + + copied := copyISC(original) + + // Modify the copied version + copied.Mirror.Operators[0].Catalog = "modified-catalog" + + // Verify original is unchanged + assert.Equal(t, + redhatIndexTag, + original.Mirror.Operators[0].Catalog, + ) + + // Verify copied has the modification + assert.Equal(t, + "modified-catalog", + copied.Mirror.Operators[0].Catalog, + ) +} + +func TestCreateDISCFromISC_Success(t *testing.T) { + tmpDir := t.TempDir() + + // Create a pinned ISC (catalogs already have digests) + pinnedISC := v2alpha1.ImageSetConfiguration{ + ImageSetConfigurationSpec: v2alpha1.ImageSetConfigurationSpec{ + Mirror: v2alpha1.Mirror{ + Operators: []v2alpha1.Operator{ + { + Catalog: redhatIndexBase + "@sha256:" + testDigestShort1, + }, + }, + }, + }, + } + + filePath, err := createDISCFromISC(pinnedISC, tmpDir) + require.NoError(t, err) + assert.FileExists(t, filePath) + + // Verify filename format + filename := filepath.Base(filePath) + assert.True(t, strings.HasPrefix(filename, filenamePrefixDISC)) + assert.True(t, strings.HasSuffix(filename, ".yaml")) + + // Read back and verify + data, err := os.ReadFile(filePath) + require.NoError(t, err) + + var loadedDISC v2alpha1.DeleteImageSetConfiguration + err = yaml.Unmarshal(data, &loadedDISC) + require.NoError(t, err) + + // Verify TypeMeta + assert.Equal(t, kindDISC, loadedDISC.Kind) + assert.Equal(t, apiVersion, loadedDISC.APIVersion) + + // Verify operator catalog is pinned (copied from pinnedISC) + assert.Equal(t, + redhatIndexBase+"@sha256:"+testDigestShort1, + loadedDISC.Delete.Operators[0].Catalog, + ) +} + +func TestCreateDISCFromISC_MultipleOperators(t *testing.T) { + tmpDir := t.TempDir() + + // Create a pinned ISC (all catalogs already have digests) + pinnedISC := v2alpha1.ImageSetConfiguration{ + ImageSetConfigurationSpec: v2alpha1.ImageSetConfigurationSpec{ + Mirror: v2alpha1.Mirror{ + Operators: []v2alpha1.Operator{ + { + Catalog: redhatIndexBase + "@sha256:" + testDigestShort2, + }, + { + Catalog: "registry.redhat.io/redhat/certified-operator-index@sha256:" + testDigestShort3, + }, + { + Catalog: communityIndexBase + "@sha256:" + testDigest2, + }, + }, + }, + }, + } + + filePath, err := createDISCFromISC(pinnedISC, tmpDir) + require.NoError(t, err) + + // Read back and verify + data, err := os.ReadFile(filePath) + require.NoError(t, err) + + var loadedDISC v2alpha1.DeleteImageSetConfiguration + err = yaml.Unmarshal(data, &loadedDISC) + require.NoError(t, err) + + // All operators should be pinned (copied from pinnedISC) + assert.Equal(t, + redhatIndexBase+"@sha256:"+testDigestShort2, + loadedDISC.Delete.Operators[0].Catalog, + ) + assert.Equal(t, + "registry.redhat.io/redhat/certified-operator-index@sha256:"+testDigestShort3, + loadedDISC.Delete.Operators[1].Catalog, + ) + assert.Equal(t, + communityIndexBase+"@sha256:"+testDigest2, + loadedDISC.Delete.Operators[2].Catalog, + ) +} + +func TestCreateDISCFromISC_AllSections(t *testing.T) { + tmpDir := t.TempDir() + + // Create a pinned ISC with all sections + pinnedISC := v2alpha1.ImageSetConfiguration{ + ImageSetConfigurationSpec: v2alpha1.ImageSetConfigurationSpec{ + Mirror: v2alpha1.Mirror{ + Platform: v2alpha1.Platform{ + Channels: []v2alpha1.ReleaseChannel{ + { + Name: "stable-4.12", + }, + }, + }, + Operators: []v2alpha1.Operator{ + { + Catalog: redhatIndexBase + "@sha256:" + testDigestShort4, + }, + }, + AdditionalImages: []v2alpha1.Image{ + { + Name: "quay.io/example/image:latest", + }, + }, + Helm: v2alpha1.Helm{ + Repositories: []v2alpha1.Repository{ + { + Name: "my-repo", + URL: "https://example.com/charts", + }, + }, + }, + }, + }, + } + + filePath, err := createDISCFromISC(pinnedISC, tmpDir) + require.NoError(t, err) + + // Read back and verify + data, err := os.ReadFile(filePath) + require.NoError(t, err) + + var loadedDISC v2alpha1.DeleteImageSetConfiguration + err = yaml.Unmarshal(data, &loadedDISC) + require.NoError(t, err) + + // Verify all sections are copied + assert.Equal(t, "stable-4.12", loadedDISC.Delete.Platform.Channels[0].Name) + assert.Equal(t, redhatIndexBase+"@sha256:"+testDigestShort4, loadedDISC.Delete.Operators[0].Catalog) + assert.Equal(t, "quay.io/example/image:latest", loadedDISC.Delete.AdditionalImages[0].Name) + assert.Equal(t, "my-repo", loadedDISC.Delete.Helm.Repositories[0].Name) +} + +func TestCreateDISCFromISC_InvalidDirectory(t *testing.T) { + pinnedISC := v2alpha1.ImageSetConfiguration{ + ImageSetConfigurationSpec: v2alpha1.ImageSetConfigurationSpec{ + Mirror: v2alpha1.Mirror{ + Operators: []v2alpha1.Operator{ + { + Catalog: redhatIndexBase + "@sha256:" + testDigestShort4, + }, + }, + }, + }, + } + + // Use a non-existent directory + _, err := createDISCFromISC(pinnedISC, nonexistentDirectory) + require.Error(t, err) + assert.Contains(t, err.Error(), errMsgWriteDISC) +} diff --git a/internal/pkg/operator/common.go b/internal/pkg/operator/common.go index c07fd943e..d47ac89d9 100644 --- a/internal/pkg/operator/common.go +++ b/internal/pkg/operator/common.go @@ -88,12 +88,16 @@ func (o OperatorCollector) catalogDigest(ctx context.Context, catalog v2alpha1.O return "", fmt.Errorf("unable to determine cached reference for catalog: %w", err) } + // If the catalog is specified by digest, return it directly. + // No need to query the cache - the digest is already known from the ISC. + if srcImgSpec.IsImageByDigestOnly() { + return srcImgSpec.Digest, nil + } + var tag string switch { case len(catalog.TargetTag) > 0: // applies only to catalogs tag = catalog.TargetTag - case srcImgSpec.Tag == "" && srcImgSpec.Digest != "": - tag = fmt.Sprintf("%s-%s", srcImgSpec.Algorithm, srcImgSpec.Digest) case srcImgSpec.Tag == "" && srcImgSpec.Digest == "" && srcImgSpec.Transport == ociProtocol: tag = latestTag default: diff --git a/internal/pkg/operator/filtered_collector.go b/internal/pkg/operator/filtered_collector.go index c262148f9..ca57dd370 100644 --- a/internal/pkg/operator/filtered_collector.go +++ b/internal/pkg/operator/filtered_collector.go @@ -129,11 +129,23 @@ func createFolders(paths []string) error { return errors.Join(errs...) } -func digestOfFilter(catalog v2alpha1.Operator) (string, error) { +func digestOfFilter(catalog v2alpha1.Operator, catalogDigest string) (string, error) { c := catalog c.TargetCatalog = "" c.TargetTag = "" c.TargetCatalogSourceTemplate = "" + // CLID-513: Normalize catalog reference to ensure consistent rebuiltTag + // whether catalog is specified by tag or digest in the ISC. + // Use catalog name + digest to ensure: + // - Same catalog with same filter → same rebuiltTag (both v4.18 and the corresponding digest produce same hash) + // - Different catalog versions with same filter → different rebuiltTag (v4.18 and v4.19 produce different hashes) + if c.Catalog != "" && catalogDigest != "" { + imgSpec, err := image.ParseRef(c.Catalog) + if err == nil { + // Normalize to: name@sha256:digest + c.Catalog = imgSpec.Name + "@sha256:" + catalogDigest + } + } pkgs, err := json.Marshal(c) if err != nil { return "", err @@ -251,7 +263,7 @@ func (o FilterCollector) collectOperator( //nolint:cyclop // TODO: this needs fu rebuiltTag := "" if !isFullCatalog(op) { - tag, err := digestOfFilter(op) + tag, err := digestOfFilter(op, catalogDigest) if err != nil { return v2alpha1.CatalogFilterResult{}, err } @@ -297,7 +309,7 @@ func (o FilterCollector) filterOperator(ctx context.Context, op v2alpha1.Operato imageIndexDir := filepath.Join(o.Opts.Global.WorkingDir, operatorCatalogsDir, imgSpec.ComponentName(), catalogDigest) filteredCatalogsDir := filepath.Join(imageIndexDir, operatorCatalogFilteredDir) - filterDigest, err := digestOfFilter(op) + filterDigest, err := digestOfFilter(op, catalogDigest) if err != nil { return v2alpha1.CatalogFilterResult{}, err } diff --git a/internal/pkg/operator/filtered_collector_test.go b/internal/pkg/operator/filtered_collector_test.go index b7db39d74..b7dc45fa4 100644 --- a/internal/pkg/operator/filtered_collector_test.go +++ b/internal/pkg/operator/filtered_collector_test.go @@ -358,21 +358,21 @@ func TestFilterCollectorM2D(t *testing.T) { Destination: "docker://localhost:9999/certified-operators:v4.7", Origin: "docker://certified-operators:v4.7", Type: v2alpha1.TypeOperatorCatalog, - RebuiltTag: "442c7ba64d56a85eea155325aa0c6537", + RebuiltTag: "70eb0b2116707316c6130de415ceeb69", }, { Source: "docker://community-operators:v4.7", Destination: "docker://localhost:9999/community-operators:v4.7", Origin: "docker://community-operators:v4.7", Type: v2alpha1.TypeOperatorCatalog, - RebuiltTag: "4dab2467f35b4d9c9ba7c2a7823de8bd", + RebuiltTag: "ac8e314872a499f2c6edb0616489c628", }, { Source: "oci://" + filepath.Join(testDir, "simple-test-bundle"), Destination: "docker://localhost:9999/simple-test-bundle:latest", Origin: "oci://" + filepath.Join(testDir, "simple-test-bundle"), Type: v2alpha1.TypeOperatorCatalog, - RebuiltTag: "9fadc6c70adb4b2571f66f674a876279", + RebuiltTag: "eaf28fd0a9f205e44fb52a8b0bd8e678", }, }, }, @@ -404,7 +404,7 @@ func TestFilterCollectorM2D(t *testing.T) { Destination: "docker://localhost:9999/simple-test-bundle:v4.14", Origin: "oci://" + filepath.Join(testDir, "simple-test-bundle"), Type: v2alpha1.TypeOperatorCatalog, - RebuiltTag: "9fadc6c70adb4b2571f66f674a876279", + RebuiltTag: "eaf28fd0a9f205e44fb52a8b0bd8e678", }, }, }, @@ -442,7 +442,7 @@ func TestFilterCollectorM2D(t *testing.T) { Destination: "docker://localhost:9999/test-catalog:v4.14", Origin: "oci://" + filepath.Join(testDir, "simple-test-bundle"), Type: v2alpha1.TypeOperatorCatalog, - RebuiltTag: "9fadc6c70adb4b2571f66f674a876279", + RebuiltTag: "eaf28fd0a9f205e44fb52a8b0bd8e678", }, }, }, @@ -474,7 +474,7 @@ func TestFilterCollectorM2D(t *testing.T) { Destination: "docker://localhost:9999/test-namespace/test-catalog:v2.0", Origin: "docker://certified-operators:v4.7", Type: v2alpha1.TypeOperatorCatalog, - RebuiltTag: "442c7ba64d56a85eea155325aa0c6537", + RebuiltTag: "70eb0b2116707316c6130de415ceeb69", }, }, }, @@ -553,18 +553,18 @@ func TestFilterCollectorD2M(t *testing.T) { Type: v2alpha1.TypeInvalid, }, { - Source: "docker://localhost:9999/simple-test-bundle:9fadc6c70adb4b2571f66f674a876279", + Source: "docker://localhost:9999/simple-test-bundle:eaf28fd0a9f205e44fb52a8b0bd8e678", Destination: "docker://localhost:5000/test/simple-test-bundle:latest", Origin: "oci://" + filepath.Join(testDir, "simple-test-bundle"), Type: v2alpha1.TypeOperatorCatalog, - RebuiltTag: "9fadc6c70adb4b2571f66f674a876279", + RebuiltTag: "eaf28fd0a9f205e44fb52a8b0bd8e678", }, { - Source: "docker://localhost:9999/redhat/redhat-operator-index:6566d78129230a2e107cb5aafcb7787b", + Source: "docker://localhost:9999/redhat/redhat-operator-index:94563f14d54e0ea1d600fa8c002c204b", Destination: "docker://localhost:5000/test/redhat/redhat-operator-index:v4.14", Origin: "docker://registry.redhat.io/redhat/redhat-operator-index:v4.14", Type: v2alpha1.TypeOperatorCatalog, - RebuiltTag: "6566d78129230a2e107cb5aafcb7787b", + RebuiltTag: "94563f14d54e0ea1d600fa8c002c204b", }, }, }, @@ -597,11 +597,11 @@ func TestFilterCollectorD2M(t *testing.T) { Type: v2alpha1.TypeInvalid, }, { - Source: "docker://localhost:9999/test-catalog:9fadc6c70adb4b2571f66f674a876279", + Source: "docker://localhost:9999/test-catalog:eaf28fd0a9f205e44fb52a8b0bd8e678", Destination: "docker://localhost:5000/test/test-catalog:v4.14", Origin: "oci://" + filepath.Join(testDir, "simple-test-bundle"), Type: v2alpha1.TypeOperatorCatalog, - RebuiltTag: "9fadc6c70adb4b2571f66f674a876279", + RebuiltTag: "eaf28fd0a9f205e44fb52a8b0bd8e678", }, }, }, @@ -715,71 +715,71 @@ func TestFilterCollectorM2M(t *testing.T) { Destination: "docker://localhost:9999/redhat/redhat-filtered-index:v4.17", Origin: "docker://registry.redhat.io/redhat/redhat-operator-index:v4.17", Type: v2alpha1.TypeOperatorCatalog, - RebuiltTag: "08a5610c0e6f72fd34b1c76d30788c66", + RebuiltTag: "b6db5253b0a8b995840d4d6b5a8aefca", }, { Source: "docker://registry.redhat.io/redhat/certified-operators:v4.17", Destination: "docker://localhost:9999/redhat/certified-operators-pinned:v4.17.0-20241114", Origin: "docker://registry.redhat.io/redhat/certified-operators:v4.17", Type: v2alpha1.TypeOperatorCatalog, - RebuiltTag: "65af60f894902a1758a30ae262c0e39e", + RebuiltTag: "37e8b17cf0089fb1de93893cfb41dbfb", }, { Source: "oci://" + filepath.Join(testDir, "catalog-on-disk1"), Destination: "docker://localhost:9999/catalog-on-disk1:latest", Origin: "oci://" + filepath.Join(testDir, "catalog-on-disk1"), Type: v2alpha1.TypeOperatorCatalog, - RebuiltTag: "fc2e113a1d6f0dbe89bd2bc5c83886e3", + RebuiltTag: "bff06b6d6cc99438ad7a080e38025b52", }, { Source: "oci://" + filepath.Join(testDir, "catalog-on-disk2"), Destination: "docker://localhost:9999/coffee-shop-index:latest", Origin: "oci://" + filepath.Join(testDir, "catalog-on-disk2"), Type: v2alpha1.TypeOperatorCatalog, - RebuiltTag: "421035ded2cb0e83f50ee6445b1466a5", + RebuiltTag: "04a29cd46d562afadfa317467451756e", }, { Source: "oci://" + filepath.Join(testDir, "catalog-on-disk3"), Destination: "docker://localhost:9999/tea-shop-index:v3.14", Origin: "oci://" + filepath.Join(testDir, "catalog-on-disk3"), Type: v2alpha1.TypeOperatorCatalog, - RebuiltTag: "d81a7ad49cabfc8aa050edaf56f25a3f", + RebuiltTag: "4b3bae8f9360ced2d4a4473d5481cc9f", }, { - Source: "docker://localhost:9999/redhat/redhat-filtered-index:08a5610c0e6f72fd34b1c76d30788c66", + Source: "docker://localhost:9999/redhat/redhat-filtered-index:b6db5253b0a8b995840d4d6b5a8aefca", Destination: "docker://localhost:5000/test/redhat/redhat-filtered-index:v4.17", Origin: "docker://registry.redhat.io/redhat/redhat-operator-index:v4.17", Type: v2alpha1.TypeOperatorCatalog, - RebuiltTag: "08a5610c0e6f72fd34b1c76d30788c66", + RebuiltTag: "b6db5253b0a8b995840d4d6b5a8aefca", }, { - Source: "docker://localhost:9999/redhat/certified-operators-pinned:65af60f894902a1758a30ae262c0e39e", + Source: "docker://localhost:9999/redhat/certified-operators-pinned:37e8b17cf0089fb1de93893cfb41dbfb", Destination: "docker://localhost:5000/test/redhat/certified-operators-pinned:v4.17.0-20241114", Origin: "docker://registry.redhat.io/redhat/certified-operators:v4.17", Type: v2alpha1.TypeOperatorCatalog, - RebuiltTag: "65af60f894902a1758a30ae262c0e39e", + RebuiltTag: "37e8b17cf0089fb1de93893cfb41dbfb", }, { - Source: "docker://localhost:9999/catalog-on-disk1:fc2e113a1d6f0dbe89bd2bc5c83886e3", + Source: "docker://localhost:9999/catalog-on-disk1:bff06b6d6cc99438ad7a080e38025b52", Destination: "docker://localhost:5000/test/catalog-on-disk1:latest", Origin: "oci://" + filepath.Join(testDir, "catalog-on-disk1"), Type: v2alpha1.TypeOperatorCatalog, - RebuiltTag: "fc2e113a1d6f0dbe89bd2bc5c83886e3", + RebuiltTag: "bff06b6d6cc99438ad7a080e38025b52", }, { - Source: "docker://localhost:9999/coffee-shop-index:421035ded2cb0e83f50ee6445b1466a5", + Source: "docker://localhost:9999/coffee-shop-index:04a29cd46d562afadfa317467451756e", Destination: "docker://localhost:5000/test/coffee-shop-index:latest", Origin: "oci://" + filepath.Join(testDir, "catalog-on-disk2"), Type: v2alpha1.TypeOperatorCatalog, - RebuiltTag: "421035ded2cb0e83f50ee6445b1466a5", + RebuiltTag: "04a29cd46d562afadfa317467451756e", }, { - Source: "docker://localhost:9999/tea-shop-index:d81a7ad49cabfc8aa050edaf56f25a3f", + Source: "docker://localhost:9999/tea-shop-index:4b3bae8f9360ced2d4a4473d5481cc9f", Destination: "docker://localhost:5000/test/tea-shop-index:v3.14", Origin: "oci://" + filepath.Join(testDir, "catalog-on-disk3"), Type: v2alpha1.TypeOperatorCatalog, - RebuiltTag: "d81a7ad49cabfc8aa050edaf56f25a3f", + RebuiltTag: "4b3bae8f9360ced2d4a4473d5481cc9f", }, }, },