Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
21 changes: 21 additions & 0 deletions internal/pkg/cli/executor.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
}
}
181 changes: 181 additions & 0 deletions internal/pkg/config/pin_catalogs.go
Original file line number Diff line number Diff line change
@@ -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 {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When writing the file should we allow others to read? Probably it is not mandatory that the user who wrote the file is the same who is going to read it in another phase.

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
}
Loading