-
Notifications
You must be signed in to change notification settings - Fork 445
Expand file tree
/
Copy pathcontainerCollectionsOCIPush.ts
More file actions
364 lines (312 loc) · 16.8 KB
/
Copy pathcontainerCollectionsOCIPush.ts
File metadata and controls
364 lines (312 loc) · 16.8 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
import * as path from 'path';
import * as fs from 'fs';
import * as crypto from 'crypto';
import { delay } from '../spec-common/async';
import { headRequest, requestResolveHeaders } from '../spec-utils/httpRequest';
import { Log, LogLevel } from '../spec-utils/log';
import { isLocalFile, readLocalFile } from '../spec-utils/pfs';
import { DEVCONTAINER_COLLECTION_LAYER_MEDIATYPE, DEVCONTAINER_TAR_LAYER_MEDIATYPE, fetchOCIManifestIfExists, fetchRegistryAuthToken, HEADERS, OCICollectionRef, OCILayer, OCIManifest, OCIRef } from './containerCollectionsOCI';
// (!) Entrypoint function to push a single feature/template to a registry.
// Devcontainer Spec (features) : https://containers.dev/implementors/features-distribution/#oci-registry
// Devcontainer Spec (templates): https://github.com/devcontainers/spec/blob/main/proposals/devcontainer-templates-distribution.md#oci-registry
// OCI Spec : https://github.com/opencontainers/distribution-spec/blob/main/spec.md#push
export async function pushOCIFeatureOrTemplate(output: Log, ociRef: OCIRef, pathToTgz: string, tags: string[], collectionType: string): Promise<boolean> {
output.write(`Starting push of ${collectionType} '${ociRef.id}' to '${ociRef.resource}' with tags '${tags.join(', ')}'`);
output.write(`${JSON.stringify(ociRef, null, 2)}`, LogLevel.Trace);
const env = process.env;
// Generate registry auth token with `pull,push` scopes.
const registryAuthToken = await fetchRegistryAuthToken(output, ociRef.registry, ociRef.path, env, 'pull,push');
if (!registryAuthToken) {
output.write(`Failed to get registry auth token`, LogLevel.Error);
return false;
}
// Generate Manifest for given feature/template artifact.
const manifest = await generateCompleteManifestForIndividualFeatureOrTemplate(output, pathToTgz, ociRef, collectionType);
if (!manifest) {
output.write(`Failed to generate manifest for ${ociRef.id}`, LogLevel.Error);
return false;
}
output.write(`Generated manifest: \n${JSON.stringify(manifest?.manifestObj, undefined, 4)}`, LogLevel.Trace);
// If the exact manifest digest already exists in the registry, we don't need to push individual blobs (it's already there!)
const existingManifest = await fetchOCIManifestIfExists(output, env, ociRef, manifest.digest, registryAuthToken);
if (manifest.digest && existingManifest) {
output.write(`Not reuploading blobs, digest already exists.`, LogLevel.Trace);
return await putManifestWithTags(output, manifest.manifestStr, ociRef, tags, registryAuthToken);
}
const blobsToPush = [
{
name: 'configLayer',
digest: manifest.manifestObj.config.digest,
},
{
name: 'tgzLayer',
digest: manifest.manifestObj.layers[0].digest,
}
];
// Obtain session ID with `/v2/<namespace>/blobs/uploads/`
const blobPutLocationUriPath = await postUploadSessionId(output, ociRef, registryAuthToken);
if (!blobPutLocationUriPath) {
output.write(`Failed to get upload session ID`, LogLevel.Error);
return false;
}
for await (const blob of blobsToPush) {
const { name, digest } = blob;
const blobExistsConfigLayer = await checkIfBlobExists(output, ociRef, digest, registryAuthToken);
output.write(`blob: '${name}' with digest '${digest}' ${blobExistsConfigLayer ? 'already exists' : 'does not exist'} in registry.`, LogLevel.Trace);
// PUT blobs
if (!blobExistsConfigLayer) {
if (!(await putBlob(output, pathToTgz, blobPutLocationUriPath, ociRef, digest, registryAuthToken))) {
output.write(`Failed to PUT blob '${name}' with digest '${digest}'`, LogLevel.Error);
return false;
}
}
}
// Send a final PUT to combine blobs and tag manifest properly.
return await putManifestWithTags(output, manifest.manifestStr, ociRef, tags, registryAuthToken);
}
// (!) Entrypoint function to push a collection metadata/overview file for a set of features/templates to a registry.
// Devcontainer Spec (features) : https://containers.dev/implementors/features-distribution/#oci-registry (see 'devcontainer-collection.json')
// Devcontainer Spec (templates): https://github.com/devcontainers/spec/blob/main/proposals/devcontainer-templates-distribution.md#oci-registry (see 'devcontainer-collection.json')
// OCI Spec : https://github.com/opencontainers/distribution-spec/blob/main/spec.md#push
export async function pushCollectionMetadata(output: Log, collectionRef: OCICollectionRef, pathToCollectionJson: string, collectionType: string): Promise<boolean> {
output.write(`Starting push of latest ${collectionType} collection for namespace '${collectionRef.path}' to '${collectionRef.registry}'`);
output.write(`${JSON.stringify(collectionRef, null, 2)}`, LogLevel.Trace);
const env = process.env;
const registryAuthToken = await fetchRegistryAuthToken(output, collectionRef.registry, collectionRef.path, env, 'pull,push');
if (!registryAuthToken) {
output.write(`Failed to get registry auth token`, LogLevel.Error);
return false;
}
// Generate Manifest for collection artifact.
const manifest = await generateCompleteManifestForCollectionFile(output, pathToCollectionJson, collectionRef);
if (!manifest) {
output.write(`Failed to generate manifest for ${collectionRef.path}`, LogLevel.Error);
return false;
}
output.write(`Generated manifest: \n${JSON.stringify(manifest?.manifestObj, undefined, 4)}`, LogLevel.Trace);
// If the exact manifest digest already exists in the registry, we don't need to push individual blobs (it's already there!)
const existingManifest = await fetchOCIManifestIfExists(output, env, collectionRef, manifest.digest, registryAuthToken);
if (manifest.digest && existingManifest) {
output.write(`Not reuploading blobs, digest already exists.`, LogLevel.Trace);
return await putManifestWithTags(output, manifest.manifestStr, collectionRef, ['latest'], registryAuthToken);
}
// Obtain session ID with `/v2/<namespace>/blobs/uploads/`
const blobPutLocationUriPath = await postUploadSessionId(output, collectionRef, registryAuthToken);
if (!blobPutLocationUriPath) {
output.write(`Failed to get upload session ID`, LogLevel.Error);
return false;
}
const blobsToPush = [
{
name: 'configLayer',
digest: manifest.manifestObj.config.digest,
},
{
name: 'collectionLayer',
digest: manifest.manifestObj.layers[0].digest,
}
];
for await (const blob of blobsToPush) {
const { name, digest } = blob;
const blobExistsConfigLayer = await checkIfBlobExists(output, collectionRef, digest, registryAuthToken);
output.write(`blob: '${name}' with digest '${digest}' ${blobExistsConfigLayer ? 'already exists' : 'does not exist'} in registry.`, LogLevel.Trace);
// PUT blobs
if (!blobExistsConfigLayer) {
if (!(await putBlob(output, pathToCollectionJson, blobPutLocationUriPath, collectionRef, digest, registryAuthToken))) {
output.write(`Failed to PUT blob '${name}' with digest '${digest}'`, LogLevel.Error);
return false;
}
}
}
// Send a final PUT to combine blobs and tag manifest properly.
// Collections are always tagged 'latest'
return await putManifestWithTags(output, manifest.manifestStr, collectionRef, ['latest'], registryAuthToken);
}
// --- Helper Functions
// Spec: https://github.com/opencontainers/distribution-spec/blob/main/spec.md#pushing-manifests (PUT /manifests/<ref>)
async function putManifestWithTags(output: Log, manifestStr: string, ociRef: OCIRef | OCICollectionRef, tags: string[], registryAuthToken: string): Promise<boolean> {
output.write(`Tagging manifest with tags: ${tags.join(', ')}`, LogLevel.Trace);
for await (const tag of tags) {
const url = `https://${ociRef.registry}/v2/${ociRef.path}/manifests/${tag}`;
output.write(`PUT -> '${url}'`, LogLevel.Trace);
const options = {
type: 'PUT',
url,
headers: {
'Authorization': `Bearer ${registryAuthToken}`,
'Content-Type': 'application/vnd.oci.image.manifest.v1+json',
},
data: Buffer.from(manifestStr),
};
let { statusCode, resHeaders } = await requestResolveHeaders(options);
// Retry logic: when request fails with HTTP 429: too many requests
if (statusCode === 429) {
output.write(`Failed to PUT manifest for tag ${tag} due to too many requests. Retrying...`, LogLevel.Warning);
await delay(2000);
let response = await requestResolveHeaders(options);
statusCode = response.statusCode;
resHeaders = response.resHeaders;
}
if (statusCode !== 201) {
output.write(`Failed to PUT manifest for tag ${tag}`, LogLevel.Error);
return false;
}
const dockerContentDigestResponseHeader = resHeaders['docker-content-digest'] || resHeaders['Docker-Content-Digest'];
const locationResponseHeader = resHeaders['location'] || resHeaders['Location'];
output.write(`Tagged: ${tag} -> ${locationResponseHeader}`, LogLevel.Info);
output.write(`Returned Content-Digest: ${dockerContentDigestResponseHeader}`, LogLevel.Trace);
}
return true;
}
// Spec: https://github.com/opencontainers/distribution-spec/blob/main/spec.md#post-then-put (PUT <location>?digest=<digest>)
async function putBlob(output: Log, pathToBlob: string, blobPutLocationUriPath: string, ociRef: OCIRef | OCICollectionRef, digest: string, registryAuthToken: string): Promise<boolean> {
output.write(`PUT new blob -> '${digest}'`, LogLevel.Info);
if (!(await isLocalFile(pathToBlob))) {
output.write(`Blob ${pathToBlob} does not exist`, LogLevel.Error);
return false;
}
const headers: HEADERS = {
'user-agent': 'devcontainer',
'authorization': `Bearer ${registryAuthToken}`,
'content-type': 'application/octet-stream',
};
// OCI distribution spec is ambiguous on whether we get back an absolute or relative path.
let url = '';
if (blobPutLocationUriPath.startsWith('https://')) {
url = blobPutLocationUriPath;
} else {
url = `https://${ociRef.registry}${blobPutLocationUriPath}`;
}
url += `?digest=${digest}`;
output.write(`Crafted blob url: ${url}`, LogLevel.Trace);
const { statusCode } = await requestResolveHeaders({ type: 'PUT', url, headers, data: await readLocalFile(pathToBlob) });
if (statusCode !== 201) {
output.write(`${statusCode}: Failed to upload blob '${pathToBlob}' to '${url}'`, LogLevel.Error);
return false;
}
return true;
}
// Generate a layer that follows the `application/vnd.devcontainers.layer.v1+tar` mediaType as defined in
// Devcontainer Spec (features) : https://containers.dev/implementors/features-distribution/#oci-registry
// Devcontainer Spec (templates): https://github.com/devcontainers/spec/blob/main/proposals/devcontainer-templates-distribution.md#oci-registry
async function generateCompleteManifestForIndividualFeatureOrTemplate(output: Log, pathToTgz: string, ociRef: OCIRef, collectionType: string): Promise<{ manifestObj: OCIManifest; manifestStr: string; digest: string } | undefined> {
const tgzLayer = await calculateDataLayer(output, pathToTgz, DEVCONTAINER_TAR_LAYER_MEDIATYPE);
if (!tgzLayer) {
output.write(`Failed to calculate tgz layer.`, LogLevel.Error);
return undefined;
}
let annotations: { [key: string]: string } | undefined = undefined;
// Specific registries look for certain optional metadata
// in the manifest, in this case for UI presentation.
if (ociRef.registry === 'ghcr.io') {
annotations = {
'com.github.package.type': `devcontainer_${collectionType}`,
};
}
return await calculateManifestAndContentDigest(output, tgzLayer, annotations);
}
// Generate a layer that follows the `application/vnd.devcontainers.collection.layer.v1+json` mediaType as defined in
// Devcontainer Spec (features) : https://containers.dev/implementors/features-distribution/#oci-registry
// Devcontainer Spec (templates): https://github.com/devcontainers/spec/blob/main/proposals/devcontainer-templates-distribution.md#oci-registry
async function generateCompleteManifestForCollectionFile(output: Log, pathToCollectionFile: string, collectionRef: OCICollectionRef): Promise<{ manifestObj: OCIManifest; manifestStr: string; digest: string } | undefined> {
const collectionMetadataLayer = await calculateDataLayer(output, pathToCollectionFile, DEVCONTAINER_COLLECTION_LAYER_MEDIATYPE);
if (!collectionMetadataLayer) {
output.write(`Failed to calculate collection file layer.`, LogLevel.Error);
return undefined;
}
let annotations: { [key: string]: string } | undefined = undefined;
// Specific registries look for certain optional metadata
// in the manifest, in this case for UI presentation.
if (collectionRef.registry === 'ghcr.io') {
annotations = {
'com.github.package.type': 'devcontainer_collection',
};
}
return await calculateManifestAndContentDigest(output, collectionMetadataLayer, annotations);
}
// Generic construction of a layer in the manifest and digest for the generated layer.
export async function calculateDataLayer(output: Log, pathToData: string, mediaType: string): Promise<OCILayer | undefined> {
output.write(`Creating manifest from ${pathToData}`, LogLevel.Trace);
if (!(await isLocalFile(pathToData))) {
output.write(`${pathToData} does not exist.`, LogLevel.Error);
return undefined;
}
const dataBytes = fs.readFileSync(pathToData);
const tarSha256 = crypto.createHash('sha256').update(dataBytes).digest('hex');
output.write(`${pathToData}: sha256:${tarSha256} (size: ${dataBytes.byteLength})`, LogLevel.Info);
return {
mediaType,
digest: `sha256:${tarSha256}`,
size: dataBytes.byteLength,
annotations: {
'org.opencontainers.image.title': path.basename(pathToData),
}
};
}
// Spec: https://github.com/opencontainers/distribution-spec/blob/main/spec.md#checking-if-content-exists-in-the-registry
// Requires registry auth token.
export async function checkIfBlobExists(output: Log, ociRef: OCIRef | OCICollectionRef, digest: string, authToken: string): Promise<boolean> {
const headers: HEADERS = {
'user-agent': 'devcontainer',
'authorization': `Bearer ${authToken}`,
};
const url = `https://${ociRef.registry}/v2/${ociRef.path}/blobs/${digest}`;
const statusCode = await headRequest({ url, headers }, output);
output.write(`${url}: ${statusCode}`, LogLevel.Trace);
return statusCode === 200;
}
// Spec: https://github.com/opencontainers/distribution-spec/blob/main/spec.md#post-then-put
// Requires registry auth token.
async function postUploadSessionId(output: Log, ociRef: OCIRef | OCICollectionRef, authToken: string): Promise<string | undefined> {
const headers: HEADERS = {
'user-agent': 'devcontainer',
'authorization': `Bearer ${authToken}`,
};
const url = `https://${ociRef.registry}/v2/${ociRef.path}/blobs/uploads/`;
output.write(`Generating Upload URL -> ${url}`, LogLevel.Trace);
const { statusCode, resHeaders, resBody } = await requestResolveHeaders({ type: 'POST', url, headers }, output);
output.write(`${url}: ${statusCode}`, LogLevel.Trace);
if (statusCode === 202) {
const locationHeader = resHeaders['location'] || resHeaders['Location'];
if (!locationHeader) {
output.write(`${url}: Got 202 status code, but no location header found.`, LogLevel.Error);
return undefined;
}
return locationHeader;
} else {
// Any other statusCode besides 202 is unexpected
// https://github.com/opencontainers/distribution-spec/blob/main/spec.md#error-codes
const displayResBody = resBody ? ` -> ${resBody}` : '';
output.write(`${url}: Unexpected status code '${statusCode}'${displayResBody}`, LogLevel.Error);
return undefined;
}
}
export async function calculateManifestAndContentDigest(output: Log, dataLayer: OCILayer, annotations: { [key: string]: string } | undefined) {
// A canonical manifest digest is the sha256 hash of the JSON representation of the manifest, without the signature content.
// See: https://docs.docker.com/registry/spec/api/#content-digests
// Below is an example of a serialized manifest that should resolve to '9726054859c13377c4c3c3c73d15065de59d0c25d61d5652576c0125f2ea8ed3'
// {"schemaVersion":2,"mediaType":"application/vnd.oci.image.manifest.v1+json","config":{"mediaType":"application/vnd.devcontainers","digest":"sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855","size":0},"layers":[{"mediaType":"application/vnd.devcontainers.layer.v1+tar","digest":"sha256:b2006e7647191f7b47222ae48df049c6e21a4c5a04acfad0c4ef614d819de4c5","size":15872,"annotations":{"org.opencontainers.image.title":"go.tgz"}}]}
let manifest: OCIManifest = {
schemaVersion: 2,
mediaType: 'application/vnd.oci.image.manifest.v1+json',
config: {
mediaType: 'application/vnd.devcontainers',
digest: 'sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855', // A zero byte digest for the devcontainer mediaType.
size: 0
},
layers: [
dataLayer
],
};
if (annotations) {
manifest.annotations = annotations;
}
const manifestStringified = JSON.stringify(manifest);
const manifestHash = crypto.createHash('sha256').update(manifestStringified).digest('hex');
output.write(`Computed Content-Digest -> sha256:${manifestHash} (size: ${manifestHash.length})`, LogLevel.Info);
return {
manifestStr: manifestStringified,
manifestObj: manifest,
digest: manifestHash,
};
}