264 lines
7.2 KiB
TypeScript
264 lines
7.2 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;
|
||
|
|
|
||
|
|
constructor(
|
||
|
|
config: IProtocolUpstreamConfig,
|
||
|
|
localBasePath: string = '/oci',
|
||
|
|
logger?: plugins.smartlog.Smartlog,
|
||
|
|
) {
|
||
|
|
super(config, logger);
|
||
|
|
this.localBasePath = localBasePath;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* 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: `/v2/${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: `/v2/${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: `/v2/${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: `/v2/${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: `/v2/${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 /v2/ prefix 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);
|
||
|
|
}
|
||
|
|
|
||
|
|
// 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\/([^\/]+)\/(.+)$/);
|
||
|
|
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}${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;
|
||
|
|
}
|
||
|
|
}
|