Skip to content

Add ability to sync specific architectures. #3128

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 9 commits into
base: main
Choose a base branch
from
24 changes: 24 additions & 0 deletions examples/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -1017,6 +1017,30 @@ Configure each registry sync:
```
Prefixes can be strings that exactly match repositories or they can be [glob](https://en.wikipedia.org/wiki/Glob_(programming)) patterns.

### Platform Filtering in Sync

You can selectively sync multi-architecture images by specifying which platforms to include:

```
"platforms": ["amd64", "arm64", "linux/amd64", "linux/arm64", "linux/arm/v7"]
```

The platforms field accepts three formats:

1. Architecture-only format: `"amd64"`, `"arm64"`, `"arm"`, etc.
2. OS/Architecture format: `"linux/amd64"`, `"windows/amd64"`, etc.
3. OS/Architecture/Variant format: `"linux/arm/v7"`, `"linux/arm/v8"`, etc.

This is particularly useful for ARM architectures where variants like "v6", "v7", and "v8" are common.

Example configuration with variant filtering:

```json
"platforms": ["linux/amd64", "linux/arm64", "linux/arm/v7"]
```

This would sync only manifests for Linux AMD64, Linux ARM64, and Linux ARM v7 architectures, saving bandwidth and storage by excluding other architectures and variants.

### Sync's certDir option

sync uses the same logic for reading cert directory as docker: https://docs.docker.com/engine/security/certificates/#understand-the-configuration
Expand Down
109 changes: 109 additions & 0 deletions examples/config-sync-platforms.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
{
"distSpecVersion": "1.1.1",
"storage": {
"rootDirectory": "/tmp/zot"
},
"http": {
"address": "127.0.0.1",
"port": "8080"
},
"log": {
"level": "debug"
},
"extensions": {
"sync": {
"enable": true,
"credentialsFile": "./examples/sync-auth-filepath.json",
"registries": [
{
"urls": [
"https://registry1:5000"
],
"onDemand": false,
"pollInterval": "6h",
"tlsVerify": true,
"certDir": "/home/user/certs",
"maxRetries": 3,
"retryDelay": "5m",
"onlySigned": true,
"platforms": ["amd64", "arm64", "linux/amd64", "linux/arm64", "linux/arm/v7"],
"content": [
{
"prefix": "/repo1/repo",
"tags": {
"regex": "4.*",
"semver": true
}
},
{
"prefix": "/repo2/repo",
"destination": "/repo",
"stripPrefix": true
},
{
"prefix": "/repo3/**"
},
{
"prefix": "/repo4/**",
"tags": {
"excludeRegex": ".*-(amd64|arm64)$"
}
}
]
},
{
"urls": [
"https://registry2:5000",
"https://registry3:5000"
],
"pollInterval": "12h",
"tlsVerify": false,
"onDemand": false,
"platforms": ["amd64", "linux/amd64", "windows/amd64"],
"content": [
{
"prefix": "**",
"tags": {
"semver": true
}
}
]
},
{
"urls": [
"https://index.docker.io"
],
"onDemand": true,
"tlsVerify": true,
"maxRetries": 5,
"retryDelay": "30s",
"platforms": ["amd64", "arm64", "arm", "linux/amd64", "linux/arm64", "linux/arm/v7", "linux/arm/v8"]
},
{
"urls": [
"https://demo.goharbor.io"
],
"pollInterval": "12h",
"content": [
{
"prefix": "zot/**"
}
],
"onDemand": true,
"tlsVerify": true,
"maxRetries": 5,
"retryDelay": "1m",
"platforms": ["darwin/amd64", "linux/amd64", "linux/arm64", "linux/arm/v7"]
},
{
"urls": [
"https://registry5:5000"
],
"onDemand": false,
"tlsVerify": true,
"platforms": ["linux/amd64", "amd64", "arm64", "linux/arm/v6", "linux/arm/v7", "linux/arm/v8"]
}
]
}
}
}
3 changes: 2 additions & 1 deletion pkg/extensions/config/sync/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,8 @@ type RegistryConfig struct {
RetryDelay *time.Duration
OnlySigned *bool
CredentialHelper string
PreserveDigest bool // sync without converting
PreserveDigest bool // sync without converting
Platforms []string `mapstructure:",omitempty"` // filter platforms during sync (supports both "arch" and "os/arch" formats)
}

type Content struct {
Expand Down
156 changes: 154 additions & 2 deletions pkg/extensions/sync/destination.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import (

zerr "zotregistry.dev/zot/errors"
"zotregistry.dev/zot/pkg/common"
syncconf "zotregistry.dev/zot/pkg/extensions/config/sync"
"zotregistry.dev/zot/pkg/extensions/monitoring"
"zotregistry.dev/zot/pkg/log"
"zotregistry.dev/zot/pkg/meta"
Expand All @@ -29,26 +30,102 @@ import (
storageTypes "zotregistry.dev/zot/pkg/storage/types"
)

// Platform represents an OS/architecture/variant combination
type Platform struct {
OS string
Architecture string
Variant string
}

// ParsePlatform parses a platform string into a Platform struct
// The string can be in the following formats:
// - "arch" (e.g., "amd64")
// - "os/arch" (e.g., "linux/amd64")
// - "os/arch/variant" (e.g., "linux/arm/v7")
func ParsePlatform(platform string) Platform {
Copy link
Contributor

Choose a reason for hiding this comment

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

Ok, instead of a nested map, it is a flat string with a '/' separator

parts := strings.Split(platform, "/")
if len(parts) == 3 {
return Platform{
OS: parts[0],
Architecture: parts[1],
Variant: parts[2],
}
} else if len(parts) == 2 {
return Platform{
OS: parts[0],
Architecture: parts[1],
}
}
// For any other case, assume only architecture is specified
return Platform{
OS: "",
Architecture: platform,
}
}

// MatchesPlatform checks if the given platform matches any of the platform specifications
// Platform specs can be in format "os/arch/variant", "os/arch", or just "arch"
func MatchesPlatform(platform *ispec.Platform, platformSpecs []string) bool {
if platform == nil || len(platformSpecs) == 0 {
return true
}

for _, spec := range platformSpecs {
specPlatform := ParsePlatform(spec)

// Check if architecture matches
if specPlatform.Architecture != "" &&
specPlatform.Architecture != platform.Architecture {
continue
}

// Check if OS matches (if specified)
if specPlatform.OS != "" &&
specPlatform.OS != platform.OS {
continue
}

// Check if variant matches (if specified)
if specPlatform.Variant != "" && platform.Variant != "" &&
specPlatform.Variant != platform.Variant {
continue
}

// If we got here, it's a match
return true
}

return false
}

type DestinationRegistry struct {
storeController storage.StoreController
tempStorage OciLayoutStorage
metaDB mTypes.MetaDB
log log.Logger
config *syncconf.RegistryConfig // Config used for filtering architectures
}

func NewDestinationRegistry(
storeController storage.StoreController, // local store controller
tempStoreController storage.StoreController, // temp store controller
metaDB mTypes.MetaDB,
log log.Logger,
config ...*syncconf.RegistryConfig, // optional config for filtering
Copy link
Contributor

Choose a reason for hiding this comment

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

Why is this optional?

) Destination {
var cfg *syncconf.RegistryConfig
if len(config) > 0 {
cfg = config[0]
}

return &DestinationRegistry{
storeController: storeController,
tempStorage: NewOciLayoutStorage(tempStoreController),
metaDB: metaDB,
// first we sync from remote (using containers/image copy from docker:// to oci:) to a temp imageStore
// then we copy the image from tempStorage to zot's storage using ImageStore APIs
log: log,
log: log,
config: cfg,
}
}

Expand Down Expand Up @@ -227,7 +304,63 @@ func (registry *DestinationRegistry) copyManifest(repo string, desc ispec.Descri
return err
}

for _, manifest := range indexManifest.Manifests {
// Filter manifests based on platforms/architectures if configured
var filteredManifests []ispec.Descriptor

// Determine which platform specifications to use
var platformSpecs []string
if registry.config != nil {
if len(registry.config.Platforms) > 0 {
platformSpecs = registry.config.Platforms
registry.log.Info().
Strs("platforms", registry.config.Platforms).
Str("repository", repo).
Str("reference", reference).
Msg("filtering manifest list by platforms")
}
}

// Apply filtering if we have platform specifications
if len(platformSpecs) > 0 {
for _, manifest := range indexManifest.Manifests {
if manifest.Platform != nil {
// Check if this platform should be included
if MatchesPlatform(manifest.Platform, platformSpecs) {
filteredManifests = append(filteredManifests, manifest)
} else {
platformDesc := manifest.Platform.Architecture
if manifest.Platform.OS != "" {
platformDesc = manifest.Platform.OS + "/" + manifest.Platform.Architecture
if manifest.Platform.Variant != "" {
platformDesc += "/" + manifest.Platform.Variant
}
}

registry.log.Info().
Str("repository", repo).
Str("platform", platformDesc).
Msg("skipping platform during sync")
}
} else {
// No platform info, include the manifest
filteredManifests = append(filteredManifests, manifest)
}
}

// If we have no filtered manifests but had original ones, warn
if len(filteredManifests) == 0 && len(indexManifest.Manifests) > 0 {
registry.log.Warn().
Str("repository", repo).
Str("reference", reference).
Msg("no platform matched the configured filters, manifest list might be empty")
}
} else {
// No filtering, use all manifests
filteredManifests = indexManifest.Manifests
}

// Process the filtered manifests
for _, manifest := range filteredManifests {
reference := GetDescriptorReference(manifest)

manifestBuf, err := tempImageStore.GetBlobContent(repo, manifest.Digest)
Expand All @@ -254,6 +387,25 @@ func (registry *DestinationRegistry) copyManifest(repo string, desc ispec.Descri
}
}

// If we've filtered the manifest list, we need to update it
if registry.config != nil &&
len(registry.config.Platforms) > 0 &&
len(filteredManifests) != len(indexManifest.Manifests) && len(filteredManifests) > 0 {
// Create a new index with the filtered manifests
indexManifest.Manifests = filteredManifests

// Update the manifest content with the filtered list
updatedContent, err := json.Marshal(indexManifest)
if err != nil {
registry.log.Error().Str("errorType", common.TypeOf(err)).
Err(err).Str("repository", repo).
Msg("failed to marshal updated index manifest")
return err
}

manifestContent = updatedContent
Copy link
Contributor

Choose a reason for hiding this comment

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

Note this will break any referrer pointing to the original index digest.
Unless the same digest is kept in the filename, but a different one would be computed based on the new filename, are we doing this?

}

_, _, err := imageStore.PutImageManifest(repo, reference, desc.MediaType, manifestContent)
if err != nil {
registry.log.Error().Str("errorType", common.TypeOf(err)).Str("repo", repo).Str("reference", reference).
Expand Down
Loading