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.
274 lines
7.8 KiB
TypeScript
274 lines
7.8 KiB
TypeScript
import * as plugins from '../plugins.js';
|
|
import { BaseUpstream } from '../upstream/classes.baseupstream.js';
|
|
import type {
|
|
IProtocolUpstreamConfig,
|
|
IUpstreamFetchContext,
|
|
IUpstreamResult,
|
|
IUpstreamRegistryConfig,
|
|
} from '../upstream/interfaces.upstream.js';
|
|
import type { IOciManifest, IOciImageIndex, ITagList } from './interfaces.oci.js';
|
|
|
|
/**
|
|
* OCI-specific upstream implementation.
|
|
*
|
|
* Handles:
|
|
* - Manifest fetching (image manifests and index manifests)
|
|
* - Blob proxying (layers, configs)
|
|
* - Tag list fetching
|
|
* - Content-addressable caching (blobs are immutable)
|
|
* - Docker Hub authentication flow
|
|
*/
|
|
export class OciUpstream extends BaseUpstream {
|
|
protected readonly protocolName = 'oci';
|
|
|
|
/** 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;
|
|
}
|
|
|
|
/**
|
|
* Fetch a manifest from upstream registries.
|
|
*/
|
|
public async fetchManifest(
|
|
repository: string,
|
|
reference: string,
|
|
): Promise<{ manifest: IOciManifest | IOciImageIndex; contentType: string; digest: string } | null> {
|
|
const context: IUpstreamFetchContext = {
|
|
protocol: 'oci',
|
|
resource: repository,
|
|
resourceType: 'manifest',
|
|
path: `${this.apiPrefix}/${repository}/manifests/${reference}`,
|
|
method: 'GET',
|
|
headers: {
|
|
'accept': [
|
|
'application/vnd.oci.image.manifest.v1+json',
|
|
'application/vnd.oci.image.index.v1+json',
|
|
'application/vnd.docker.distribution.manifest.v2+json',
|
|
'application/vnd.docker.distribution.manifest.list.v2+json',
|
|
'application/vnd.docker.distribution.manifest.v1+json',
|
|
].join(', '),
|
|
},
|
|
query: {},
|
|
};
|
|
|
|
const result = await this.fetch(context);
|
|
|
|
if (!result || !result.success) {
|
|
return null;
|
|
}
|
|
|
|
let manifest: IOciManifest | IOciImageIndex;
|
|
if (Buffer.isBuffer(result.body)) {
|
|
manifest = JSON.parse(result.body.toString('utf8'));
|
|
} else {
|
|
manifest = result.body;
|
|
}
|
|
|
|
const contentType = result.headers['content-type'] || 'application/vnd.oci.image.manifest.v1+json';
|
|
const digest = result.headers['docker-content-digest'] || '';
|
|
|
|
return { manifest, contentType, digest };
|
|
}
|
|
|
|
/**
|
|
* Check if a manifest exists in upstream (HEAD request).
|
|
*/
|
|
public async headManifest(
|
|
repository: string,
|
|
reference: string,
|
|
): Promise<{ exists: boolean; contentType?: string; digest?: string; size?: number } | null> {
|
|
const context: IUpstreamFetchContext = {
|
|
protocol: 'oci',
|
|
resource: repository,
|
|
resourceType: 'manifest',
|
|
path: `${this.apiPrefix}/${repository}/manifests/${reference}`,
|
|
method: 'HEAD',
|
|
headers: {
|
|
'accept': [
|
|
'application/vnd.oci.image.manifest.v1+json',
|
|
'application/vnd.oci.image.index.v1+json',
|
|
'application/vnd.docker.distribution.manifest.v2+json',
|
|
'application/vnd.docker.distribution.manifest.list.v2+json',
|
|
].join(', '),
|
|
},
|
|
query: {},
|
|
};
|
|
|
|
const result = await this.fetch(context);
|
|
|
|
if (!result) {
|
|
return null;
|
|
}
|
|
|
|
if (!result.success) {
|
|
return { exists: false };
|
|
}
|
|
|
|
return {
|
|
exists: true,
|
|
contentType: result.headers['content-type'],
|
|
digest: result.headers['docker-content-digest'],
|
|
size: result.headers['content-length'] ? parseInt(result.headers['content-length'], 10) : undefined,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Fetch a blob from upstream registries.
|
|
*/
|
|
public async fetchBlob(repository: string, digest: string): Promise<Buffer | null> {
|
|
const context: IUpstreamFetchContext = {
|
|
protocol: 'oci',
|
|
resource: repository,
|
|
resourceType: 'blob',
|
|
path: `${this.apiPrefix}/${repository}/blobs/${digest}`,
|
|
method: 'GET',
|
|
headers: {
|
|
'accept': 'application/octet-stream',
|
|
},
|
|
query: {},
|
|
};
|
|
|
|
const result = await this.fetch(context);
|
|
|
|
if (!result || !result.success) {
|
|
return null;
|
|
}
|
|
|
|
return Buffer.isBuffer(result.body) ? result.body : Buffer.from(result.body);
|
|
}
|
|
|
|
/**
|
|
* Check if a blob exists in upstream (HEAD request).
|
|
*/
|
|
public async headBlob(
|
|
repository: string,
|
|
digest: string,
|
|
): Promise<{ exists: boolean; size?: number } | null> {
|
|
const context: IUpstreamFetchContext = {
|
|
protocol: 'oci',
|
|
resource: repository,
|
|
resourceType: 'blob',
|
|
path: `${this.apiPrefix}/${repository}/blobs/${digest}`,
|
|
method: 'HEAD',
|
|
headers: {},
|
|
query: {},
|
|
};
|
|
|
|
const result = await this.fetch(context);
|
|
|
|
if (!result) {
|
|
return null;
|
|
}
|
|
|
|
if (!result.success) {
|
|
return { exists: false };
|
|
}
|
|
|
|
return {
|
|
exists: true,
|
|
size: result.headers['content-length'] ? parseInt(result.headers['content-length'], 10) : undefined,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Fetch the tag list for a repository.
|
|
*/
|
|
public async fetchTags(repository: string, n?: number, last?: string): Promise<ITagList | null> {
|
|
const query: Record<string, string> = {};
|
|
if (n) query.n = n.toString();
|
|
if (last) query.last = last;
|
|
|
|
const context: IUpstreamFetchContext = {
|
|
protocol: 'oci',
|
|
resource: repository,
|
|
resourceType: 'tags',
|
|
path: `${this.apiPrefix}/${repository}/tags/list`,
|
|
method: 'GET',
|
|
headers: {
|
|
'accept': 'application/json',
|
|
},
|
|
query,
|
|
};
|
|
|
|
const result = await this.fetch(context);
|
|
|
|
if (!result || !result.success) {
|
|
return null;
|
|
}
|
|
|
|
let tagList: ITagList;
|
|
if (Buffer.isBuffer(result.body)) {
|
|
tagList = JSON.parse(result.body.toString('utf8'));
|
|
} else {
|
|
tagList = result.body;
|
|
}
|
|
|
|
return tagList;
|
|
}
|
|
|
|
/**
|
|
* Override URL building for OCI-specific handling.
|
|
* OCI registries use a configurable API prefix (default /v2/) and may require
|
|
* special handling for Docker Hub.
|
|
*/
|
|
protected buildUpstreamUrl(
|
|
upstream: IUpstreamRegistryConfig,
|
|
context: IUpstreamFetchContext,
|
|
): string {
|
|
let baseUrl = upstream.url;
|
|
|
|
// Remove trailing slash
|
|
if (baseUrl.endsWith('/')) {
|
|
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 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}${prefix}/library/${repository}/${rest}`;
|
|
}
|
|
}
|
|
}
|
|
|
|
return `${baseUrl}${context.path}`;
|
|
}
|
|
|
|
/**
|
|
* Override header building for OCI-specific authentication.
|
|
* OCI registries may require token-based auth obtained from a separate endpoint.
|
|
*/
|
|
protected buildHeaders(
|
|
upstream: IUpstreamRegistryConfig,
|
|
context: IUpstreamFetchContext,
|
|
): Record<string, string> {
|
|
const headers = super.buildHeaders(upstream, context);
|
|
|
|
// OCI registries typically use Docker-Distribution-API-Version header
|
|
headers['docker-distribution-api-version'] = 'registry/2.0';
|
|
|
|
return headers;
|
|
}
|
|
}
|