feat(oci): Support monolithic OCI blob uploads; add registry cleanup/destroy hooks; update tests and docs
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user