fix(oci): remove /v2/ from internal route patterns and make upstream apiPrefix configurable
The OCI handler had /v2/ baked into all regex patterns and Location headers. When basePath was set to /v2 (as in stack.gallery), stripping it removed the prefix that patterns expected, causing all OCI endpoints to 404. Now patterns match on bare paths after basePath stripping, working correctly regardless of the basePath value. Also adds configurable apiPrefix to OCI upstream class (default /v2) for registries behind reverse proxies with custom path prefixes.
This commit is contained in:
@@ -48,7 +48,7 @@ async function createDockerTestRegistry(port: number): Promise<SmartRegistry> {
|
||||
},
|
||||
oci: {
|
||||
enabled: true,
|
||||
basePath: '/oci',
|
||||
basePath: '/v2',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -95,8 +95,7 @@ let testImageName: string;
|
||||
* Create HTTP server wrapper around SmartRegistry
|
||||
* CRITICAL: Always passes rawBody for content-addressable operations (OCI manifests/blobs)
|
||||
*
|
||||
* Docker expects registry at /v2/ but SmartRegistry serves at /oci/v2/
|
||||
* This wrapper rewrites paths for Docker compatibility
|
||||
* SmartRegistry OCI is configured with basePath '/v2' matching Docker's native /v2/ prefix.
|
||||
*
|
||||
* Also implements a simple /v2/token endpoint for Docker Bearer auth flow
|
||||
*/
|
||||
@@ -130,10 +129,7 @@ async function createHttpServer(
|
||||
// Log all requests for debugging
|
||||
console.log(`[Registry] ${req.method} ${pathname}`);
|
||||
|
||||
// Docker expects /v2/ but SmartRegistry serves at /oci/v2/
|
||||
if (pathname.startsWith('/v2')) {
|
||||
pathname = '/oci' + pathname;
|
||||
}
|
||||
// basePath is /v2 which matches Docker's native /v2/ prefix — no rewrite needed
|
||||
|
||||
// Read raw body - ALWAYS preserve exact bytes for OCI
|
||||
const chunks: Buffer[] = [];
|
||||
@@ -313,7 +309,7 @@ tap.test('Docker CLI: should verify server is responding', async () => {
|
||||
// Give the server a moment to fully initialize
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
|
||||
const response = await fetch(`${registryUrl}/oci/v2/`);
|
||||
const response = await fetch(`${registryUrl}/v2/`);
|
||||
expect(response.status).toEqual(200);
|
||||
console.log('OCI v2 response:', await response.json());
|
||||
});
|
||||
@@ -352,7 +348,7 @@ tap.test('Docker CLI: should push image to registry', async () => {
|
||||
});
|
||||
|
||||
tap.test('Docker CLI: should verify manifest in registry via API', async () => {
|
||||
const response = await fetch(`${registryUrl}/oci/v2/test-image/tags/list`, {
|
||||
const response = await fetch(`${registryUrl}/v2/test-image/tags/list`, {
|
||||
headers: { Authorization: `Bearer ${ociToken}` },
|
||||
});
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ tap.test('OCI: should create registry instance', async () => {
|
||||
tap.test('OCI: should handle version check (GET /v2/)', async () => {
|
||||
const response = await registry.handleRequest({
|
||||
method: 'GET',
|
||||
path: '/oci/v2/',
|
||||
path: '/oci/',
|
||||
headers: {},
|
||||
query: {},
|
||||
});
|
||||
@@ -36,7 +36,7 @@ tap.test('OCI: should handle version check (GET /v2/)', async () => {
|
||||
tap.test('OCI: should initiate blob upload (POST /v2/{name}/blobs/uploads/)', async () => {
|
||||
const response = await registry.handleRequest({
|
||||
method: 'POST',
|
||||
path: '/oci/v2/test-repo/blobs/uploads/',
|
||||
path: '/oci/test-repo/blobs/uploads/',
|
||||
headers: {
|
||||
Authorization: `Bearer ${ociToken}`,
|
||||
},
|
||||
@@ -53,7 +53,7 @@ tap.test('OCI: should upload blob in single PUT', async () => {
|
||||
|
||||
const response = await registry.handleRequest({
|
||||
method: 'POST',
|
||||
path: '/oci/v2/test-repo/blobs/uploads/',
|
||||
path: '/oci/test-repo/blobs/uploads/',
|
||||
headers: {
|
||||
Authorization: `Bearer ${ociToken}`,
|
||||
},
|
||||
@@ -73,7 +73,7 @@ tap.test('OCI: should upload config blob', async () => {
|
||||
|
||||
const response = await registry.handleRequest({
|
||||
method: 'POST',
|
||||
path: '/oci/v2/test-repo/blobs/uploads/',
|
||||
path: '/oci/test-repo/blobs/uploads/',
|
||||
headers: {
|
||||
Authorization: `Bearer ${ociToken}`,
|
||||
},
|
||||
@@ -90,7 +90,7 @@ tap.test('OCI: should upload config blob', async () => {
|
||||
tap.test('OCI: should check if blob exists (HEAD /v2/{name}/blobs/{digest})', async () => {
|
||||
const response = await registry.handleRequest({
|
||||
method: 'HEAD',
|
||||
path: `/oci/v2/test-repo/blobs/${testBlobDigest}`,
|
||||
path: `/oci/test-repo/blobs/${testBlobDigest}`,
|
||||
headers: {
|
||||
Authorization: `Bearer ${ociToken}`,
|
||||
},
|
||||
@@ -105,7 +105,7 @@ tap.test('OCI: should check if blob exists (HEAD /v2/{name}/blobs/{digest})', as
|
||||
tap.test('OCI: should retrieve blob (GET /v2/{name}/blobs/{digest})', async () => {
|
||||
const response = await registry.handleRequest({
|
||||
method: 'GET',
|
||||
path: `/oci/v2/test-repo/blobs/${testBlobDigest}`,
|
||||
path: `/oci/test-repo/blobs/${testBlobDigest}`,
|
||||
headers: {
|
||||
Authorization: `Bearer ${ociToken}`,
|
||||
},
|
||||
@@ -126,7 +126,7 @@ tap.test('OCI: should upload manifest (PUT /v2/{name}/manifests/{reference})', a
|
||||
|
||||
const response = await registry.handleRequest({
|
||||
method: 'PUT',
|
||||
path: '/oci/v2/test-repo/manifests/v1.0.0',
|
||||
path: '/oci/test-repo/manifests/v1.0.0',
|
||||
headers: {
|
||||
Authorization: `Bearer ${ociToken}`,
|
||||
'Content-Type': 'application/vnd.oci.image.manifest.v1+json',
|
||||
@@ -143,7 +143,7 @@ tap.test('OCI: should upload manifest (PUT /v2/{name}/manifests/{reference})', a
|
||||
tap.test('OCI: should retrieve manifest by tag (GET /v2/{name}/manifests/{reference})', async () => {
|
||||
const response = await registry.handleRequest({
|
||||
method: 'GET',
|
||||
path: '/oci/v2/test-repo/manifests/v1.0.0',
|
||||
path: '/oci/test-repo/manifests/v1.0.0',
|
||||
headers: {
|
||||
Authorization: `Bearer ${ociToken}`,
|
||||
Accept: 'application/vnd.oci.image.manifest.v1+json',
|
||||
@@ -163,7 +163,7 @@ tap.test('OCI: should retrieve manifest by tag (GET /v2/{name}/manifests/{refere
|
||||
tap.test('OCI: should retrieve manifest by digest (GET /v2/{name}/manifests/{digest})', async () => {
|
||||
const response = await registry.handleRequest({
|
||||
method: 'GET',
|
||||
path: `/oci/v2/test-repo/manifests/${testManifestDigest}`,
|
||||
path: `/oci/test-repo/manifests/${testManifestDigest}`,
|
||||
headers: {
|
||||
Authorization: `Bearer ${ociToken}`,
|
||||
Accept: 'application/vnd.oci.image.manifest.v1+json',
|
||||
@@ -178,7 +178,7 @@ tap.test('OCI: should retrieve manifest by digest (GET /v2/{name}/manifests/{dig
|
||||
tap.test('OCI: should check if manifest exists (HEAD /v2/{name}/manifests/{reference})', async () => {
|
||||
const response = await registry.handleRequest({
|
||||
method: 'HEAD',
|
||||
path: '/oci/v2/test-repo/manifests/v1.0.0',
|
||||
path: '/oci/test-repo/manifests/v1.0.0',
|
||||
headers: {
|
||||
Authorization: `Bearer ${ociToken}`,
|
||||
Accept: 'application/vnd.oci.image.manifest.v1+json',
|
||||
@@ -193,7 +193,7 @@ tap.test('OCI: should check if manifest exists (HEAD /v2/{name}/manifests/{refer
|
||||
tap.test('OCI: should list tags (GET /v2/{name}/tags/list)', async () => {
|
||||
const response = await registry.handleRequest({
|
||||
method: 'GET',
|
||||
path: '/oci/v2/test-repo/tags/list',
|
||||
path: '/oci/test-repo/tags/list',
|
||||
headers: {
|
||||
Authorization: `Bearer ${ociToken}`,
|
||||
},
|
||||
@@ -212,7 +212,7 @@ tap.test('OCI: should list tags (GET /v2/{name}/tags/list)', async () => {
|
||||
tap.test('OCI: should handle pagination for tag list', async () => {
|
||||
const response = await registry.handleRequest({
|
||||
method: 'GET',
|
||||
path: '/oci/v2/test-repo/tags/list',
|
||||
path: '/oci/test-repo/tags/list',
|
||||
headers: {
|
||||
Authorization: `Bearer ${ociToken}`,
|
||||
},
|
||||
@@ -228,7 +228,7 @@ tap.test('OCI: should handle pagination for tag list', async () => {
|
||||
tap.test('OCI: should return 404 for non-existent blob', async () => {
|
||||
const response = await registry.handleRequest({
|
||||
method: 'GET',
|
||||
path: '/oci/v2/test-repo/blobs/sha256:0000000000000000000000000000000000000000000000000000000000000000',
|
||||
path: '/oci/test-repo/blobs/sha256:0000000000000000000000000000000000000000000000000000000000000000',
|
||||
headers: {
|
||||
Authorization: `Bearer ${ociToken}`,
|
||||
},
|
||||
@@ -242,7 +242,7 @@ tap.test('OCI: should return 404 for non-existent blob', async () => {
|
||||
tap.test('OCI: should return 404 for non-existent manifest', async () => {
|
||||
const response = await registry.handleRequest({
|
||||
method: 'GET',
|
||||
path: '/oci/v2/test-repo/manifests/non-existent-tag',
|
||||
path: '/oci/test-repo/manifests/non-existent-tag',
|
||||
headers: {
|
||||
Authorization: `Bearer ${ociToken}`,
|
||||
Accept: 'application/vnd.oci.image.manifest.v1+json',
|
||||
@@ -257,7 +257,7 @@ tap.test('OCI: should return 404 for non-existent manifest', async () => {
|
||||
tap.test('OCI: should delete manifest (DELETE /v2/{name}/manifests/{digest})', async () => {
|
||||
const response = await registry.handleRequest({
|
||||
method: 'DELETE',
|
||||
path: `/oci/v2/test-repo/manifests/${testManifestDigest}`,
|
||||
path: `/oci/test-repo/manifests/${testManifestDigest}`,
|
||||
headers: {
|
||||
Authorization: `Bearer ${ociToken}`,
|
||||
},
|
||||
@@ -270,7 +270,7 @@ tap.test('OCI: should delete manifest (DELETE /v2/{name}/manifests/{digest})', a
|
||||
tap.test('OCI: should delete blob (DELETE /v2/{name}/blobs/{digest})', async () => {
|
||||
const response = await registry.handleRequest({
|
||||
method: 'DELETE',
|
||||
path: `/oci/v2/test-repo/blobs/${testBlobDigest}`,
|
||||
path: `/oci/test-repo/blobs/${testBlobDigest}`,
|
||||
headers: {
|
||||
Authorization: `Bearer ${ociToken}`,
|
||||
},
|
||||
@@ -283,7 +283,7 @@ tap.test('OCI: should delete blob (DELETE /v2/{name}/blobs/{digest})', async ()
|
||||
tap.test('OCI: should handle unauthorized requests', async () => {
|
||||
const response = await registry.handleRequest({
|
||||
method: 'GET',
|
||||
path: '/oci/v2/test-repo/manifests/v1.0.0',
|
||||
path: '/oci/test-repo/manifests/v1.0.0',
|
||||
headers: {
|
||||
// No authorization header
|
||||
},
|
||||
|
||||
@@ -125,12 +125,12 @@ export class OciRegistry extends BaseRegistry {
|
||||
};
|
||||
|
||||
// Route to appropriate handler
|
||||
if (path === '/v2/' || path === '/v2') {
|
||||
if (path === '/' || path === '') {
|
||||
return this.handleVersionCheck();
|
||||
}
|
||||
|
||||
// Manifest operations: /v2/{name}/manifests/{reference}
|
||||
const manifestMatch = path.match(/^\/v2\/([^\/]+(?:\/[^\/]+)*)\/manifests\/([^\/]+)$/);
|
||||
// Manifest operations: /{name}/manifests/{reference}
|
||||
const manifestMatch = path.match(/^\/([^\/]+(?:\/[^\/]+)*)\/manifests\/([^\/]+)$/);
|
||||
if (manifestMatch) {
|
||||
const [, name, reference] = manifestMatch;
|
||||
// Prefer rawBody for content-addressable operations to preserve exact bytes
|
||||
@@ -138,15 +138,15 @@ export class OciRegistry extends BaseRegistry {
|
||||
return this.handleManifestRequest(context.method, name, reference, token, bodyData, context.headers, actor);
|
||||
}
|
||||
|
||||
// Blob operations: /v2/{name}/blobs/{digest}
|
||||
const blobMatch = path.match(/^\/v2\/([^\/]+(?:\/[^\/]+)*)\/blobs\/(sha256:[a-f0-9]{64})$/);
|
||||
// Blob operations: /{name}/blobs/{digest}
|
||||
const blobMatch = path.match(/^\/([^\/]+(?:\/[^\/]+)*)\/blobs\/(sha256:[a-f0-9]{64})$/);
|
||||
if (blobMatch) {
|
||||
const [, name, digest] = blobMatch;
|
||||
return this.handleBlobRequest(context.method, name, digest, token, context.headers, actor);
|
||||
}
|
||||
|
||||
// Blob upload operations: /v2/{name}/blobs/uploads/
|
||||
const uploadInitMatch = path.match(/^\/v2\/([^\/]+(?:\/[^\/]+)*)\/blobs\/uploads\/?$/);
|
||||
// Blob upload operations: /{name}/blobs/uploads/
|
||||
const uploadInitMatch = path.match(/^\/([^\/]+(?:\/[^\/]+)*)\/blobs\/uploads\/?$/);
|
||||
if (uploadInitMatch && context.method === 'POST') {
|
||||
const [, name] = uploadInitMatch;
|
||||
// Prefer rawBody for content-addressable operations to preserve exact bytes
|
||||
@@ -154,22 +154,22 @@ export class OciRegistry extends BaseRegistry {
|
||||
return this.handleUploadInit(name, token, context.query, bodyData);
|
||||
}
|
||||
|
||||
// Blob upload operations: /v2/{name}/blobs/uploads/{uuid}
|
||||
const uploadMatch = path.match(/^\/v2\/([^\/]+(?:\/[^\/]+)*)\/blobs\/uploads\/([^\/]+)$/);
|
||||
// Blob upload operations: /{name}/blobs/uploads/{uuid}
|
||||
const uploadMatch = path.match(/^\/([^\/]+(?:\/[^\/]+)*)\/blobs\/uploads\/([^\/]+)$/);
|
||||
if (uploadMatch) {
|
||||
const [, name, uploadId] = uploadMatch;
|
||||
return this.handleUploadSession(context.method, uploadId, token, context);
|
||||
}
|
||||
|
||||
// Tags list: /v2/{name}/tags/list
|
||||
const tagsMatch = path.match(/^\/v2\/([^\/]+(?:\/[^\/]+)*)\/tags\/list$/);
|
||||
// Tags list: /{name}/tags/list
|
||||
const tagsMatch = path.match(/^\/([^\/]+(?:\/[^\/]+)*)\/tags\/list$/);
|
||||
if (tagsMatch) {
|
||||
const [, name] = tagsMatch;
|
||||
return this.handleTagsList(name, token, context.query);
|
||||
}
|
||||
|
||||
// Referrers: /v2/{name}/referrers/{digest}
|
||||
const referrersMatch = path.match(/^\/v2\/([^\/]+(?:\/[^\/]+)*)\/referrers\/(sha256:[a-f0-9]{64})$/);
|
||||
// Referrers: /{name}/referrers/{digest}
|
||||
const referrersMatch = path.match(/^\/([^\/]+(?:\/[^\/]+)*)\/referrers\/(sha256:[a-f0-9]{64})$/);
|
||||
if (referrersMatch) {
|
||||
const [, name, digest] = referrersMatch;
|
||||
return this.handleReferrers(name, digest, token, context.query);
|
||||
@@ -289,7 +289,7 @@ export class OciRegistry extends BaseRegistry {
|
||||
return {
|
||||
status: 201,
|
||||
headers: {
|
||||
'Location': `${this.basePath}/v2/${repository}/blobs/${digest}`,
|
||||
'Location': `${this.basePath}/${repository}/blobs/${digest}`,
|
||||
'Docker-Content-Digest': digest,
|
||||
},
|
||||
body: null,
|
||||
@@ -312,7 +312,7 @@ export class OciRegistry extends BaseRegistry {
|
||||
return {
|
||||
status: 202,
|
||||
headers: {
|
||||
'Location': `${this.basePath}/v2/${repository}/blobs/uploads/${uploadId}`,
|
||||
'Location': `${this.basePath}/${repository}/blobs/uploads/${uploadId}`,
|
||||
'Docker-Upload-UUID': uploadId,
|
||||
},
|
||||
body: null,
|
||||
@@ -527,7 +527,7 @@ export class OciRegistry extends BaseRegistry {
|
||||
return {
|
||||
status: 201,
|
||||
headers: {
|
||||
'Location': `${this.basePath}/v2/${repository}/manifests/${digest}`,
|
||||
'Location': `${this.basePath}/${repository}/manifests/${digest}`,
|
||||
'Docker-Content-Digest': digest,
|
||||
},
|
||||
body: null,
|
||||
@@ -677,7 +677,7 @@ export class OciRegistry extends BaseRegistry {
|
||||
return {
|
||||
status: 202,
|
||||
headers: {
|
||||
'Location': `${this.basePath}/v2/${session.repository}/blobs/uploads/${uploadId}`,
|
||||
'Location': `${this.basePath}/${session.repository}/blobs/uploads/${uploadId}`,
|
||||
'Range': `0-${session.totalSize - 1}`,
|
||||
'Docker-Upload-UUID': uploadId,
|
||||
},
|
||||
@@ -719,7 +719,7 @@ export class OciRegistry extends BaseRegistry {
|
||||
return {
|
||||
status: 201,
|
||||
headers: {
|
||||
'Location': `${this.basePath}/v2/${session.repository}/blobs/${digest}`,
|
||||
'Location': `${this.basePath}/${session.repository}/blobs/${digest}`,
|
||||
'Docker-Content-Digest': digest,
|
||||
},
|
||||
body: null,
|
||||
@@ -739,7 +739,7 @@ export class OciRegistry extends BaseRegistry {
|
||||
return {
|
||||
status: 204,
|
||||
headers: {
|
||||
'Location': `${this.basePath}/v2/${session.repository}/blobs/uploads/${uploadId}`,
|
||||
'Location': `${this.basePath}/${session.repository}/blobs/uploads/${uploadId}`,
|
||||
'Range': session.totalSize > 0 ? `0-${session.totalSize - 1}` : '0-0',
|
||||
'Docker-Upload-UUID': uploadId,
|
||||
},
|
||||
@@ -884,7 +884,7 @@ export class OciRegistry extends BaseRegistry {
|
||||
* Per OCI Distribution Spec, 401 responses MUST include WWW-Authenticate header.
|
||||
*/
|
||||
private createUnauthorizedResponse(repository: string, action: string): IResponse {
|
||||
const realm = this.ociTokens?.realm || `${this.basePath}/v2/token`;
|
||||
const realm = this.ociTokens?.realm || `${this.basePath}/token`;
|
||||
const service = this.ociTokens?.service || 'registry';
|
||||
return {
|
||||
status: 401,
|
||||
@@ -899,7 +899,7 @@ export class OciRegistry extends BaseRegistry {
|
||||
* Create an unauthorized HEAD response (no body per HTTP spec).
|
||||
*/
|
||||
private createUnauthorizedHeadResponse(repository: string, action: string): IResponse {
|
||||
const realm = this.ociTokens?.realm || `${this.basePath}/v2/token`;
|
||||
const realm = this.ociTokens?.realm || `${this.basePath}/token`;
|
||||
const service = this.ociTokens?.service || 'registry';
|
||||
return {
|
||||
status: 401,
|
||||
|
||||
@@ -24,13 +24,18 @@ export class OciUpstream extends BaseUpstream {
|
||||
/** Local registry base path for URL building */
|
||||
private readonly localBasePath: string;
|
||||
|
||||
/** API prefix for outbound OCI requests (default: /v2) */
|
||||
private readonly apiPrefix: string;
|
||||
|
||||
constructor(
|
||||
config: IProtocolUpstreamConfig,
|
||||
localBasePath: string = '/oci',
|
||||
logger?: plugins.smartlog.Smartlog,
|
||||
apiPrefix: string = '/v2',
|
||||
) {
|
||||
super(config, logger);
|
||||
this.localBasePath = localBasePath;
|
||||
this.apiPrefix = apiPrefix;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -44,7 +49,7 @@ export class OciUpstream extends BaseUpstream {
|
||||
protocol: 'oci',
|
||||
resource: repository,
|
||||
resourceType: 'manifest',
|
||||
path: `/v2/${repository}/manifests/${reference}`,
|
||||
path: `${this.apiPrefix}/${repository}/manifests/${reference}`,
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'accept': [
|
||||
@@ -88,7 +93,7 @@ export class OciUpstream extends BaseUpstream {
|
||||
protocol: 'oci',
|
||||
resource: repository,
|
||||
resourceType: 'manifest',
|
||||
path: `/v2/${repository}/manifests/${reference}`,
|
||||
path: `${this.apiPrefix}/${repository}/manifests/${reference}`,
|
||||
method: 'HEAD',
|
||||
headers: {
|
||||
'accept': [
|
||||
@@ -127,7 +132,7 @@ export class OciUpstream extends BaseUpstream {
|
||||
protocol: 'oci',
|
||||
resource: repository,
|
||||
resourceType: 'blob',
|
||||
path: `/v2/${repository}/blobs/${digest}`,
|
||||
path: `${this.apiPrefix}/${repository}/blobs/${digest}`,
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'accept': 'application/octet-stream',
|
||||
@@ -155,7 +160,7 @@ export class OciUpstream extends BaseUpstream {
|
||||
protocol: 'oci',
|
||||
resource: repository,
|
||||
resourceType: 'blob',
|
||||
path: `/v2/${repository}/blobs/${digest}`,
|
||||
path: `${this.apiPrefix}/${repository}/blobs/${digest}`,
|
||||
method: 'HEAD',
|
||||
headers: {},
|
||||
query: {},
|
||||
@@ -189,7 +194,7 @@ export class OciUpstream extends BaseUpstream {
|
||||
protocol: 'oci',
|
||||
resource: repository,
|
||||
resourceType: 'tags',
|
||||
path: `/v2/${repository}/tags/list`,
|
||||
path: `${this.apiPrefix}/${repository}/tags/list`,
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'accept': 'application/json',
|
||||
@@ -215,7 +220,8 @@ export class OciUpstream extends BaseUpstream {
|
||||
|
||||
/**
|
||||
* Override URL building for OCI-specific handling.
|
||||
* OCI registries use /v2/ prefix and may require special handling for Docker Hub.
|
||||
* OCI registries use a configurable API prefix (default /v2/) and may require
|
||||
* special handling for Docker Hub.
|
||||
*/
|
||||
protected buildUpstreamUrl(
|
||||
upstream: IUpstreamRegistryConfig,
|
||||
@@ -228,16 +234,20 @@ export class OciUpstream extends BaseUpstream {
|
||||
baseUrl = baseUrl.slice(0, -1);
|
||||
}
|
||||
|
||||
// Use per-upstream apiPrefix if configured, otherwise use the instance default
|
||||
const prefix = upstream.apiPrefix || this.apiPrefix;
|
||||
|
||||
// Handle Docker Hub special case
|
||||
// Docker Hub uses registry-1.docker.io but library images need special handling
|
||||
if (baseUrl.includes('docker.io') || baseUrl.includes('registry-1.docker.io')) {
|
||||
// For library images (e.g., "nginx" -> "library/nginx")
|
||||
const pathParts = context.path.match(/^\/v2\/([^\/]+)\/(.+)$/);
|
||||
const escapedPrefix = prefix.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
const pathParts = context.path.match(new RegExp(`^${escapedPrefix}\\/([^\\/]+)\\/(.+)$`));
|
||||
if (pathParts) {
|
||||
const [, repository, rest] = pathParts;
|
||||
// If repository doesn't contain a slash, it's a library image
|
||||
if (!repository.includes('/')) {
|
||||
return `${baseUrl}/v2/library/${repository}/${rest}`;
|
||||
return `${baseUrl}${prefix}/library/${repository}/${rest}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,6 +86,8 @@ export interface IUpstreamRegistryConfig {
|
||||
cache?: Partial<IUpstreamCacheConfig>;
|
||||
/** Resilience configuration overrides */
|
||||
resilience?: Partial<IUpstreamResilienceConfig>;
|
||||
/** API path prefix for OCI registries (default: /v2). Useful for registries behind reverse proxies. */
|
||||
apiPrefix?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user