@push.rocks/smartregistry 2.6.0 → 2.8.0
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 +104 -48
- package/dist_ts/oci/classes.ociregistry.d.ts +19 -3
- package/dist_ts/oci/classes.ociregistry.js +186 -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 +3 -3
- 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 +118 -49
- package/ts/oci/classes.ociregistry.ts +205 -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 +2 -2
- 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,37 @@ 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
|
-
if (path === '/
|
|
129
|
+
if (path === '/' || path === '') {
|
|
85
130
|
return this.handleVersionCheck();
|
|
86
131
|
}
|
|
87
132
|
|
|
88
|
-
// Manifest operations: /
|
|
89
|
-
const manifestMatch = path.match(/^\/
|
|
133
|
+
// Manifest operations: /{name}/manifests/{reference}
|
|
134
|
+
const manifestMatch = path.match(/^\/([^\/]+(?:\/[^\/]+)*)\/manifests\/([^\/]+)$/);
|
|
90
135
|
if (manifestMatch) {
|
|
91
136
|
const [, name, reference] = manifestMatch;
|
|
92
137
|
// Prefer rawBody for content-addressable operations to preserve exact bytes
|
|
93
138
|
const bodyData = context.rawBody || context.body;
|
|
94
|
-
return this.handleManifestRequest(context.method, name, reference, token, bodyData, context.headers);
|
|
139
|
+
return this.handleManifestRequest(context.method, name, reference, token, bodyData, context.headers, actor);
|
|
95
140
|
}
|
|
96
141
|
|
|
97
|
-
// Blob operations: /
|
|
98
|
-
const blobMatch = path.match(/^\/
|
|
142
|
+
// Blob operations: /{name}/blobs/{digest}
|
|
143
|
+
const blobMatch = path.match(/^\/([^\/]+(?:\/[^\/]+)*)\/blobs\/(sha256:[a-f0-9]{64})$/);
|
|
99
144
|
if (blobMatch) {
|
|
100
145
|
const [, name, digest] = blobMatch;
|
|
101
|
-
return this.handleBlobRequest(context.method, name, digest, token, context.headers);
|
|
146
|
+
return this.handleBlobRequest(context.method, name, digest, token, context.headers, actor);
|
|
102
147
|
}
|
|
103
148
|
|
|
104
|
-
// Blob upload operations: /
|
|
105
|
-
const uploadInitMatch = path.match(/^\/
|
|
149
|
+
// Blob upload operations: /{name}/blobs/uploads/
|
|
150
|
+
const uploadInitMatch = path.match(/^\/([^\/]+(?:\/[^\/]+)*)\/blobs\/uploads\/?$/);
|
|
106
151
|
if (uploadInitMatch && context.method === 'POST') {
|
|
107
152
|
const [, name] = uploadInitMatch;
|
|
108
153
|
// Prefer rawBody for content-addressable operations to preserve exact bytes
|
|
@@ -110,22 +155,22 @@ export class OciRegistry extends BaseRegistry {
|
|
|
110
155
|
return this.handleUploadInit(name, token, context.query, bodyData);
|
|
111
156
|
}
|
|
112
157
|
|
|
113
|
-
// Blob upload operations: /
|
|
114
|
-
const uploadMatch = path.match(/^\/
|
|
158
|
+
// Blob upload operations: /{name}/blobs/uploads/{uuid}
|
|
159
|
+
const uploadMatch = path.match(/^\/([^\/]+(?:\/[^\/]+)*)\/blobs\/uploads\/([^\/]+)$/);
|
|
115
160
|
if (uploadMatch) {
|
|
116
161
|
const [, name, uploadId] = uploadMatch;
|
|
117
162
|
return this.handleUploadSession(context.method, uploadId, token, context);
|
|
118
163
|
}
|
|
119
164
|
|
|
120
|
-
// Tags list: /
|
|
121
|
-
const tagsMatch = path.match(/^\/
|
|
165
|
+
// Tags list: /{name}/tags/list
|
|
166
|
+
const tagsMatch = path.match(/^\/([^\/]+(?:\/[^\/]+)*)\/tags\/list$/);
|
|
122
167
|
if (tagsMatch) {
|
|
123
168
|
const [, name] = tagsMatch;
|
|
124
169
|
return this.handleTagsList(name, token, context.query);
|
|
125
170
|
}
|
|
126
171
|
|
|
127
|
-
// Referrers: /
|
|
128
|
-
const referrersMatch = path.match(/^\/
|
|
172
|
+
// Referrers: /{name}/referrers/{digest}
|
|
173
|
+
const referrersMatch = path.match(/^\/([^\/]+(?:\/[^\/]+)*)\/referrers\/(sha256:[a-f0-9]{64})$/);
|
|
129
174
|
if (referrersMatch) {
|
|
130
175
|
const [, name, digest] = referrersMatch;
|
|
131
176
|
return this.handleReferrers(name, digest, token, context.query);
|
|
@@ -168,11 +213,12 @@ export class OciRegistry extends BaseRegistry {
|
|
|
168
213
|
reference: string,
|
|
169
214
|
token: IAuthToken | null,
|
|
170
215
|
body?: Buffer | any,
|
|
171
|
-
headers?: Record<string, string
|
|
216
|
+
headers?: Record<string, string>,
|
|
217
|
+
actor?: IRequestActor
|
|
172
218
|
): Promise<IResponse> {
|
|
173
219
|
switch (method) {
|
|
174
220
|
case 'GET':
|
|
175
|
-
return this.getManifest(repository, reference, token, headers);
|
|
221
|
+
return this.getManifest(repository, reference, token, headers, actor);
|
|
176
222
|
case 'HEAD':
|
|
177
223
|
return this.headManifest(repository, reference, token);
|
|
178
224
|
case 'PUT':
|
|
@@ -193,11 +239,12 @@ export class OciRegistry extends BaseRegistry {
|
|
|
193
239
|
repository: string,
|
|
194
240
|
digest: string,
|
|
195
241
|
token: IAuthToken | null,
|
|
196
|
-
headers: Record<string, string
|
|
242
|
+
headers: Record<string, string>,
|
|
243
|
+
actor?: IRequestActor
|
|
197
244
|
): Promise<IResponse> {
|
|
198
245
|
switch (method) {
|
|
199
246
|
case 'GET':
|
|
200
|
-
return this.getBlob(repository, digest, token, headers['range'] || headers['Range']);
|
|
247
|
+
return this.getBlob(repository, digest, token, headers['range'] || headers['Range'], actor);
|
|
201
248
|
case 'HEAD':
|
|
202
249
|
return this.headBlob(repository, digest, token);
|
|
203
250
|
case 'DELETE':
|
|
@@ -243,7 +290,7 @@ export class OciRegistry extends BaseRegistry {
|
|
|
243
290
|
return {
|
|
244
291
|
status: 201,
|
|
245
292
|
headers: {
|
|
246
|
-
'Location': `${this.basePath}
|
|
293
|
+
'Location': `${this.basePath}/${repository}/blobs/${digest}`,
|
|
247
294
|
'Docker-Content-Digest': digest,
|
|
248
295
|
},
|
|
249
296
|
body: null,
|
|
@@ -256,6 +303,8 @@ export class OciRegistry extends BaseRegistry {
|
|
|
256
303
|
uploadId,
|
|
257
304
|
repository,
|
|
258
305
|
chunks: [],
|
|
306
|
+
chunkPaths: [],
|
|
307
|
+
chunkIndex: 0,
|
|
259
308
|
totalSize: 0,
|
|
260
309
|
createdAt: new Date(),
|
|
261
310
|
lastActivity: new Date(),
|
|
@@ -266,7 +315,7 @@ export class OciRegistry extends BaseRegistry {
|
|
|
266
315
|
return {
|
|
267
316
|
status: 202,
|
|
268
317
|
headers: {
|
|
269
|
-
'Location': `${this.basePath}
|
|
318
|
+
'Location': `${this.basePath}/${repository}/blobs/uploads/${uploadId}`,
|
|
270
319
|
'Docker-Upload-UUID': uploadId,
|
|
271
320
|
},
|
|
272
321
|
body: null,
|
|
@@ -318,7 +367,8 @@ export class OciRegistry extends BaseRegistry {
|
|
|
318
367
|
repository: string,
|
|
319
368
|
reference: string,
|
|
320
369
|
token: IAuthToken | null,
|
|
321
|
-
headers?: Record<string, string
|
|
370
|
+
headers?: Record<string, string>,
|
|
371
|
+
actor?: IRequestActor
|
|
322
372
|
): Promise<IResponse> {
|
|
323
373
|
if (!await this.checkPermission(token, repository, 'pull')) {
|
|
324
374
|
return this.createUnauthorizedResponse(repository, 'pull');
|
|
@@ -346,30 +396,33 @@ export class OciRegistry extends BaseRegistry {
|
|
|
346
396
|
}
|
|
347
397
|
|
|
348
398
|
// 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
|
-
|
|
399
|
+
if (!manifestData) {
|
|
400
|
+
const upstream = await this.getUpstreamForRequest(repository, 'manifest', 'GET', actor);
|
|
401
|
+
if (upstream) {
|
|
402
|
+
this.logger.log('debug', 'getManifest: fetching from upstream', { repository, reference });
|
|
403
|
+
const upstreamResult = await upstream.fetchManifest(repository, reference);
|
|
404
|
+
if (upstreamResult) {
|
|
405
|
+
manifestData = Buffer.from(JSON.stringify(upstreamResult.manifest), 'utf8');
|
|
406
|
+
contentType = upstreamResult.contentType;
|
|
407
|
+
digest = upstreamResult.digest;
|
|
408
|
+
|
|
409
|
+
// Cache the manifest locally
|
|
410
|
+
await this.storage.putOciManifest(repository, digest, manifestData, contentType);
|
|
411
|
+
|
|
412
|
+
// If reference is a tag, update tags mapping
|
|
413
|
+
if (!reference.startsWith('sha256:')) {
|
|
414
|
+
const tags = await this.getTagsData(repository);
|
|
415
|
+
tags[reference] = digest;
|
|
416
|
+
const tagsPath = `oci/tags/${repository}/tags.json`;
|
|
417
|
+
await this.storage.putObject(tagsPath, Buffer.from(JSON.stringify(tags), 'utf-8'));
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
this.logger.log('debug', 'getManifest: cached manifest locally', {
|
|
421
|
+
repository,
|
|
422
|
+
reference,
|
|
423
|
+
digest,
|
|
424
|
+
});
|
|
366
425
|
}
|
|
367
|
-
|
|
368
|
-
this.logger.log('debug', 'getManifest: cached manifest locally', {
|
|
369
|
-
repository,
|
|
370
|
-
reference,
|
|
371
|
-
digest,
|
|
372
|
-
});
|
|
373
426
|
}
|
|
374
427
|
}
|
|
375
428
|
|
|
@@ -477,7 +530,7 @@ export class OciRegistry extends BaseRegistry {
|
|
|
477
530
|
return {
|
|
478
531
|
status: 201,
|
|
479
532
|
headers: {
|
|
480
|
-
'Location': `${this.basePath}
|
|
533
|
+
'Location': `${this.basePath}/${repository}/manifests/${digest}`,
|
|
481
534
|
'Docker-Content-Digest': digest,
|
|
482
535
|
},
|
|
483
536
|
body: null,
|
|
@@ -514,19 +567,33 @@ export class OciRegistry extends BaseRegistry {
|
|
|
514
567
|
repository: string,
|
|
515
568
|
digest: string,
|
|
516
569
|
token: IAuthToken | null,
|
|
517
|
-
range?: string
|
|
570
|
+
range?: string,
|
|
571
|
+
actor?: IRequestActor
|
|
518
572
|
): Promise<IResponse> {
|
|
519
573
|
if (!await this.checkPermission(token, repository, 'pull')) {
|
|
520
574
|
return this.createUnauthorizedResponse(repository, 'pull');
|
|
521
575
|
}
|
|
522
576
|
|
|
523
|
-
// Try local storage first
|
|
524
|
-
|
|
577
|
+
// Try local storage first (streaming)
|
|
578
|
+
const streamResult = await this.storage.getOciBlobStream(digest);
|
|
579
|
+
if (streamResult) {
|
|
580
|
+
return {
|
|
581
|
+
status: 200,
|
|
582
|
+
headers: {
|
|
583
|
+
'Content-Type': 'application/octet-stream',
|
|
584
|
+
'Content-Length': streamResult.size.toString(),
|
|
585
|
+
'Docker-Content-Digest': digest,
|
|
586
|
+
},
|
|
587
|
+
body: streamResult.stream,
|
|
588
|
+
};
|
|
589
|
+
}
|
|
525
590
|
|
|
526
591
|
// If not found locally, try upstream
|
|
527
|
-
|
|
592
|
+
let data: Buffer | null = null;
|
|
593
|
+
const upstream = await this.getUpstreamForRequest(repository, 'blob', 'GET', actor);
|
|
594
|
+
if (upstream) {
|
|
528
595
|
this.logger.log('debug', 'getBlob: fetching from upstream', { repository, digest });
|
|
529
|
-
const upstreamBlob = await
|
|
596
|
+
const upstreamBlob = await upstream.fetchBlob(repository, digest);
|
|
530
597
|
if (upstreamBlob) {
|
|
531
598
|
data = upstreamBlob;
|
|
532
599
|
// Cache the blob locally (blobs are content-addressable and immutable)
|
|
@@ -566,17 +633,15 @@ export class OciRegistry extends BaseRegistry {
|
|
|
566
633
|
return this.createUnauthorizedHeadResponse(repository, 'pull');
|
|
567
634
|
}
|
|
568
635
|
|
|
569
|
-
const
|
|
570
|
-
if (
|
|
636
|
+
const blobSize = await this.storage.getOciBlobSize(digest);
|
|
637
|
+
if (blobSize === null) {
|
|
571
638
|
return { status: 404, headers: {}, body: null };
|
|
572
639
|
}
|
|
573
640
|
|
|
574
|
-
const blob = await this.storage.getOciBlob(digest);
|
|
575
|
-
|
|
576
641
|
return {
|
|
577
642
|
status: 200,
|
|
578
643
|
headers: {
|
|
579
|
-
'Content-Length':
|
|
644
|
+
'Content-Length': blobSize.toString(),
|
|
580
645
|
'Docker-Content-Digest': digest,
|
|
581
646
|
},
|
|
582
647
|
body: null,
|
|
@@ -616,14 +681,19 @@ export class OciRegistry extends BaseRegistry {
|
|
|
616
681
|
}
|
|
617
682
|
|
|
618
683
|
const chunkData = this.toBuffer(data);
|
|
619
|
-
|
|
684
|
+
|
|
685
|
+
// Write chunk to temp S3 object instead of accumulating in memory
|
|
686
|
+
const chunkPath = `oci/uploads/${uploadId}/chunk-${session.chunkIndex}`;
|
|
687
|
+
await this.storage.putObject(chunkPath, chunkData);
|
|
688
|
+
session.chunkPaths.push(chunkPath);
|
|
689
|
+
session.chunkIndex++;
|
|
620
690
|
session.totalSize += chunkData.length;
|
|
621
691
|
session.lastActivity = new Date();
|
|
622
692
|
|
|
623
693
|
return {
|
|
624
694
|
status: 202,
|
|
625
695
|
headers: {
|
|
626
|
-
'Location': `${this.basePath}
|
|
696
|
+
'Location': `${this.basePath}/${session.repository}/blobs/uploads/${uploadId}`,
|
|
627
697
|
'Range': `0-${session.totalSize - 1}`,
|
|
628
698
|
'Docker-Upload-UUID': uploadId,
|
|
629
699
|
},
|
|
@@ -645,13 +715,52 @@ export class OciRegistry extends BaseRegistry {
|
|
|
645
715
|
};
|
|
646
716
|
}
|
|
647
717
|
|
|
648
|
-
|
|
649
|
-
if (finalData)
|
|
650
|
-
|
|
718
|
+
// If there's final data in the PUT body, write it as the last chunk
|
|
719
|
+
if (finalData) {
|
|
720
|
+
const buf = this.toBuffer(finalData);
|
|
721
|
+
const chunkPath = `oci/uploads/${uploadId}/chunk-${session.chunkIndex}`;
|
|
722
|
+
await this.storage.putObject(chunkPath, buf);
|
|
723
|
+
session.chunkPaths.push(chunkPath);
|
|
724
|
+
session.chunkIndex++;
|
|
725
|
+
session.totalSize += buf.length;
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
// Create a ReadableStream that assembles all chunks from S3 sequentially
|
|
729
|
+
const chunkPaths = [...session.chunkPaths];
|
|
730
|
+
const storage = this.storage;
|
|
731
|
+
let chunkIdx = 0;
|
|
732
|
+
const assembledStream = new ReadableStream<Uint8Array>({
|
|
733
|
+
async pull(controller) {
|
|
734
|
+
if (chunkIdx >= chunkPaths.length) {
|
|
735
|
+
controller.close();
|
|
736
|
+
return;
|
|
737
|
+
}
|
|
738
|
+
const result = await storage.getObjectStream(chunkPaths[chunkIdx++]);
|
|
739
|
+
if (result) {
|
|
740
|
+
const reader = result.stream.getReader();
|
|
741
|
+
while (true) {
|
|
742
|
+
const { done, value } = await reader.read();
|
|
743
|
+
if (done) break;
|
|
744
|
+
if (value) controller.enqueue(value);
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
},
|
|
748
|
+
});
|
|
749
|
+
|
|
750
|
+
// Pipe through hash transform for incremental digest verification
|
|
751
|
+
const { transform: hashTransform, getDigest } = createHashTransform('sha256');
|
|
752
|
+
const hashedStream = assembledStream.pipeThrough(hashTransform);
|
|
651
753
|
|
|
652
|
-
//
|
|
653
|
-
|
|
754
|
+
// Consume stream to buffer for S3 upload
|
|
755
|
+
// (AWS SDK PutObjectCommand requires known content-length for streams;
|
|
756
|
+
// the key win is chunks are NOT accumulated in memory during PATCH — they live in S3)
|
|
757
|
+
const blobData = await streamToBuffer(hashedStream);
|
|
758
|
+
|
|
759
|
+
// Verify digest before storing
|
|
760
|
+
const calculatedDigest = `sha256:${getDigest()}`;
|
|
654
761
|
if (calculatedDigest !== digest) {
|
|
762
|
+
await this.cleanupUploadChunks(session);
|
|
763
|
+
this.uploadSessions.delete(uploadId);
|
|
655
764
|
return {
|
|
656
765
|
status: 400,
|
|
657
766
|
headers: {},
|
|
@@ -659,19 +768,36 @@ export class OciRegistry extends BaseRegistry {
|
|
|
659
768
|
};
|
|
660
769
|
}
|
|
661
770
|
|
|
771
|
+
// Store verified blob
|
|
662
772
|
await this.storage.putOciBlob(digest, blobData);
|
|
773
|
+
|
|
774
|
+
// Cleanup temp chunks and session
|
|
775
|
+
await this.cleanupUploadChunks(session);
|
|
663
776
|
this.uploadSessions.delete(uploadId);
|
|
664
777
|
|
|
665
778
|
return {
|
|
666
779
|
status: 201,
|
|
667
780
|
headers: {
|
|
668
|
-
'Location': `${this.basePath}
|
|
781
|
+
'Location': `${this.basePath}/${session.repository}/blobs/${digest}`,
|
|
669
782
|
'Docker-Content-Digest': digest,
|
|
670
783
|
},
|
|
671
784
|
body: null,
|
|
672
785
|
};
|
|
673
786
|
}
|
|
674
787
|
|
|
788
|
+
/**
|
|
789
|
+
* Delete all temp S3 chunk objects for an upload session.
|
|
790
|
+
*/
|
|
791
|
+
private async cleanupUploadChunks(session: IUploadSession): Promise<void> {
|
|
792
|
+
for (const chunkPath of session.chunkPaths) {
|
|
793
|
+
try {
|
|
794
|
+
await this.storage.deleteObject(chunkPath);
|
|
795
|
+
} catch {
|
|
796
|
+
// Best-effort cleanup
|
|
797
|
+
}
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
|
|
675
801
|
private async getUploadStatus(uploadId: string): Promise<IResponse> {
|
|
676
802
|
const session = this.uploadSessions.get(uploadId);
|
|
677
803
|
if (!session) {
|
|
@@ -685,7 +811,7 @@ export class OciRegistry extends BaseRegistry {
|
|
|
685
811
|
return {
|
|
686
812
|
status: 204,
|
|
687
813
|
headers: {
|
|
688
|
-
'Location': `${this.basePath}
|
|
814
|
+
'Location': `${this.basePath}/${session.repository}/blobs/uploads/${uploadId}`,
|
|
689
815
|
'Range': session.totalSize > 0 ? `0-${session.totalSize - 1}` : '0-0',
|
|
690
816
|
'Docker-Upload-UUID': uploadId,
|
|
691
817
|
},
|
|
@@ -830,7 +956,7 @@ export class OciRegistry extends BaseRegistry {
|
|
|
830
956
|
* Per OCI Distribution Spec, 401 responses MUST include WWW-Authenticate header.
|
|
831
957
|
*/
|
|
832
958
|
private createUnauthorizedResponse(repository: string, action: string): IResponse {
|
|
833
|
-
const realm = this.ociTokens?.realm || `${this.basePath}/
|
|
959
|
+
const realm = this.ociTokens?.realm || `${this.basePath}/token`;
|
|
834
960
|
const service = this.ociTokens?.service || 'registry';
|
|
835
961
|
return {
|
|
836
962
|
status: 401,
|
|
@@ -845,7 +971,7 @@ export class OciRegistry extends BaseRegistry {
|
|
|
845
971
|
* Create an unauthorized HEAD response (no body per HTTP spec).
|
|
846
972
|
*/
|
|
847
973
|
private createUnauthorizedHeadResponse(repository: string, action: string): IResponse {
|
|
848
|
-
const realm = this.ociTokens?.realm || `${this.basePath}/
|
|
974
|
+
const realm = this.ociTokens?.realm || `${this.basePath}/token`;
|
|
849
975
|
const service = this.ociTokens?.service || 'registry';
|
|
850
976
|
return {
|
|
851
977
|
status: 401,
|
|
@@ -863,6 +989,8 @@ export class OciRegistry extends BaseRegistry {
|
|
|
863
989
|
|
|
864
990
|
for (const [uploadId, session] of this.uploadSessions.entries()) {
|
|
865
991
|
if (now.getTime() - session.lastActivity.getTime() > maxAge) {
|
|
992
|
+
// Clean up temp S3 chunks for stale sessions
|
|
993
|
+
this.cleanupUploadChunks(session).catch(() => {});
|
|
866
994
|
this.uploadSessions.delete(uploadId);
|
|
867
995
|
}
|
|
868
996
|
}
|
|
@@ -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;
|