diff --git a/backend/src/__tests__/registry-api.test.ts b/backend/src/__tests__/registry-api.test.ts index 11c5ac0e..4e93cab1 100644 --- a/backend/src/__tests__/registry-api.test.ts +++ b/backend/src/__tests__/registry-api.test.ts @@ -115,6 +115,34 @@ describe('repoDigestMatchesRef', () => { }); }); +describe('parseImageRef', () => { + // docker.io / index.docker.io / registry-1.docker.io are the same registry, but only + // the literal 'registry-1.docker.io' is recognized elsewhere (getAuthToken, the + // library/ auto-prefix in parseImageRef). An unnormalized 'docker.io' or 'index.docker.io' + // leaks through into request URLs and hits the marketing domain instead of the registry API. + // Each alias is listed with and without an explicit library/ namespace to pin both paths. + it.each([ + 'docker.io/library/traefik:latest', + 'docker.io/traefik:latest', + 'index.docker.io/library/traefik:latest', + 'index.docker.io/traefik:latest', + ])('normalizes %s to the registry API host and the library/ namespace', (ref) => { + expect(parseImageRef(ref)).toEqual({ + registry: 'registry-1.docker.io', + repo: 'library/traefik', + tag: 'latest', + }); + }); + + it('leaves a bare official image name unchanged (regression guard)', () => { + expect(parseImageRef('traefik:latest')).toEqual({ + registry: 'registry-1.docker.io', + repo: 'library/traefik', + tag: 'latest', + }); + }); +}); + describe('getRemoteDigest HEAD-first lookup', () => { beforeEach(() => { calls.length = 0; diff --git a/backend/src/__tests__/update-preview-service.test.ts b/backend/src/__tests__/update-preview-service.test.ts index 931cbacd..81b43f05 100644 --- a/backend/src/__tests__/update-preview-service.test.ts +++ b/backend/src/__tests__/update-preview-service.test.ts @@ -432,6 +432,15 @@ describe('buildSummary', () => { expect(buildSummary('stacky', images).rollback_target).toBe('postgres:16'); }); + it('computes rollback target for an explicit docker.io/ ref the same as the bare form', () => { + // docker.io is a Docker Hub alias normalized by parseImageRef; the rollback target + // must render the same shortened form as the bare-name case above, not the literal host. + const images = [ + baseImage({ service: 'db', image: 'docker.io/library/postgres:16', has_update: true, semver_bump: 'patch', next_tag: '16', current_tag: '16' }), + ]; + expect(buildSummary('stacky', images).rollback_target).toBe('postgres:16'); + }); + it('computes rollback target for registry with port', () => { const images = [ baseImage({ diff --git a/backend/src/services/registry-api.ts b/backend/src/services/registry-api.ts index b2c6dd24..13125668 100644 --- a/backend/src/services/registry-api.ts +++ b/backend/src/services/registry-api.ts @@ -35,7 +35,12 @@ export function parseImageRef(imageRef: string): ParsedRef | null { if (slashIdx !== -1) { const firstPart = imageRef.slice(0, slashIdx); if (firstPart.includes('.') || firstPart.includes(':') || firstPart === 'localhost') { - registry = firstPart; + // docker.io / index.docker.io are Docker Hub aliases; normalize them to the + // actual registry API host so requests never hit the marketing domain (which + // redirects instead of serving /v2/) and the library/ auto-prefix below still applies. + registry = (firstPart === 'docker.io' || firstPart === 'index.docker.io') + ? 'registry-1.docker.io' + : firstPart; rest = imageRef.slice(slashIdx + 1); } } @@ -214,7 +219,12 @@ const MANIFEST_ACCEPT = [ 'application/vnd.oci.image.manifest.v1+json', ].join(', '); -/** docker.io has three hostnames that all address the same registry. */ +/** + * docker.io has three hostnames that all address the same registry. Folds two + * already-parsed ParsedRef.registry values down to a shared form for equality/cache-key + * comparisons; not the canonical form parseImageRef assigns (which normalizes to + * 'registry-1.docker.io'). + */ function canonicalRegistry(host: string): string { if (host === 'docker.io' || host === 'index.docker.io' || host === 'registry-1.docker.io') { return 'docker.io';