Skip to content

Commit 1486b67

Browse files
authored
feat(mirror): add $registry placeholder for repoPrefix templating (#208)
* feat(mirror): add $registry placeholder for repoPrefix templating * fix(mirror): fix nil pointer in $registry integration test
1 parent 3b451d0 commit 1486b67

File tree

3 files changed

+110
-0
lines changed

3 files changed

+110
-0
lines changed

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -174,6 +174,7 @@ When a `repoPrefix` is configured (via config file or environment variables), th
174174
- `$podname` — Name of the owning resource (or Pod when available).
175175
- `$container_name` — Container name that uses the image.
176176
- `$arch` — Architecture of the mirrored image. When `digestPull` is enabled this is the architecture of the selected manifest (for example `amd64`). When mirroring a manifest list, the placeholder expands to a hyphen-separated list of all mirrored architectures (for example `386-amd64-arm64-ppc64le-riscv64-s390x`). If copycat cannot determine the architecture it leaves the segment blank.
177+
- `$registry` — Source registry of the image (for example `ghcr.io` or `quay.io`). Images from Docker Hub (including short names like `nginx`) are normalised to `docker.io`.
177178

178179
For example, setting `repoPrefix: "$namespace/$podname"` keeps target repositories unique across namespaces even when multiple workloads reference the same source image. To separate images by architecture you can combine placeholders:
179180

internal/mirror/pusher.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ type Metadata struct {
5252
Architecture string
5353
OS string
5454
ImageID string
55+
Registry string
5556
}
5657

5758
type platformSpec struct {
@@ -348,6 +349,16 @@ func (p *pusher) Mirror(ctx context.Context, src string, meta Metadata) error {
348349
return fmt.Errorf("parse source: %w", err)
349350
}
350351

352+
// Populate source registry in metadata for repoPrefix templating.
353+
if meta.Registry == "" {
354+
reg := srcRef.Context().RegistryStr()
355+
// Normalize Docker Hub registry to docker.io.
356+
if reg == name.DefaultRegistry {
357+
reg = "docker.io"
358+
}
359+
meta.Registry = reg
360+
}
361+
351362
// Build target repo path
352363
srcRepo := srcRef.Context().RepositoryStr()
353364
repo := p.resolveRepoPath(srcRepo, meta)
@@ -1459,6 +1470,7 @@ func expandRepoPrefix(prefix string, meta Metadata) string {
14591470
"$podname", meta.PodName,
14601471
"$container_name", meta.ContainerName,
14611472
"$arch", meta.Architecture,
1473+
"$registry", meta.Registry,
14621474
)
14631475
expanded := replacer.Replace(prefix)
14641476
expanded = strings.TrimSpace(expanded)

internal/mirror/pusher_test.go

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,19 @@ func TestResolveRepoPathWithArchitectureMetadata(t *testing.T) {
188188
}
189189
}
190190

191+
func TestResolveRepoPathWithRegistryMetadata(t *testing.T) {
192+
p := &pusher{
193+
target: fakeTarget{prefix: "$registry/$namespace"},
194+
transform: util.CleanRepoName,
195+
}
196+
197+
repo := p.resolveRepoPath("library/nginx", Metadata{Namespace: "prod", Registry: "ghcr.io"})
198+
want := "ghcr.io/prod/library/nginx"
199+
if repo != want {
200+
t.Fatalf("expected %q, got %q", want, repo)
201+
}
202+
}
203+
191204
func TestDryPullOption(t *testing.T) {
192205
p := NewPusher(fakeTarget{}, true, true, nil, testr.New(t), nil, 0, 0, false, true, nil, nil)
193206

@@ -514,6 +527,66 @@ func TestMirrorSkipsExcludedRegistry(t *testing.T) {
514527
}
515528
}
516529

530+
func TestMirrorPopulatesRegistryMetadataFromSource(t *testing.T) {
531+
cases := []struct {
532+
name string
533+
source string
534+
wantRepo string
535+
}{
536+
{
537+
name: "docker hub image normalizes to docker.io",
538+
source: "docker.io/library/nginx:1.28",
539+
wantRepo: "example.com/docker.io/default/library/nginx:1.28",
540+
},
541+
{
542+
name: "ghcr.io image preserves registry",
543+
source: "ghcr.io/org/app:v1",
544+
wantRepo: "example.com/ghcr.io/default/org/app:v1",
545+
},
546+
{
547+
name: "quay.io image preserves registry",
548+
source: "quay.io/prometheus/node-exporter:latest",
549+
wantRepo: "example.com/quay.io/default/prometheus/node-exporter:latest",
550+
},
551+
}
552+
553+
for _, tc := range cases {
554+
t.Run(tc.name, func(t *testing.T) {
555+
originalGet := remoteGetFunc
556+
remoteGetFunc = func(ref name.Reference, _ ...remote.Option) (*remote.Descriptor, error) {
557+
return nil, errors.New("stop after target resolution")
558+
}
559+
t.Cleanup(func() { remoteGetFunc = originalGet })
560+
561+
// Use a custom logger to capture the target from log output.
562+
var logMu sync.Mutex
563+
var logMessages []string
564+
logger := funcr.New(func(prefix, args string) {
565+
logMu.Lock()
566+
defer logMu.Unlock()
567+
logMessages = append(logMessages, prefix+args)
568+
}, funcr.Options{Verbosity: 10})
569+
570+
p := NewPusher(fakeTarget{prefix: "$registry/$namespace"}, false, false, nil, logger, nil, 0, 0, false, true, nil, nil)
571+
572+
_ = p.Mirror(context.Background(), tc.source, Metadata{Namespace: "default"})
573+
574+
logMu.Lock()
575+
defer logMu.Unlock()
576+
found := false
577+
for _, msg := range logMessages {
578+
if strings.Contains(msg, tc.wantRepo) {
579+
found = true
580+
break
581+
}
582+
}
583+
if !found {
584+
t.Fatalf("expected target %q in log output, got messages: %v", tc.wantRepo, logMessages)
585+
}
586+
})
587+
}
588+
}
589+
517590
func TestMirrorSkipsWithoutPodDigestWhenDigestPullEnabled(t *testing.T) {
518591
p := &pusher{
519592
target: fakeTarget{prefix: "$namespace"},
@@ -627,6 +700,30 @@ func TestExpandRepoPrefixSkipsEmptySegments(t *testing.T) {
627700
meta: Metadata{Namespace: "default", Architecture: "amd64"},
628701
want: "amd64/default",
629702
},
703+
{
704+
name: "registry placeholder with ghcr",
705+
pref: "$registry/$namespace",
706+
meta: Metadata{Namespace: "prod", Registry: "ghcr.io"},
707+
want: "ghcr.io/prod",
708+
},
709+
{
710+
name: "registry placeholder with docker.io",
711+
pref: "$registry/$namespace",
712+
meta: Metadata{Namespace: "prod", Registry: "docker.io"},
713+
want: "docker.io/prod",
714+
},
715+
{
716+
name: "registry placeholder omitted when empty",
717+
pref: "$registry/$namespace",
718+
meta: Metadata{Namespace: "prod"},
719+
want: "prod",
720+
},
721+
{
722+
name: "all placeholders including registry",
723+
pref: "$registry/$namespace/$arch",
724+
meta: Metadata{Namespace: "team-a", Architecture: "arm64", Registry: "quay.io"},
725+
want: "quay.io/team-a/arm64",
726+
},
630727
}
631728

632729
for _, tc := range cases {

0 commit comments

Comments
 (0)