feat(oci): Support monolithic OCI blob uploads; add registry cleanup/destroy hooks; update tests and docs

This commit is contained in:
2025-11-20 19:46:34 +00:00
parent eca08604dc
commit 1c63b74bb8
10 changed files with 186 additions and 53 deletions

View File

@@ -19,6 +19,7 @@ export class OciRegistry extends BaseRegistry {
private authManager: AuthManager;
private uploadSessions: Map<string, IUploadSession> = new Map();
private basePath: string = '/oci';
private cleanupInterval?: NodeJS.Timeout;
constructor(storage: RegistryStorage, authManager: AuthManager, basePath: string = '/oci') {
super();
@@ -54,7 +55,7 @@ export class OciRegistry extends BaseRegistry {
const manifestMatch = path.match(/^\/v2\/([^\/]+(?:\/[^\/]+)*)\/manifests\/([^\/]+)$/);
if (manifestMatch) {
const [, name, reference] = manifestMatch;
return this.handleManifestRequest(context.method, name, reference, token);
return this.handleManifestRequest(context.method, name, reference, token, context.body, context.headers);
}
// Blob operations: /v2/{name}/blobs/{digest}
@@ -68,7 +69,7 @@ export class OciRegistry extends BaseRegistry {
const uploadInitMatch = path.match(/^\/v2\/([^\/]+(?:\/[^\/]+)*)\/blobs\/uploads\/?$/);
if (uploadInitMatch && context.method === 'POST') {
const [, name] = uploadInitMatch;
return this.handleUploadInit(name, token, context.query);
return this.handleUploadInit(name, token, context.query, context.body);
}
// Blob upload operations: /v2/{name}/blobs/uploads/{uuid}
@@ -115,7 +116,10 @@ export class OciRegistry extends BaseRegistry {
private handleVersionCheck(): IResponse {
return {
status: 200,
headers: { 'Content-Type': 'application/json' },
headers: {
'Content-Type': 'application/json',
'Docker-Distribution-API-Version': 'registry/2.0',
},
body: {},
};
}
@@ -124,15 +128,17 @@ export class OciRegistry extends BaseRegistry {
method: string,
repository: string,
reference: string,
token: IAuthToken | null
token: IAuthToken | null,
body?: Buffer | any,
headers?: Record<string, string>
): Promise<IResponse> {
switch (method) {
case 'GET':
return this.getManifest(repository, reference, token);
return this.getManifest(repository, reference, token, headers);
case 'HEAD':
return this.headManifest(repository, reference, token);
case 'PUT':
return this.putManifest(repository, reference, token);
return this.putManifest(repository, reference, token, body, headers);
case 'DELETE':
return this.deleteManifest(repository, reference, token);
default:
@@ -170,7 +176,8 @@ export class OciRegistry extends BaseRegistry {
private async handleUploadInit(
repository: string,
token: IAuthToken | null,
query: Record<string, string>
query: Record<string, string>,
body?: Buffer | any
): Promise<IResponse> {
if (!await this.checkPermission(token, repository, 'push')) {
return {
@@ -180,6 +187,36 @@ export class OciRegistry extends BaseRegistry {
};
}
// Check for monolithic upload (digest + body provided)
const digest = query.digest;
if (digest && body) {
// Monolithic upload: complete upload in single POST
const blobData = Buffer.isBuffer(body) ? body : Buffer.from(JSON.stringify(body));
// Verify digest
const calculatedDigest = await this.calculateDigest(blobData);
if (calculatedDigest !== digest) {
return {
status: 400,
headers: {},
body: this.createError('DIGEST_INVALID', 'Provided digest does not match uploaded content'),
};
}
// Store the blob
await this.storage.putOciBlob(digest, blobData);
return {
status: 201,
headers: {
'Location': `${this.basePath}/v2/${repository}/blobs/${digest}`,
'Docker-Content-Digest': digest,
},
body: null,
};
}
// Standard chunked upload: create session
const uploadId = this.generateUploadId();
const session: IUploadSession = {
uploadId,
@@ -334,21 +371,51 @@ export class OciRegistry extends BaseRegistry {
private async putManifest(
repository: string,
reference: string,
token: IAuthToken | null
token: IAuthToken | null,
body?: Buffer | any,
headers?: Record<string, string>
): Promise<IResponse> {
if (!await this.checkPermission(token, repository, 'push')) {
return {
status: 401,
headers: {},
headers: {
'WWW-Authenticate': `Bearer realm="${this.basePath}/v2/token",service="registry",scope="repository:${repository}:push"`,
},
body: this.createError('DENIED', 'Insufficient permissions'),
};
}
// Implementation continued in next file due to length...
if (!body) {
return {
status: 400,
headers: {},
body: this.createError('MANIFEST_INVALID', 'Manifest body is required'),
};
}
const manifestData = Buffer.isBuffer(body) ? body : Buffer.from(JSON.stringify(body));
const contentType = headers?.['content-type'] || headers?.['Content-Type'] || 'application/vnd.oci.image.manifest.v1+json';
// Calculate manifest digest
const digest = await this.calculateDigest(manifestData);
// Store manifest by digest
await this.storage.putOciManifest(repository, digest, manifestData, contentType);
// If reference is a tag (not a digest), create tag reference
if (!reference.startsWith('sha256:')) {
// Store tag -> digest mapping
const tagPath = `oci/repositories/${repository}/tags/${reference}`;
await this.storage.putObject(tagPath, Buffer.from(digest, 'utf-8'));
}
return {
status: 501,
headers: {},
body: this.createError('UNSUPPORTED', 'Not yet implemented'),
status: 201,
headers: {
'Location': `${this.basePath}/v2/${repository}/manifests/${digest}`,
'Docker-Content-Digest': digest,
},
body: null,
};
}
@@ -642,7 +709,7 @@ export class OciRegistry extends BaseRegistry {
}
private startUploadSessionCleanup(): void {
setInterval(() => {
this.cleanupInterval = setInterval(() => {
const now = new Date();
const maxAge = 60 * 60 * 1000; // 1 hour
@@ -653,4 +720,11 @@ export class OciRegistry extends BaseRegistry {
}
}, 10 * 60 * 1000);
}
public destroy(): void {
if (this.cleanupInterval) {
clearInterval(this.cleanupInterval);
this.cleanupInterval = undefined;
}
}
}