@push.rocks/smartregistry 2.6.0 → 2.8.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.smartconfig.json +24 -0
- package/dist_ts/00_commitinfo_data.js +1 -1
- package/dist_ts/cargo/classes.cargoregistry.d.ts +8 -3
- package/dist_ts/cargo/classes.cargoregistry.js +71 -33
- package/dist_ts/classes.smartregistry.js +48 -36
- package/dist_ts/composer/classes.composerregistry.d.ts +14 -3
- package/dist_ts/composer/classes.composerregistry.js +64 -28
- package/dist_ts/core/classes.registrystorage.d.ts +45 -0
- package/dist_ts/core/classes.registrystorage.js +116 -1
- package/dist_ts/core/helpers.stream.d.ts +20 -0
- package/dist_ts/core/helpers.stream.js +59 -0
- package/dist_ts/core/index.d.ts +1 -0
- package/dist_ts/core/index.js +3 -1
- package/dist_ts/core/interfaces.core.d.ts +28 -5
- package/dist_ts/maven/classes.mavenregistry.d.ts +14 -3
- package/dist_ts/maven/classes.mavenregistry.js +78 -27
- package/dist_ts/npm/classes.npmregistry.d.ts +14 -3
- package/dist_ts/npm/classes.npmregistry.js +121 -48
- package/dist_ts/oci/classes.ociregistry.d.ts +19 -3
- package/dist_ts/oci/classes.ociregistry.js +187 -73
- package/dist_ts/oci/classes.ociupstream.d.ts +5 -2
- package/dist_ts/oci/classes.ociupstream.js +17 -10
- package/dist_ts/oci/interfaces.oci.d.ts +4 -0
- package/dist_ts/pypi/classes.pypiregistry.d.ts +8 -3
- package/dist_ts/pypi/classes.pypiregistry.js +88 -50
- package/dist_ts/rubygems/classes.rubygemsregistry.d.ts +8 -3
- package/dist_ts/rubygems/classes.rubygemsregistry.js +61 -23
- package/dist_ts/rubygems/helpers.rubygems.js +9 -11
- package/dist_ts/upstream/classes.upstreamcache.js +2 -2
- package/dist_ts/upstream/interfaces.upstream.d.ts +72 -1
- package/dist_ts/upstream/interfaces.upstream.js +24 -1
- package/package.json +24 -20
- package/readme.md +354 -812
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/cargo/classes.cargoregistry.ts +84 -37
- package/ts/classes.smartregistry.ts +49 -35
- package/ts/composer/classes.composerregistry.ts +74 -30
- package/ts/core/classes.registrystorage.ts +133 -2
- package/ts/core/helpers.stream.ts +63 -0
- package/ts/core/index.ts +3 -0
- package/ts/core/interfaces.core.ts +29 -5
- package/ts/maven/classes.mavenregistry.ts +89 -28
- package/ts/npm/classes.npmregistry.ts +134 -49
- package/ts/oci/classes.ociregistry.ts +206 -77
- package/ts/oci/classes.ociupstream.ts +18 -8
- package/ts/oci/interfaces.oci.ts +4 -0
- package/ts/pypi/classes.pypiregistry.ts +100 -54
- package/ts/rubygems/classes.rubygemsregistry.ts +69 -24
- package/ts/rubygems/helpers.rubygems.ts +8 -10
- package/ts/upstream/classes.upstreamcache.ts +1 -1
- package/ts/upstream/interfaces.upstream.ts +82 -1
- package/npmextra.json +0 -18
|
@@ -2,8 +2,9 @@ import { Smartlog } from '@push.rocks/smartlog';
|
|
|
2
2
|
import { BaseRegistry } from '../core/classes.baseregistry.js';
|
|
3
3
|
import { RegistryStorage } from '../core/classes.registrystorage.js';
|
|
4
4
|
import { AuthManager } from '../core/classes.authmanager.js';
|
|
5
|
-
import type { IRequestContext, IResponse, IAuthToken, IRegistryError } from '../core/interfaces.core.js';
|
|
6
|
-
import
|
|
5
|
+
import type { IRequestContext, IResponse, IAuthToken, IRegistryError, IRequestActor } from '../core/interfaces.core.js';
|
|
6
|
+
import { createHashTransform, streamToBuffer } from '../core/helpers.stream.js';
|
|
7
|
+
import type { IUpstreamProvider } from '../upstream/interfaces.upstream.js';
|
|
7
8
|
import { OciUpstream } from './classes.ociupstream.js';
|
|
8
9
|
import type {
|
|
9
10
|
IUploadSession,
|
|
@@ -24,7 +25,7 @@ export class OciRegistry extends BaseRegistry {
|
|
|
24
25
|
private basePath: string = '/oci';
|
|
25
26
|
private cleanupInterval?: NodeJS.Timeout;
|
|
26
27
|
private ociTokens?: { realm: string; service: string };
|
|
27
|
-
private
|
|
28
|
+
private upstreamProvider: IUpstreamProvider | null = null;
|
|
28
29
|
private logger: Smartlog;
|
|
29
30
|
|
|
30
31
|
constructor(
|
|
@@ -32,13 +33,14 @@ export class OciRegistry extends BaseRegistry {
|
|
|
32
33
|
authManager: AuthManager,
|
|
33
34
|
basePath: string = '/oci',
|
|
34
35
|
ociTokens?: { realm: string; service: string },
|
|
35
|
-
|
|
36
|
+
upstreamProvider?: IUpstreamProvider
|
|
36
37
|
) {
|
|
37
38
|
super();
|
|
38
39
|
this.storage = storage;
|
|
39
40
|
this.authManager = authManager;
|
|
40
41
|
this.basePath = basePath;
|
|
41
42
|
this.ociTokens = ociTokens;
|
|
43
|
+
this.upstreamProvider = upstreamProvider || null;
|
|
42
44
|
|
|
43
45
|
// Initialize logger
|
|
44
46
|
this.logger = new Smartlog({
|
|
@@ -53,15 +55,50 @@ export class OciRegistry extends BaseRegistry {
|
|
|
53
55
|
});
|
|
54
56
|
this.logger.enableConsole();
|
|
55
57
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
this.upstream = new OciUpstream(upstreamConfig, basePath, this.logger);
|
|
59
|
-
this.logger.log('info', 'OCI upstream initialized', {
|
|
60
|
-
upstreams: upstreamConfig.upstreams.map(u => u.name),
|
|
61
|
-
});
|
|
58
|
+
if (upstreamProvider) {
|
|
59
|
+
this.logger.log('info', 'OCI upstream provider configured');
|
|
62
60
|
}
|
|
63
61
|
}
|
|
64
62
|
|
|
63
|
+
/**
|
|
64
|
+
* Extract scope from OCI repository name.
|
|
65
|
+
* @example "myorg/myimage" -> "myorg"
|
|
66
|
+
* @example "library/nginx" -> "library"
|
|
67
|
+
* @example "nginx" -> null
|
|
68
|
+
*/
|
|
69
|
+
private extractScope(repository: string): string | null {
|
|
70
|
+
const slashIndex = repository.indexOf('/');
|
|
71
|
+
if (slashIndex > 0) {
|
|
72
|
+
return repository.substring(0, slashIndex);
|
|
73
|
+
}
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Get upstream for a specific request.
|
|
79
|
+
* Calls the provider to resolve upstream config dynamically.
|
|
80
|
+
*/
|
|
81
|
+
private async getUpstreamForRequest(
|
|
82
|
+
resource: string,
|
|
83
|
+
resourceType: string,
|
|
84
|
+
method: string,
|
|
85
|
+
actor?: IRequestActor
|
|
86
|
+
): Promise<OciUpstream | null> {
|
|
87
|
+
if (!this.upstreamProvider) return null;
|
|
88
|
+
|
|
89
|
+
const config = await this.upstreamProvider.resolveUpstreamConfig({
|
|
90
|
+
protocol: 'oci',
|
|
91
|
+
resource,
|
|
92
|
+
scope: this.extractScope(resource),
|
|
93
|
+
actor,
|
|
94
|
+
method,
|
|
95
|
+
resourceType,
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
if (!config?.enabled) return null;
|
|
99
|
+
return new OciUpstream(config, this.basePath, this.logger);
|
|
100
|
+
}
|
|
101
|
+
|
|
65
102
|
public async init(): Promise<void> {
|
|
66
103
|
// Start cleanup of stale upload sessions
|
|
67
104
|
this.startUploadSessionCleanup();
|
|
@@ -80,29 +117,38 @@ export class OciRegistry extends BaseRegistry {
|
|
|
80
117
|
const tokenString = authHeader?.replace(/^Bearer\s+/i, '');
|
|
81
118
|
const token = tokenString ? await this.authManager.validateToken(tokenString, 'oci') : null;
|
|
82
119
|
|
|
120
|
+
// Build actor from context and validated token
|
|
121
|
+
const actor: IRequestActor = {
|
|
122
|
+
...context.actor,
|
|
123
|
+
userId: token?.userId,
|
|
124
|
+
ip: context.headers['x-forwarded-for'] || context.headers['X-Forwarded-For'],
|
|
125
|
+
userAgent: context.headers['user-agent'] || context.headers['User-Agent'],
|
|
126
|
+
};
|
|
127
|
+
|
|
83
128
|
// Route to appropriate handler
|
|
84
|
-
|
|
129
|
+
// OCI spec: GET /v2/ is the version check endpoint
|
|
130
|
+
if (path === '/' || path === '' || path === '/v2/' || path === '/v2') {
|
|
85
131
|
return this.handleVersionCheck();
|
|
86
132
|
}
|
|
87
133
|
|
|
88
|
-
// Manifest operations: /
|
|
89
|
-
const manifestMatch = path.match(/^\/
|
|
134
|
+
// Manifest operations: /{name}/manifests/{reference}
|
|
135
|
+
const manifestMatch = path.match(/^\/([^\/]+(?:\/[^\/]+)*)\/manifests\/([^\/]+)$/);
|
|
90
136
|
if (manifestMatch) {
|
|
91
137
|
const [, name, reference] = manifestMatch;
|
|
92
138
|
// Prefer rawBody for content-addressable operations to preserve exact bytes
|
|
93
139
|
const bodyData = context.rawBody || context.body;
|
|
94
|
-
return this.handleManifestRequest(context.method, name, reference, token, bodyData, context.headers);
|
|
140
|
+
return this.handleManifestRequest(context.method, name, reference, token, bodyData, context.headers, actor);
|
|
95
141
|
}
|
|
96
142
|
|
|
97
|
-
// Blob operations: /
|
|
98
|
-
const blobMatch = path.match(/^\/
|
|
143
|
+
// Blob operations: /{name}/blobs/{digest}
|
|
144
|
+
const blobMatch = path.match(/^\/([^\/]+(?:\/[^\/]+)*)\/blobs\/(sha256:[a-f0-9]{64})$/);
|
|
99
145
|
if (blobMatch) {
|
|
100
146
|
const [, name, digest] = blobMatch;
|
|
101
|
-
return this.handleBlobRequest(context.method, name, digest, token, context.headers);
|
|
147
|
+
return this.handleBlobRequest(context.method, name, digest, token, context.headers, actor);
|
|
102
148
|
}
|
|
103
149
|
|
|
104
|
-
// Blob upload operations: /
|
|
105
|
-
const uploadInitMatch = path.match(/^\/
|
|
150
|
+
// Blob upload operations: /{name}/blobs/uploads/
|
|
151
|
+
const uploadInitMatch = path.match(/^\/([^\/]+(?:\/[^\/]+)*)\/blobs\/uploads\/?$/);
|
|
106
152
|
if (uploadInitMatch && context.method === 'POST') {
|
|
107
153
|
const [, name] = uploadInitMatch;
|
|
108
154
|
// Prefer rawBody for content-addressable operations to preserve exact bytes
|
|
@@ -110,22 +156,22 @@ export class OciRegistry extends BaseRegistry {
|
|
|
110
156
|
return this.handleUploadInit(name, token, context.query, bodyData);
|
|
111
157
|
}
|
|
112
158
|
|
|
113
|
-
// Blob upload operations: /
|
|
114
|
-
const uploadMatch = path.match(/^\/
|
|
159
|
+
// Blob upload operations: /{name}/blobs/uploads/{uuid}
|
|
160
|
+
const uploadMatch = path.match(/^\/([^\/]+(?:\/[^\/]+)*)\/blobs\/uploads\/([^\/]+)$/);
|
|
115
161
|
if (uploadMatch) {
|
|
116
162
|
const [, name, uploadId] = uploadMatch;
|
|
117
163
|
return this.handleUploadSession(context.method, uploadId, token, context);
|
|
118
164
|
}
|
|
119
165
|
|
|
120
|
-
// Tags list: /
|
|
121
|
-
const tagsMatch = path.match(/^\/
|
|
166
|
+
// Tags list: /{name}/tags/list
|
|
167
|
+
const tagsMatch = path.match(/^\/([^\/]+(?:\/[^\/]+)*)\/tags\/list$/);
|
|
122
168
|
if (tagsMatch) {
|
|
123
169
|
const [, name] = tagsMatch;
|
|
124
170
|
return this.handleTagsList(name, token, context.query);
|
|
125
171
|
}
|
|
126
172
|
|
|
127
|
-
// Referrers: /
|
|
128
|
-
const referrersMatch = path.match(/^\/
|
|
173
|
+
// Referrers: /{name}/referrers/{digest}
|
|
174
|
+
const referrersMatch = path.match(/^\/([^\/]+(?:\/[^\/]+)*)\/referrers\/(sha256:[a-f0-9]{64})$/);
|
|
129
175
|
if (referrersMatch) {
|
|
130
176
|
const [, name, digest] = referrersMatch;
|
|
131
177
|
return this.handleReferrers(name, digest, token, context.query);
|
|
@@ -168,11 +214,12 @@ export class OciRegistry extends BaseRegistry {
|
|
|
168
214
|
reference: string,
|
|
169
215
|
token: IAuthToken | null,
|
|
170
216
|
body?: Buffer | any,
|
|
171
|
-
headers?: Record<string, string
|
|
217
|
+
headers?: Record<string, string>,
|
|
218
|
+
actor?: IRequestActor
|
|
172
219
|
): Promise<IResponse> {
|
|
173
220
|
switch (method) {
|
|
174
221
|
case 'GET':
|
|
175
|
-
return this.getManifest(repository, reference, token, headers);
|
|
222
|
+
return this.getManifest(repository, reference, token, headers, actor);
|
|
176
223
|
case 'HEAD':
|
|
177
224
|
return this.headManifest(repository, reference, token);
|
|
178
225
|
case 'PUT':
|
|
@@ -193,11 +240,12 @@ export class OciRegistry extends BaseRegistry {
|
|
|
193
240
|
repository: string,
|
|
194
241
|
digest: string,
|
|
195
242
|
token: IAuthToken | null,
|
|
196
|
-
headers: Record<string, string
|
|
243
|
+
headers: Record<string, string>,
|
|
244
|
+
actor?: IRequestActor
|
|
197
245
|
): Promise<IResponse> {
|
|
198
246
|
switch (method) {
|
|
199
247
|
case 'GET':
|
|
200
|
-
return this.getBlob(repository, digest, token, headers['range'] || headers['Range']);
|
|
248
|
+
return this.getBlob(repository, digest, token, headers['range'] || headers['Range'], actor);
|
|
201
249
|
case 'HEAD':
|
|
202
250
|
return this.headBlob(repository, digest, token);
|
|
203
251
|
case 'DELETE':
|
|
@@ -243,7 +291,7 @@ export class OciRegistry extends BaseRegistry {
|
|
|
243
291
|
return {
|
|
244
292
|
status: 201,
|
|
245
293
|
headers: {
|
|
246
|
-
'Location': `${this.basePath}
|
|
294
|
+
'Location': `${this.basePath}/${repository}/blobs/${digest}`,
|
|
247
295
|
'Docker-Content-Digest': digest,
|
|
248
296
|
},
|
|
249
297
|
body: null,
|
|
@@ -256,6 +304,8 @@ export class OciRegistry extends BaseRegistry {
|
|
|
256
304
|
uploadId,
|
|
257
305
|
repository,
|
|
258
306
|
chunks: [],
|
|
307
|
+
chunkPaths: [],
|
|
308
|
+
chunkIndex: 0,
|
|
259
309
|
totalSize: 0,
|
|
260
310
|
createdAt: new Date(),
|
|
261
311
|
lastActivity: new Date(),
|
|
@@ -266,7 +316,7 @@ export class OciRegistry extends BaseRegistry {
|
|
|
266
316
|
return {
|
|
267
317
|
status: 202,
|
|
268
318
|
headers: {
|
|
269
|
-
'Location': `${this.basePath}
|
|
319
|
+
'Location': `${this.basePath}/${repository}/blobs/uploads/${uploadId}`,
|
|
270
320
|
'Docker-Upload-UUID': uploadId,
|
|
271
321
|
},
|
|
272
322
|
body: null,
|
|
@@ -318,7 +368,8 @@ export class OciRegistry extends BaseRegistry {
|
|
|
318
368
|
repository: string,
|
|
319
369
|
reference: string,
|
|
320
370
|
token: IAuthToken | null,
|
|
321
|
-
headers?: Record<string, string
|
|
371
|
+
headers?: Record<string, string>,
|
|
372
|
+
actor?: IRequestActor
|
|
322
373
|
): Promise<IResponse> {
|
|
323
374
|
if (!await this.checkPermission(token, repository, 'pull')) {
|
|
324
375
|
return this.createUnauthorizedResponse(repository, 'pull');
|
|
@@ -346,30 +397,33 @@ export class OciRegistry extends BaseRegistry {
|
|
|
346
397
|
}
|
|
347
398
|
|
|
348
399
|
// If not found locally, try upstream
|
|
349
|
-
if (!manifestData
|
|
350
|
-
this.
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
400
|
+
if (!manifestData) {
|
|
401
|
+
const upstream = await this.getUpstreamForRequest(repository, 'manifest', 'GET', actor);
|
|
402
|
+
if (upstream) {
|
|
403
|
+
this.logger.log('debug', 'getManifest: fetching from upstream', { repository, reference });
|
|
404
|
+
const upstreamResult = await upstream.fetchManifest(repository, reference);
|
|
405
|
+
if (upstreamResult) {
|
|
406
|
+
manifestData = Buffer.from(JSON.stringify(upstreamResult.manifest), 'utf8');
|
|
407
|
+
contentType = upstreamResult.contentType;
|
|
408
|
+
digest = upstreamResult.digest;
|
|
409
|
+
|
|
410
|
+
// Cache the manifest locally
|
|
411
|
+
await this.storage.putOciManifest(repository, digest, manifestData, contentType);
|
|
412
|
+
|
|
413
|
+
// If reference is a tag, update tags mapping
|
|
414
|
+
if (!reference.startsWith('sha256:')) {
|
|
415
|
+
const tags = await this.getTagsData(repository);
|
|
416
|
+
tags[reference] = digest;
|
|
417
|
+
const tagsPath = `oci/tags/${repository}/tags.json`;
|
|
418
|
+
await this.storage.putObject(tagsPath, Buffer.from(JSON.stringify(tags), 'utf-8'));
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
this.logger.log('debug', 'getManifest: cached manifest locally', {
|
|
422
|
+
repository,
|
|
423
|
+
reference,
|
|
424
|
+
digest,
|
|
425
|
+
});
|
|
366
426
|
}
|
|
367
|
-
|
|
368
|
-
this.logger.log('debug', 'getManifest: cached manifest locally', {
|
|
369
|
-
repository,
|
|
370
|
-
reference,
|
|
371
|
-
digest,
|
|
372
|
-
});
|
|
373
427
|
}
|
|
374
428
|
}
|
|
375
429
|
|
|
@@ -477,7 +531,7 @@ export class OciRegistry extends BaseRegistry {
|
|
|
477
531
|
return {
|
|
478
532
|
status: 201,
|
|
479
533
|
headers: {
|
|
480
|
-
'Location': `${this.basePath}
|
|
534
|
+
'Location': `${this.basePath}/${repository}/manifests/${digest}`,
|
|
481
535
|
'Docker-Content-Digest': digest,
|
|
482
536
|
},
|
|
483
537
|
body: null,
|
|
@@ -514,19 +568,33 @@ export class OciRegistry extends BaseRegistry {
|
|
|
514
568
|
repository: string,
|
|
515
569
|
digest: string,
|
|
516
570
|
token: IAuthToken | null,
|
|
517
|
-
range?: string
|
|
571
|
+
range?: string,
|
|
572
|
+
actor?: IRequestActor
|
|
518
573
|
): Promise<IResponse> {
|
|
519
574
|
if (!await this.checkPermission(token, repository, 'pull')) {
|
|
520
575
|
return this.createUnauthorizedResponse(repository, 'pull');
|
|
521
576
|
}
|
|
522
577
|
|
|
523
|
-
// Try local storage first
|
|
524
|
-
|
|
578
|
+
// Try local storage first (streaming)
|
|
579
|
+
const streamResult = await this.storage.getOciBlobStream(digest);
|
|
580
|
+
if (streamResult) {
|
|
581
|
+
return {
|
|
582
|
+
status: 200,
|
|
583
|
+
headers: {
|
|
584
|
+
'Content-Type': 'application/octet-stream',
|
|
585
|
+
'Content-Length': streamResult.size.toString(),
|
|
586
|
+
'Docker-Content-Digest': digest,
|
|
587
|
+
},
|
|
588
|
+
body: streamResult.stream,
|
|
589
|
+
};
|
|
590
|
+
}
|
|
525
591
|
|
|
526
592
|
// If not found locally, try upstream
|
|
527
|
-
|
|
593
|
+
let data: Buffer | null = null;
|
|
594
|
+
const upstream = await this.getUpstreamForRequest(repository, 'blob', 'GET', actor);
|
|
595
|
+
if (upstream) {
|
|
528
596
|
this.logger.log('debug', 'getBlob: fetching from upstream', { repository, digest });
|
|
529
|
-
const upstreamBlob = await
|
|
597
|
+
const upstreamBlob = await upstream.fetchBlob(repository, digest);
|
|
530
598
|
if (upstreamBlob) {
|
|
531
599
|
data = upstreamBlob;
|
|
532
600
|
// Cache the blob locally (blobs are content-addressable and immutable)
|
|
@@ -566,17 +634,15 @@ export class OciRegistry extends BaseRegistry {
|
|
|
566
634
|
return this.createUnauthorizedHeadResponse(repository, 'pull');
|
|
567
635
|
}
|
|
568
636
|
|
|
569
|
-
const
|
|
570
|
-
if (
|
|
637
|
+
const blobSize = await this.storage.getOciBlobSize(digest);
|
|
638
|
+
if (blobSize === null) {
|
|
571
639
|
return { status: 404, headers: {}, body: null };
|
|
572
640
|
}
|
|
573
641
|
|
|
574
|
-
const blob = await this.storage.getOciBlob(digest);
|
|
575
|
-
|
|
576
642
|
return {
|
|
577
643
|
status: 200,
|
|
578
644
|
headers: {
|
|
579
|
-
'Content-Length':
|
|
645
|
+
'Content-Length': blobSize.toString(),
|
|
580
646
|
'Docker-Content-Digest': digest,
|
|
581
647
|
},
|
|
582
648
|
body: null,
|
|
@@ -616,14 +682,19 @@ export class OciRegistry extends BaseRegistry {
|
|
|
616
682
|
}
|
|
617
683
|
|
|
618
684
|
const chunkData = this.toBuffer(data);
|
|
619
|
-
|
|
685
|
+
|
|
686
|
+
// Write chunk to temp S3 object instead of accumulating in memory
|
|
687
|
+
const chunkPath = `oci/uploads/${uploadId}/chunk-${session.chunkIndex}`;
|
|
688
|
+
await this.storage.putObject(chunkPath, chunkData);
|
|
689
|
+
session.chunkPaths.push(chunkPath);
|
|
690
|
+
session.chunkIndex++;
|
|
620
691
|
session.totalSize += chunkData.length;
|
|
621
692
|
session.lastActivity = new Date();
|
|
622
693
|
|
|
623
694
|
return {
|
|
624
695
|
status: 202,
|
|
625
696
|
headers: {
|
|
626
|
-
'Location': `${this.basePath}
|
|
697
|
+
'Location': `${this.basePath}/${session.repository}/blobs/uploads/${uploadId}`,
|
|
627
698
|
'Range': `0-${session.totalSize - 1}`,
|
|
628
699
|
'Docker-Upload-UUID': uploadId,
|
|
629
700
|
},
|
|
@@ -645,13 +716,52 @@ export class OciRegistry extends BaseRegistry {
|
|
|
645
716
|
};
|
|
646
717
|
}
|
|
647
718
|
|
|
648
|
-
|
|
649
|
-
if (finalData)
|
|
650
|
-
|
|
719
|
+
// If there's final data in the PUT body, write it as the last chunk
|
|
720
|
+
if (finalData) {
|
|
721
|
+
const buf = this.toBuffer(finalData);
|
|
722
|
+
const chunkPath = `oci/uploads/${uploadId}/chunk-${session.chunkIndex}`;
|
|
723
|
+
await this.storage.putObject(chunkPath, buf);
|
|
724
|
+
session.chunkPaths.push(chunkPath);
|
|
725
|
+
session.chunkIndex++;
|
|
726
|
+
session.totalSize += buf.length;
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
// Create a ReadableStream that assembles all chunks from S3 sequentially
|
|
730
|
+
const chunkPaths = [...session.chunkPaths];
|
|
731
|
+
const storage = this.storage;
|
|
732
|
+
let chunkIdx = 0;
|
|
733
|
+
const assembledStream = new ReadableStream<Uint8Array>({
|
|
734
|
+
async pull(controller) {
|
|
735
|
+
if (chunkIdx >= chunkPaths.length) {
|
|
736
|
+
controller.close();
|
|
737
|
+
return;
|
|
738
|
+
}
|
|
739
|
+
const result = await storage.getObjectStream(chunkPaths[chunkIdx++]);
|
|
740
|
+
if (result) {
|
|
741
|
+
const reader = result.stream.getReader();
|
|
742
|
+
while (true) {
|
|
743
|
+
const { done, value } = await reader.read();
|
|
744
|
+
if (done) break;
|
|
745
|
+
if (value) controller.enqueue(value);
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
},
|
|
749
|
+
});
|
|
750
|
+
|
|
751
|
+
// Pipe through hash transform for incremental digest verification
|
|
752
|
+
const { transform: hashTransform, getDigest } = createHashTransform('sha256');
|
|
753
|
+
const hashedStream = assembledStream.pipeThrough(hashTransform);
|
|
651
754
|
|
|
652
|
-
//
|
|
653
|
-
|
|
755
|
+
// Consume stream to buffer for S3 upload
|
|
756
|
+
// (AWS SDK PutObjectCommand requires known content-length for streams;
|
|
757
|
+
// the key win is chunks are NOT accumulated in memory during PATCH — they live in S3)
|
|
758
|
+
const blobData = await streamToBuffer(hashedStream);
|
|
759
|
+
|
|
760
|
+
// Verify digest before storing
|
|
761
|
+
const calculatedDigest = `sha256:${getDigest()}`;
|
|
654
762
|
if (calculatedDigest !== digest) {
|
|
763
|
+
await this.cleanupUploadChunks(session);
|
|
764
|
+
this.uploadSessions.delete(uploadId);
|
|
655
765
|
return {
|
|
656
766
|
status: 400,
|
|
657
767
|
headers: {},
|
|
@@ -659,19 +769,36 @@ export class OciRegistry extends BaseRegistry {
|
|
|
659
769
|
};
|
|
660
770
|
}
|
|
661
771
|
|
|
772
|
+
// Store verified blob
|
|
662
773
|
await this.storage.putOciBlob(digest, blobData);
|
|
774
|
+
|
|
775
|
+
// Cleanup temp chunks and session
|
|
776
|
+
await this.cleanupUploadChunks(session);
|
|
663
777
|
this.uploadSessions.delete(uploadId);
|
|
664
778
|
|
|
665
779
|
return {
|
|
666
780
|
status: 201,
|
|
667
781
|
headers: {
|
|
668
|
-
'Location': `${this.basePath}
|
|
782
|
+
'Location': `${this.basePath}/${session.repository}/blobs/${digest}`,
|
|
669
783
|
'Docker-Content-Digest': digest,
|
|
670
784
|
},
|
|
671
785
|
body: null,
|
|
672
786
|
};
|
|
673
787
|
}
|
|
674
788
|
|
|
789
|
+
/**
|
|
790
|
+
* Delete all temp S3 chunk objects for an upload session.
|
|
791
|
+
*/
|
|
792
|
+
private async cleanupUploadChunks(session: IUploadSession): Promise<void> {
|
|
793
|
+
for (const chunkPath of session.chunkPaths) {
|
|
794
|
+
try {
|
|
795
|
+
await this.storage.deleteObject(chunkPath);
|
|
796
|
+
} catch {
|
|
797
|
+
// Best-effort cleanup
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
|
|
675
802
|
private async getUploadStatus(uploadId: string): Promise<IResponse> {
|
|
676
803
|
const session = this.uploadSessions.get(uploadId);
|
|
677
804
|
if (!session) {
|
|
@@ -685,7 +812,7 @@ export class OciRegistry extends BaseRegistry {
|
|
|
685
812
|
return {
|
|
686
813
|
status: 204,
|
|
687
814
|
headers: {
|
|
688
|
-
'Location': `${this.basePath}
|
|
815
|
+
'Location': `${this.basePath}/${session.repository}/blobs/uploads/${uploadId}`,
|
|
689
816
|
'Range': session.totalSize > 0 ? `0-${session.totalSize - 1}` : '0-0',
|
|
690
817
|
'Docker-Upload-UUID': uploadId,
|
|
691
818
|
},
|
|
@@ -830,7 +957,7 @@ export class OciRegistry extends BaseRegistry {
|
|
|
830
957
|
* Per OCI Distribution Spec, 401 responses MUST include WWW-Authenticate header.
|
|
831
958
|
*/
|
|
832
959
|
private createUnauthorizedResponse(repository: string, action: string): IResponse {
|
|
833
|
-
const realm = this.ociTokens?.realm || `${this.basePath}/
|
|
960
|
+
const realm = this.ociTokens?.realm || `${this.basePath}/token`;
|
|
834
961
|
const service = this.ociTokens?.service || 'registry';
|
|
835
962
|
return {
|
|
836
963
|
status: 401,
|
|
@@ -845,7 +972,7 @@ export class OciRegistry extends BaseRegistry {
|
|
|
845
972
|
* Create an unauthorized HEAD response (no body per HTTP spec).
|
|
846
973
|
*/
|
|
847
974
|
private createUnauthorizedHeadResponse(repository: string, action: string): IResponse {
|
|
848
|
-
const realm = this.ociTokens?.realm || `${this.basePath}/
|
|
975
|
+
const realm = this.ociTokens?.realm || `${this.basePath}/token`;
|
|
849
976
|
const service = this.ociTokens?.service || 'registry';
|
|
850
977
|
return {
|
|
851
978
|
status: 401,
|
|
@@ -863,6 +990,8 @@ export class OciRegistry extends BaseRegistry {
|
|
|
863
990
|
|
|
864
991
|
for (const [uploadId, session] of this.uploadSessions.entries()) {
|
|
865
992
|
if (now.getTime() - session.lastActivity.getTime() > maxAge) {
|
|
993
|
+
// Clean up temp S3 chunks for stale sessions
|
|
994
|
+
this.cleanupUploadChunks(session).catch(() => {});
|
|
866
995
|
this.uploadSessions.delete(uploadId);
|
|
867
996
|
}
|
|
868
997
|
}
|
|
@@ -24,13 +24,18 @@ export class OciUpstream extends BaseUpstream {
|
|
|
24
24
|
/** Local registry base path for URL building */
|
|
25
25
|
private readonly localBasePath: string;
|
|
26
26
|
|
|
27
|
+
/** API prefix for outbound OCI requests (default: /v2) */
|
|
28
|
+
private readonly apiPrefix: string;
|
|
29
|
+
|
|
27
30
|
constructor(
|
|
28
31
|
config: IProtocolUpstreamConfig,
|
|
29
32
|
localBasePath: string = '/oci',
|
|
30
33
|
logger?: plugins.smartlog.Smartlog,
|
|
34
|
+
apiPrefix: string = '/v2',
|
|
31
35
|
) {
|
|
32
36
|
super(config, logger);
|
|
33
37
|
this.localBasePath = localBasePath;
|
|
38
|
+
this.apiPrefix = apiPrefix;
|
|
34
39
|
}
|
|
35
40
|
|
|
36
41
|
/**
|
|
@@ -44,7 +49,7 @@ export class OciUpstream extends BaseUpstream {
|
|
|
44
49
|
protocol: 'oci',
|
|
45
50
|
resource: repository,
|
|
46
51
|
resourceType: 'manifest',
|
|
47
|
-
path:
|
|
52
|
+
path: `${this.apiPrefix}/${repository}/manifests/${reference}`,
|
|
48
53
|
method: 'GET',
|
|
49
54
|
headers: {
|
|
50
55
|
'accept': [
|
|
@@ -88,7 +93,7 @@ export class OciUpstream extends BaseUpstream {
|
|
|
88
93
|
protocol: 'oci',
|
|
89
94
|
resource: repository,
|
|
90
95
|
resourceType: 'manifest',
|
|
91
|
-
path:
|
|
96
|
+
path: `${this.apiPrefix}/${repository}/manifests/${reference}`,
|
|
92
97
|
method: 'HEAD',
|
|
93
98
|
headers: {
|
|
94
99
|
'accept': [
|
|
@@ -127,7 +132,7 @@ export class OciUpstream extends BaseUpstream {
|
|
|
127
132
|
protocol: 'oci',
|
|
128
133
|
resource: repository,
|
|
129
134
|
resourceType: 'blob',
|
|
130
|
-
path:
|
|
135
|
+
path: `${this.apiPrefix}/${repository}/blobs/${digest}`,
|
|
131
136
|
method: 'GET',
|
|
132
137
|
headers: {
|
|
133
138
|
'accept': 'application/octet-stream',
|
|
@@ -155,7 +160,7 @@ export class OciUpstream extends BaseUpstream {
|
|
|
155
160
|
protocol: 'oci',
|
|
156
161
|
resource: repository,
|
|
157
162
|
resourceType: 'blob',
|
|
158
|
-
path:
|
|
163
|
+
path: `${this.apiPrefix}/${repository}/blobs/${digest}`,
|
|
159
164
|
method: 'HEAD',
|
|
160
165
|
headers: {},
|
|
161
166
|
query: {},
|
|
@@ -189,7 +194,7 @@ export class OciUpstream extends BaseUpstream {
|
|
|
189
194
|
protocol: 'oci',
|
|
190
195
|
resource: repository,
|
|
191
196
|
resourceType: 'tags',
|
|
192
|
-
path:
|
|
197
|
+
path: `${this.apiPrefix}/${repository}/tags/list`,
|
|
193
198
|
method: 'GET',
|
|
194
199
|
headers: {
|
|
195
200
|
'accept': 'application/json',
|
|
@@ -215,7 +220,8 @@ export class OciUpstream extends BaseUpstream {
|
|
|
215
220
|
|
|
216
221
|
/**
|
|
217
222
|
* Override URL building for OCI-specific handling.
|
|
218
|
-
* OCI registries use /v2/
|
|
223
|
+
* OCI registries use a configurable API prefix (default /v2/) and may require
|
|
224
|
+
* special handling for Docker Hub.
|
|
219
225
|
*/
|
|
220
226
|
protected buildUpstreamUrl(
|
|
221
227
|
upstream: IUpstreamRegistryConfig,
|
|
@@ -228,16 +234,20 @@ export class OciUpstream extends BaseUpstream {
|
|
|
228
234
|
baseUrl = baseUrl.slice(0, -1);
|
|
229
235
|
}
|
|
230
236
|
|
|
237
|
+
// Use per-upstream apiPrefix if configured, otherwise use the instance default
|
|
238
|
+
const prefix = upstream.apiPrefix || this.apiPrefix;
|
|
239
|
+
|
|
231
240
|
// Handle Docker Hub special case
|
|
232
241
|
// Docker Hub uses registry-1.docker.io but library images need special handling
|
|
233
242
|
if (baseUrl.includes('docker.io') || baseUrl.includes('registry-1.docker.io')) {
|
|
234
243
|
// For library images (e.g., "nginx" -> "library/nginx")
|
|
235
|
-
const
|
|
244
|
+
const escapedPrefix = prefix.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
245
|
+
const pathParts = context.path.match(new RegExp(`^${escapedPrefix}\\/([^\\/]+)\\/(.+)$`));
|
|
236
246
|
if (pathParts) {
|
|
237
247
|
const [, repository, rest] = pathParts;
|
|
238
248
|
// If repository doesn't contain a slash, it's a library image
|
|
239
249
|
if (!repository.includes('/')) {
|
|
240
|
-
return `${baseUrl}/
|
|
250
|
+
return `${baseUrl}${prefix}/library/${repository}/${rest}`;
|
|
241
251
|
}
|
|
242
252
|
}
|
|
243
253
|
}
|
package/ts/oci/interfaces.oci.ts
CHANGED
|
@@ -62,6 +62,10 @@ export interface IUploadSession {
|
|
|
62
62
|
uploadId: string;
|
|
63
63
|
repository: string;
|
|
64
64
|
chunks: Buffer[];
|
|
65
|
+
/** S3 paths to temp chunk objects (streaming mode) */
|
|
66
|
+
chunkPaths: string[];
|
|
67
|
+
/** Index counter for naming temp chunk objects */
|
|
68
|
+
chunkIndex: number;
|
|
65
69
|
totalSize: number;
|
|
66
70
|
createdAt: Date;
|
|
67
71
|
lastActivity: Date;
|