@xemahq/repo-build-tooling 0.12.0 → 0.13.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/package.json +7 -5
- package/src/composed-image-coordinate-cli.test.mjs +60 -0
- package/src/composed-image-coordinate.mjs +25 -23
- package/src/composed-image-coordinate.test.mjs +41 -46
- package/src/composed-image.mjs +7 -7
- package/src/composed-image.test.mjs +2 -2
- package/src/ghcr-manifest.mjs +0 -72
- package/src/ghcr-manifest.test.mjs +0 -96
- package/src/registry-fetch.mjs +0 -82
- package/src/registry-fetch.test.mjs +0 -151
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@xemahq/repo-build-tooling",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Dev-time build tooling shared by every Xema repository. Ships as plain ESM
|
|
3
|
+
"version": "0.13.0",
|
|
4
|
+
"description": "Dev-time build tooling shared by every Xema repository. Ships as plain ESM, depending only on @xemahq/release-policy for registry access, so the published artifact is the reviewed source.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"author": "Neuralchowder Inc. <developer@xema.dev> (https://xema.dev)",
|
|
7
7
|
"homepage": "https://xema.dev",
|
|
@@ -32,16 +32,18 @@
|
|
|
32
32
|
"./check-no-vendored-deps": "./src/check-no-vendored-deps.mjs",
|
|
33
33
|
"./check-workspace-range-matches-local": "./src/check-workspace-range-matches-local.mjs",
|
|
34
34
|
"./composed-image": "./src/composed-image.mjs",
|
|
35
|
-
"./ghcr-manifest": "./src/ghcr-manifest.mjs",
|
|
36
35
|
"./package.json": "./package.json",
|
|
37
36
|
"./readiness": "./src/readiness.mjs",
|
|
38
37
|
"./scrub-swagger-plugin-paths": "./src/scrub-swagger-plugin-paths.mjs",
|
|
39
38
|
"./service-bootstrap": "./src/service-bootstrap.mjs"
|
|
40
39
|
},
|
|
40
|
+
"dependencies": {
|
|
41
|
+
"@xemahq/release-policy": "^14.0.0"
|
|
42
|
+
},
|
|
41
43
|
"scripts": {
|
|
42
|
-
"test": "node src/scrub-swagger-plugin-paths.mjs --self-test && node --test src/check-workspace-range-matches-local.test.mjs && node --test src/check-no-vendored-deps.test.mjs && node --test src/readiness.test.mjs && node --test src/composed-image.test.mjs && node --test src/
|
|
44
|
+
"test": "node src/scrub-swagger-plugin-paths.mjs --self-test && node --test src/check-workspace-range-matches-local.test.mjs && node --test src/check-no-vendored-deps.test.mjs && node --test src/readiness.test.mjs && node --test src/composed-image.test.mjs && node --test src/composed-image-coordinate-cli.test.mjs && node --test src/declaration-refusal.test.mjs && node --test src/smoke-refusal.test.mjs && node --test src/load-image.test.mjs && node --test src/composed-image-coordinate.test.mjs && node --test src/composed-image-dockerfile.test.mjs && node --test src/service-bootstrap.test.mjs && node --test src/service-bootstrap-protocols.test.mjs && node --test src/composed-image-prebuilt-dockerfile.test.mjs && node --test src/composed-image-stage.test.mjs && node --test src/composed-image-sync-injected.test.mjs && node --test src/composed-image-prisma-check.test.mjs && node --test src/composed-migrate.test.mjs"
|
|
43
45
|
},
|
|
44
46
|
"xemaRelease": {
|
|
45
|
-
"sourceDigest": "sha256:
|
|
47
|
+
"sourceDigest": "sha256:a3994566dfeac8ede246a07315647fa7f10828b510cf5835a0640a8a97650f5c"
|
|
46
48
|
}
|
|
47
49
|
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { spawnSync } from 'node:child_process';
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import os from 'node:os';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import test from 'node:test';
|
|
7
|
+
import { fileURLToPath } from 'node:url';
|
|
8
|
+
|
|
9
|
+
const BIN = path.join(path.dirname(fileURLToPath(import.meta.url)), 'composed-image.mjs');
|
|
10
|
+
const HASH = 'a'.repeat(64);
|
|
11
|
+
|
|
12
|
+
function consumer() {
|
|
13
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'coordinate-cli-'));
|
|
14
|
+
const hashDir = path.join(root, 'node_modules', '@xemahq', 'distribution-source-hash');
|
|
15
|
+
fs.mkdirSync(hashDir, { recursive: true });
|
|
16
|
+
fs.writeFileSync(
|
|
17
|
+
path.join(hashDir, 'package.json'),
|
|
18
|
+
JSON.stringify({ name: '@xemahq/distribution-source-hash', bin: { 'xema-compute-image-tag': 'bin.js' } }),
|
|
19
|
+
);
|
|
20
|
+
fs.writeFileSync(path.join(hashDir, 'bin.js'), `console.log('${HASH}');\n`);
|
|
21
|
+
fs.writeFileSync(path.join(root, 'package.json'), '{}');
|
|
22
|
+
return root;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// The release-policy develop-images CLI runs the coordinate under an allow-listed
|
|
26
|
+
// environment with no registry credential and parses these two stdout lines.
|
|
27
|
+
test('the CLI prints content_tag and cross_repo_pins under an environment with no registry credential', () => {
|
|
28
|
+
const root = consumer();
|
|
29
|
+
try {
|
|
30
|
+
const tagArgs = path.join(root, 'tag-args.txt');
|
|
31
|
+
fs.writeFileSync(tagArgs, `--vendored\nd=r@${'b'.repeat(40)}:s\n`);
|
|
32
|
+
const result = spawnSync(process.execPath, [BIN, 'coordinate', '--image-name', 'img'], {
|
|
33
|
+
cwd: root,
|
|
34
|
+
encoding: 'utf8',
|
|
35
|
+
env: { PATH: process.env.PATH, SHA: 'b'.repeat(40), REGISTRY: 'ghcr.io', REGISTRY_ORG: 'org', TAG_ARGS_FILE: tagArgs },
|
|
36
|
+
});
|
|
37
|
+
assert.equal(result.status, 0, result.stderr);
|
|
38
|
+
assert.match(result.stdout, new RegExp(`^content_tag=${HASH}$`, 'm'));
|
|
39
|
+
assert.match(result.stdout, /^cross_repo_pins=1$/m);
|
|
40
|
+
assert.doesNotMatch(result.stdout, /exists=/);
|
|
41
|
+
} finally {
|
|
42
|
+
fs.rmSync(root, { recursive: true, force: true });
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test('the CLI fails with the real message when the hash package is not installed', () => {
|
|
47
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'coordinate-cli-'));
|
|
48
|
+
try {
|
|
49
|
+
fs.writeFileSync(path.join(root, 'package.json'), '{}');
|
|
50
|
+
const result = spawnSync(process.execPath, [BIN, 'coordinate', '--image-name', 'img'], {
|
|
51
|
+
cwd: root,
|
|
52
|
+
encoding: 'utf8',
|
|
53
|
+
env: { PATH: process.env.PATH, SHA: 'b'.repeat(40), REGISTRY: 'ghcr.io', REGISTRY_ORG: 'org' },
|
|
54
|
+
});
|
|
55
|
+
assert.equal(result.status, 1);
|
|
56
|
+
assert.match(result.stderr, /distribution-source-hash is not installed/);
|
|
57
|
+
} finally {
|
|
58
|
+
fs.rmSync(root, { recursive: true, force: true });
|
|
59
|
+
}
|
|
60
|
+
});
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// `xema-composed-image coordinate --image-name <name>`: the release coordinate of a
|
|
2
|
-
// repository's composed-process image
|
|
2
|
+
// repository's composed-process image and how many cross-repository inputs it hashes.
|
|
3
3
|
//
|
|
4
4
|
// The tag is the ORDINARY build-input key, `xema-compute-image-tag` over the
|
|
5
5
|
// image directory, exactly as for any other image subject, so the distribution
|
|
@@ -8,23 +8,34 @@
|
|
|
8
8
|
// bin is resolved from the CONSUMER's own `@xemahq/distribution-source-hash`
|
|
9
9
|
// install, and the coordinate refuses when the repository does not carry it.
|
|
10
10
|
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
11
|
+
// The coordinate is PURE: it computes and prints, and never probes the registry, so
|
|
12
|
+
// it needs no registry credential and runs under an environment that carries none.
|
|
13
|
+
// Whether the registry serves the tag is the caller's own question.
|
|
13
14
|
//
|
|
14
|
-
// Environment: SHA (40-hex commit), REGISTRY, REGISTRY_ORG,
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
// optional GITHUB_OUTPUT.
|
|
15
|
+
// Environment: SHA (40-hex commit), REGISTRY, REGISTRY_ORG, optional TAG_ARGS_FILE
|
|
16
|
+
// (one tag argument per line, e.g. the repository's vendored cross-repository
|
|
17
|
+
// inputs; when set it must exist), optional GITHUB_OUTPUT.
|
|
18
18
|
|
|
19
19
|
import { execFileSync } from 'node:child_process';
|
|
20
20
|
import { appendFileSync, existsSync, readFileSync } from 'node:fs';
|
|
21
21
|
import { createRequire } from 'node:module';
|
|
22
22
|
import { dirname, join } from 'node:path';
|
|
23
23
|
|
|
24
|
-
import { ghcrManifestExists } from './ghcr-manifest.mjs';
|
|
25
|
-
|
|
26
24
|
export const HASH_PACKAGE = '@xemahq/distribution-source-hash';
|
|
27
25
|
export const HASH_BIN = 'xema-compute-image-tag';
|
|
26
|
+
const VENDORED_FLAG = '--vendored';
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* The number of cross-repository pinned inputs in the hash input set: each
|
|
30
|
+
* `--vendored <dest>=<repo>@<commit>:<src>` pair of the tag arguments. Every tag
|
|
31
|
+
* argument flag takes exactly one value, so the arguments are read as flag/value pairs.
|
|
32
|
+
*/
|
|
33
|
+
export function countCrossRepoPins(tagArgs) {
|
|
34
|
+
if (tagArgs.length % 2 !== 0) throw new Error('TAG_ARGS_FILE must hold flag/value pairs, one argument per line');
|
|
35
|
+
let pins = 0;
|
|
36
|
+
for (let i = 0; i < tagArgs.length; i += 2) if (tagArgs[i] === VENDORED_FLAG) pins += 1;
|
|
37
|
+
return pins;
|
|
38
|
+
}
|
|
28
39
|
|
|
29
40
|
function required(env, name) {
|
|
30
41
|
const value = env[name];
|
|
@@ -65,8 +76,8 @@ export function resolveHashBin(root) {
|
|
|
65
76
|
return join(dirname(manifestPath), bin);
|
|
66
77
|
}
|
|
67
78
|
|
|
68
|
-
/** `{ image, content_tag,
|
|
69
|
-
export
|
|
79
|
+
/** `{ image, content_tag, cross_repo_pins, entry }` for the composed image of the repository at `root`. */
|
|
80
|
+
export function composedCoordinate({ root, imageName, imageDir, env = process.env, hash }) {
|
|
70
81
|
if (typeof imageName !== 'string' || imageName.length === 0) throw new Error('coordinate needs --image-name');
|
|
71
82
|
const sha = required(env, 'SHA');
|
|
72
83
|
if (!/^[a-f0-9]{40}$/.test(sha)) throw new Error(`SHA '${sha}' is not an exact 40-hex commit`);
|
|
@@ -93,16 +104,7 @@ export async function composedCoordinate({ root, imageName, imageDir, env = proc
|
|
|
93
104
|
throw new Error(`${HASH_BIN} emitted '${contentHash}', not a sha256 hex digest`);
|
|
94
105
|
}
|
|
95
106
|
|
|
96
|
-
const
|
|
97
|
-
const exists = await ghcrManifestExists({
|
|
98
|
-
registry,
|
|
99
|
-
repository,
|
|
100
|
-
contentHash,
|
|
101
|
-
user: required(env, 'REGISTRY_USER'),
|
|
102
|
-
token: required(env, 'REGISTRY_TOKEN'),
|
|
103
|
-
fetchImpl,
|
|
104
|
-
});
|
|
105
|
-
const image = `${registry}/${repository}`;
|
|
107
|
+
const image = `${registry}/${org}/${imageName}`;
|
|
106
108
|
// The same entry shape a service matrix entry has, so the arm64 supplement
|
|
107
109
|
// lane reads this image's receipt exactly as it reads a service's.
|
|
108
110
|
const entry = {
|
|
@@ -113,7 +115,7 @@ export async function composedCoordinate({ root, imageName, imageDir, env = proc
|
|
|
113
115
|
imageRepository: image,
|
|
114
116
|
contentHash,
|
|
115
117
|
};
|
|
116
|
-
return { image, content_tag: contentHash,
|
|
118
|
+
return { image, content_tag: contentHash, cross_repo_pins: String(countCrossRepoPins(tagArgs)), entry: JSON.stringify(entry) };
|
|
117
119
|
}
|
|
118
120
|
|
|
119
121
|
/** Print the outputs, append them to GITHUB_OUTPUT when set. */
|
|
@@ -122,5 +124,5 @@ export function emitCoordinate(outputs, { env = process.env, log = console.log }
|
|
|
122
124
|
if (env.GITHUB_OUTPUT) appendFileSync(env.GITHUB_OUTPUT, `${key}=${value}\n`);
|
|
123
125
|
log(`${key}=${value}`);
|
|
124
126
|
}
|
|
125
|
-
log(`${outputs.image}:${outputs.content_tag} ${outputs.
|
|
127
|
+
log(`${outputs.image}:${outputs.content_tag} (${outputs.cross_repo_pins} cross-repo pins)`);
|
|
126
128
|
}
|
|
@@ -4,26 +4,11 @@ import os from 'node:os';
|
|
|
4
4
|
import path from 'node:path';
|
|
5
5
|
import test from 'node:test';
|
|
6
6
|
|
|
7
|
-
import { composedCoordinate, emitCoordinate, resolveHashBin } from './composed-image-coordinate.mjs';
|
|
7
|
+
import { composedCoordinate, countCrossRepoPins, emitCoordinate, resolveHashBin } from './composed-image-coordinate.mjs';
|
|
8
8
|
|
|
9
9
|
const SHA = 'c'.repeat(40);
|
|
10
10
|
const HASH = 'd'.repeat(64);
|
|
11
|
-
const ENV = { SHA, REGISTRY: 'ghcr.io', REGISTRY_ORG: 'xema-dev'
|
|
12
|
-
|
|
13
|
-
function response(status, body) {
|
|
14
|
-
return { status, async json() { return body; } };
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
function registry(manifestStatus) {
|
|
18
|
-
const urls = [];
|
|
19
|
-
return {
|
|
20
|
-
urls,
|
|
21
|
-
fetchImpl: async (url) => {
|
|
22
|
-
urls.push(String(url));
|
|
23
|
-
return String(url).includes('/token') ? response(200, { token: 't' }) : response(manifestStatus, {});
|
|
24
|
-
},
|
|
25
|
-
};
|
|
26
|
-
}
|
|
11
|
+
const ENV = { SHA, REGISTRY: 'ghcr.io', REGISTRY_ORG: 'xema-dev' };
|
|
27
12
|
|
|
28
13
|
/** A consumer root whose own install carries a stand-in `xema-compute-image-tag` that echoes its arguments into a file. */
|
|
29
14
|
function consumer({ withHash = true, exportsMap } = {}) {
|
|
@@ -44,67 +29,77 @@ function consumer({ withHash = true, exportsMap } = {}) {
|
|
|
44
29
|
return root;
|
|
45
30
|
}
|
|
46
31
|
|
|
47
|
-
test('the hash comes from the consumer\'s own bin, called with the service, directory, commit and tag args',
|
|
32
|
+
test('the hash comes from the consumer\'s own bin, called with the service, directory, commit and tag args', () => {
|
|
48
33
|
const root = consumer();
|
|
49
34
|
const tagArgs = path.join(root, 'tag-args.txt');
|
|
50
35
|
fs.writeFileSync(tagArgs, '--vendored\nx=y@z:w\n');
|
|
51
|
-
const
|
|
52
|
-
const out = await composedCoordinate({
|
|
36
|
+
const out = composedCoordinate({
|
|
53
37
|
root,
|
|
54
38
|
imageName: 'alpha-composed-process',
|
|
55
39
|
imageDir: 'infra/composed-process',
|
|
56
40
|
env: { ...ENV, TAG_ARGS_FILE: tagArgs },
|
|
57
|
-
fetchImpl,
|
|
58
41
|
});
|
|
59
42
|
assert.deepEqual(JSON.parse(fs.readFileSync(path.join(root, 'args.json'), 'utf8')), [
|
|
60
43
|
'--service', 'alpha-composed-process', '--path', 'infra/composed-process', '--commit', SHA, '--repo-dir', root, '--vendored', 'x=y@z:w',
|
|
61
44
|
]);
|
|
62
45
|
assert.equal(out.image, 'ghcr.io/xema-dev/alpha-composed-process');
|
|
63
46
|
assert.equal(out.content_tag, HASH);
|
|
64
|
-
assert.equal(out.
|
|
65
|
-
assert.
|
|
47
|
+
assert.equal(out.cross_repo_pins, '1');
|
|
48
|
+
assert.equal('exists' in out, false);
|
|
66
49
|
assert.deepEqual(JSON.parse(out.entry), {
|
|
67
50
|
service: 'alpha-composed-process', path: 'infra/composed-process', workspace: false, runtimeImage: false,
|
|
68
51
|
imageRepository: 'ghcr.io/xema-dev/alpha-composed-process', contentHash: HASH,
|
|
69
52
|
});
|
|
70
53
|
});
|
|
71
54
|
|
|
72
|
-
test('a
|
|
73
|
-
const out =
|
|
74
|
-
assert.equal(out.
|
|
55
|
+
test('a composed subject with no tag arguments has zero cross-repo pins', () => {
|
|
56
|
+
const out = composedCoordinate({ root: consumer(), imageName: 'a', imageDir: 'd', env: ENV });
|
|
57
|
+
assert.equal(out.cross_repo_pins, '0');
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test('every --vendored input counts, other tag arguments do not', () => {
|
|
61
|
+
assert.equal(countCrossRepoPins([]), 0);
|
|
62
|
+
assert.equal(countCrossRepoPins(['--vendored', 'a=r@' + SHA + ':p', '--build-step-dir', 'x', '--vendored', 'b=r@' + SHA + ':q']), 2);
|
|
63
|
+
assert.throws(() => countCrossRepoPins(['--vendored']), /flag\/value pairs/);
|
|
75
64
|
});
|
|
76
65
|
|
|
77
|
-
test('
|
|
78
|
-
|
|
79
|
-
|
|
66
|
+
test('the coordinate is pure: no registry credential is needed and no fetch is made', () => {
|
|
67
|
+
const realFetch = globalThis.fetch;
|
|
68
|
+
globalThis.fetch = () => {
|
|
69
|
+
throw new Error('coordinate must not touch the network');
|
|
70
|
+
};
|
|
71
|
+
try {
|
|
72
|
+
const out = composedCoordinate({ root: consumer(), imageName: 'a', imageDir: 'd', env: { ...ENV } });
|
|
73
|
+
assert.equal(out.content_tag, HASH);
|
|
74
|
+
} finally {
|
|
75
|
+
globalThis.fetch = realFetch;
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test('a repository without the hash package is refused, never hashed another way', () => {
|
|
80
|
+
assert.throws(
|
|
81
|
+
() => composedCoordinate({ root: consumer({ withHash: false }), imageName: 'a', imageDir: 'd', env: ENV }),
|
|
80
82
|
/distribution-source-hash is not installed/,
|
|
81
83
|
);
|
|
82
84
|
assert.throws(() => resolveHashBin(consumer({ withHash: false })), /not installed/);
|
|
83
85
|
});
|
|
84
86
|
|
|
85
|
-
test('the inputs are checked: commit, registry settings, tag args file, digest shape',
|
|
87
|
+
test('the inputs are checked: commit, registry settings, tag args file, digest shape', () => {
|
|
86
88
|
const root = consumer();
|
|
87
|
-
const base = { root, imageName: 'a', imageDir: 'd'
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
});
|
|
94
|
-
|
|
95
|
-
test('an unusable registry answer fails the coordinate rather than reading as missing', async () => {
|
|
96
|
-
await assert.rejects(
|
|
97
|
-
composedCoordinate({ root: consumer(), imageName: 'a', imageDir: 'd', env: ENV, fetchImpl: registry(500).fetchImpl }),
|
|
98
|
-
/HTTP 500/,
|
|
99
|
-
);
|
|
89
|
+
const base = { root, imageName: 'a', imageDir: 'd' };
|
|
90
|
+
assert.throws(() => composedCoordinate({ ...base, env: { ...ENV, SHA: 'abc' } }), /40-hex/);
|
|
91
|
+
assert.throws(() => composedCoordinate({ ...base, env: { ...ENV, REGISTRY_ORG: '' } }), /REGISTRY_ORG is required/);
|
|
92
|
+
assert.throws(() => composedCoordinate({ ...base, env: { ...ENV, TAG_ARGS_FILE: path.join(root, 'nope') } }), /TAG_ARGS_FILE .* absent/);
|
|
93
|
+
assert.throws(() => composedCoordinate({ ...base, imageName: '', env: ENV }), /--image-name/);
|
|
94
|
+
assert.throws(() => composedCoordinate({ ...base, env: ENV, hash: () => 'not-a-digest' }), /not a sha256/);
|
|
100
95
|
});
|
|
101
96
|
|
|
102
97
|
test('emitCoordinate prints every output and appends them to GITHUB_OUTPUT', () => {
|
|
103
98
|
const file = path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'xema-out-')), 'out');
|
|
104
99
|
const lines = [];
|
|
105
|
-
emitCoordinate({ image: 'i', content_tag: HASH,
|
|
106
|
-
assert.equal(fs.readFileSync(file, 'utf8'), `image=i\ncontent_tag=${HASH}\
|
|
107
|
-
assert.match(lines.at(-1), /
|
|
100
|
+
emitCoordinate({ image: 'i', content_tag: HASH, cross_repo_pins: '0', entry: '{}' }, { env: { GITHUB_OUTPUT: file }, log: (l) => lines.push(l) });
|
|
101
|
+
assert.equal(fs.readFileSync(file, 'utf8'), `image=i\ncontent_tag=${HASH}\ncross_repo_pins=0\nentry={}\n`);
|
|
102
|
+
assert.match(lines.at(-1), /0 cross-repo pins/);
|
|
108
103
|
});
|
|
109
104
|
|
|
110
105
|
test('a package whose exports map lacks ./package.json still resolves, as published 0.2.2 does', () => {
|
package/src/composed-image.mjs
CHANGED
|
@@ -55,8 +55,9 @@
|
|
|
55
55
|
// user and NODE_ENV: `load` runs in the builder, which is not what ships.
|
|
56
56
|
// coordinate --image-name <name>
|
|
57
57
|
// the image's content tag (computed ONLY by the repository's own
|
|
58
|
-
// `xema-compute-image-tag`) and
|
|
59
|
-
// (`composed-image-coordinate.mjs`;
|
|
58
|
+
// `xema-compute-image-tag`) and its cross-repository pin count; pure, no
|
|
59
|
+
// registry probe or credential (`composed-image-coordinate.mjs`;
|
|
60
|
+
// environment-driven, GITHUB_OUTPUT).
|
|
60
61
|
// smoke-refusal <image> [--member <biomeId>/<componentKey>]x2
|
|
61
62
|
// boot the pushed image with a declaration the host must refuse
|
|
62
63
|
// (`smoke-refusal.mjs`).
|
|
@@ -95,7 +96,7 @@ import { pathToFileURL } from 'node:url';
|
|
|
95
96
|
|
|
96
97
|
import { COMPOSED_IMAGE_DIR } from './composed-image-paths.mjs';
|
|
97
98
|
import { composedCoordinate, emitCoordinate } from './composed-image-coordinate.mjs';
|
|
98
|
-
import { describeErrorChain } from '
|
|
99
|
+
import { describeErrorChain } from '@xemahq/release-policy/registry';
|
|
99
100
|
import { syncComposedDockerfile } from './composed-image-dockerfile.mjs';
|
|
100
101
|
import { IMAGE_BUILD_INPUTS_FILE, memberClosure, memberFilters, readMembers } from './composed-image-members.mjs';
|
|
101
102
|
import { syncPrebuiltDockerfile } from './composed-image-prebuilt-dockerfile.mjs';
|
|
@@ -458,7 +459,7 @@ const USAGE =
|
|
|
458
459
|
' members --write|--check [--manifests <glob>]… [--canonical-dockerfile <path>] [--cross-repo-manifest <path>]\n' +
|
|
459
460
|
' filters [--also <pkg>]… | dirs | pins <committed-lockfile> | index <commit> --build-source <repo> | load [--each]\n' +
|
|
460
461
|
' stage <dir> [--output <rel>]… [--also <pkg>]… | sync-injected | prisma-check | migrate <member> [--root <dir>]\n' +
|
|
461
|
-
' coordinate --image-name <name> (env: SHA REGISTRY REGISTRY_ORG
|
|
462
|
+
' coordinate --image-name <name> (env: SHA REGISTRY REGISTRY_ORG [TAG_ARGS_FILE])\n' +
|
|
462
463
|
' load-image <image>\n' +
|
|
463
464
|
' smoke-refusal <image> [--member <biomeId>/<componentKey>]x2\n' +
|
|
464
465
|
' dockerfile --write|--check --tooling-version <x.y.z> --copy <dir>… --cache-id <id> --build-source <repo>\n' +
|
|
@@ -558,9 +559,8 @@ export function main(argv, { log = console.log, write = (text) => process.stdout
|
|
|
558
559
|
|
|
559
560
|
}
|
|
560
561
|
case 'coordinate':
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
);
|
|
562
|
+
emitCoordinate(composedCoordinate({ root, imageDir, imageName: options['image-name'] }), { log });
|
|
563
|
+
return;
|
|
564
564
|
case 'load-image':
|
|
565
565
|
runImageLoad({ image: argument, log });
|
|
566
566
|
return;
|
|
@@ -238,12 +238,12 @@ test('the bin runs through a symlink, as npm installs it, and exits non-zero on
|
|
|
238
238
|
);
|
|
239
239
|
});
|
|
240
240
|
|
|
241
|
-
test('the new commands parse their options; coordinate
|
|
241
|
+
test('the new commands parse their options; coordinate refuses without an image name', async () => {
|
|
242
242
|
const parsed = parseArguments(['dockerfile', '--write', '--copy', 'a', '--copy', 'b', '--cache-id', 'c', '--tooling-version', '1.2.3']);
|
|
243
243
|
assert.deepEqual(parsed.options.copy, ['a', 'b']);
|
|
244
244
|
assert.equal(parsed.options['cache-id'], 'c');
|
|
245
245
|
assert.deepEqual(parseArguments(['smoke-refusal', 'img', '--member', 'x/y', '--member', 'x/z']).options.member, ['x/y', 'x/z']);
|
|
246
|
-
|
|
246
|
+
assert.throws(() => main(['coordinate'], { log() {} }), /--image-name/);
|
|
247
247
|
assert.throws(() => main(['dockerfile', '--write'], { log() {} }), /exact x\.y\.z/);
|
|
248
248
|
assert.throws(() => main(['smoke-refusal'], { log() {} }), /usage/);
|
|
249
249
|
});
|
package/src/ghcr-manifest.mjs
DELETED
|
@@ -1,72 +0,0 @@
|
|
|
1
|
-
// Is a content-addressed image coordinate already in GHCR? Shared by every
|
|
2
|
-
// repository that plans a build from a content tag (service matrix, composed image).
|
|
3
|
-
//
|
|
4
|
-
// Only 200 and 404 are answers; anything else throws, so an unreachable or
|
|
5
|
-
// unauthorised registry can never read as "missing" and trigger a rebuild.
|
|
6
|
-
|
|
7
|
-
import { registryFetch } from './registry-fetch.mjs';
|
|
8
|
-
|
|
9
|
-
const MANIFEST_ACCEPT = [
|
|
10
|
-
'application/vnd.oci.image.index.v1+json',
|
|
11
|
-
'application/vnd.oci.image.manifest.v1+json',
|
|
12
|
-
'application/vnd.docker.distribution.manifest.list.v2+json',
|
|
13
|
-
'application/vnd.docker.distribution.manifest.v2+json',
|
|
14
|
-
].join(', ');
|
|
15
|
-
|
|
16
|
-
function assertNonEmpty(value, label) {
|
|
17
|
-
if (typeof value !== 'string' || value.trim().length === 0) {
|
|
18
|
-
throw new Error(`${label} is missing or empty`);
|
|
19
|
-
}
|
|
20
|
-
return value;
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
function basicAuthorization(user, token) {
|
|
24
|
-
return `Basic ${Buffer.from(`${user}:${token}`, 'utf8').toString('base64')}`;
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
/**
|
|
28
|
-
* Acquire a pull-only GHCR bearer token for one repository.
|
|
29
|
-
*
|
|
30
|
-
* The endpoint is deliberately fixed to the configured GHCR host. Following a
|
|
31
|
-
* challenge-provided realm while carrying the long-lived credential would let
|
|
32
|
-
* an untrusted/misconfigured registry redirect that credential elsewhere.
|
|
33
|
-
*/
|
|
34
|
-
export async function acquireGhcrPullToken({ registry, repository, user, token, fetchImpl = fetch }) {
|
|
35
|
-
if (registry !== 'ghcr.io') {
|
|
36
|
-
throw new Error(`GHCR manifest check cannot send credentials to registry '${registry}'`);
|
|
37
|
-
}
|
|
38
|
-
assertNonEmpty(user, 'REGISTRY_USER');
|
|
39
|
-
assertNonEmpty(token, 'REGISTRY_TOKEN');
|
|
40
|
-
const url = new URL(`https://${registry}/token`);
|
|
41
|
-
url.searchParams.set('service', registry);
|
|
42
|
-
url.searchParams.set('scope', `repository:${repository}:pull`);
|
|
43
|
-
const response = await registryFetch(fetchImpl, url, {
|
|
44
|
-
method: 'GET',
|
|
45
|
-
headers: { Authorization: basicAuthorization(user, token), Accept: 'application/json' },
|
|
46
|
-
});
|
|
47
|
-
if (response.status !== 200) {
|
|
48
|
-
throw new Error(`${registry}: pull-token request for ${repository} returned HTTP ${response.status}`);
|
|
49
|
-
}
|
|
50
|
-
let body;
|
|
51
|
-
try {
|
|
52
|
-
body = await response.json();
|
|
53
|
-
} catch {
|
|
54
|
-
throw new Error(`${registry}: pull-token response for ${repository} was not JSON`);
|
|
55
|
-
}
|
|
56
|
-
if (typeof body?.token !== 'string' || body.token.length === 0) {
|
|
57
|
-
throw new Error(`${registry}: pull-token response for ${repository} contained no token`);
|
|
58
|
-
}
|
|
59
|
-
return body.token;
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
/** Returns `true` only for a proven existing manifest and `false` only for 404. */
|
|
63
|
-
export async function ghcrManifestExists({ registry, repository, contentHash, user, token, fetchImpl = fetch }) {
|
|
64
|
-
const bearer = await acquireGhcrPullToken({ registry, repository, user, token, fetchImpl });
|
|
65
|
-
const response = await registryFetch(fetchImpl, `https://${registry}/v2/${repository}/manifests/${contentHash}`, {
|
|
66
|
-
method: 'HEAD',
|
|
67
|
-
headers: { Authorization: `Bearer ${bearer}`, Accept: MANIFEST_ACCEPT },
|
|
68
|
-
});
|
|
69
|
-
if (response.status === 200) return true;
|
|
70
|
-
if (response.status === 404) return false;
|
|
71
|
-
throw new Error(`${registry}: manifest lookup for ${repository}:${contentHash} returned HTTP ${response.status}`);
|
|
72
|
-
}
|
|
@@ -1,96 +0,0 @@
|
|
|
1
|
-
import assert from 'node:assert/strict';
|
|
2
|
-
import test from 'node:test';
|
|
3
|
-
|
|
4
|
-
import { acquireGhcrPullToken, ghcrManifestExists } from './ghcr-manifest.mjs';
|
|
5
|
-
|
|
6
|
-
const HASH = 'a'.repeat(64);
|
|
7
|
-
const COMMON = { registry: 'ghcr.io', repository: 'xema-dev/alpha-api', user: 'runner', token: 'secret' };
|
|
8
|
-
|
|
9
|
-
function response(status, body) {
|
|
10
|
-
return {
|
|
11
|
-
status,
|
|
12
|
-
async json() {
|
|
13
|
-
if (body instanceof Error) throw body;
|
|
14
|
-
return body;
|
|
15
|
-
},
|
|
16
|
-
};
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
test('a pull token is requested with the exact repository scope', async () => {
|
|
20
|
-
let captured;
|
|
21
|
-
const token = await acquireGhcrPullToken({
|
|
22
|
-
...COMMON,
|
|
23
|
-
fetchImpl: async (url, init) => {
|
|
24
|
-
captured = { url: String(url), init };
|
|
25
|
-
return response(200, { token: 'bearer-token' });
|
|
26
|
-
},
|
|
27
|
-
});
|
|
28
|
-
assert.equal(token, 'bearer-token');
|
|
29
|
-
const url = new URL(captured.url);
|
|
30
|
-
assert.equal(url.origin + url.pathname, 'https://ghcr.io/token');
|
|
31
|
-
assert.equal(url.searchParams.get('service'), 'ghcr.io');
|
|
32
|
-
assert.equal(url.searchParams.get('scope'), 'repository:xema-dev/alpha-api:pull');
|
|
33
|
-
assert.equal(captured.init.method, 'GET');
|
|
34
|
-
assert.match(captured.init.headers.Authorization, /^Basic /);
|
|
35
|
-
});
|
|
36
|
-
|
|
37
|
-
test('the long-lived credential can only be sent to GHCR', async () => {
|
|
38
|
-
let called = false;
|
|
39
|
-
await assert.rejects(
|
|
40
|
-
acquireGhcrPullToken({
|
|
41
|
-
...COMMON,
|
|
42
|
-
registry: 'registry.example.com',
|
|
43
|
-
fetchImpl: async () => {
|
|
44
|
-
called = true;
|
|
45
|
-
return response(200, { token: 'bearer-token' });
|
|
46
|
-
},
|
|
47
|
-
}),
|
|
48
|
-
/cannot send credentials/,
|
|
49
|
-
);
|
|
50
|
-
assert.equal(called, false);
|
|
51
|
-
});
|
|
52
|
-
|
|
53
|
-
test('a missing credential is refused before any request', async () => {
|
|
54
|
-
await assert.rejects(acquireGhcrPullToken({ ...COMMON, token: '', fetchImpl: async () => assert.fail('requested') }), /REGISTRY_TOKEN/);
|
|
55
|
-
});
|
|
56
|
-
|
|
57
|
-
test('token acquisition fails closed on every uncertain response', async () => {
|
|
58
|
-
for (const status of [401, 403, 404, 429, 500]) {
|
|
59
|
-
await assert.rejects(acquireGhcrPullToken({ ...COMMON, fetchImpl: async () => response(status, {}) }), new RegExp(`HTTP ${status}`));
|
|
60
|
-
}
|
|
61
|
-
for (const body of [{}, { token: '' }, new Error('invalid json')]) {
|
|
62
|
-
await assert.rejects(acquireGhcrPullToken({ ...COMMON, fetchImpl: async () => response(200, body) }), /token response/);
|
|
63
|
-
}
|
|
64
|
-
});
|
|
65
|
-
|
|
66
|
-
test('only authenticated 200 and 404 are classified', async () => {
|
|
67
|
-
for (const [status, expected] of [[200, true], [404, false]]) {
|
|
68
|
-
const calls = [];
|
|
69
|
-
const exists = await ghcrManifestExists({
|
|
70
|
-
...COMMON,
|
|
71
|
-
contentHash: HASH,
|
|
72
|
-
fetchImpl: async (url, init) => {
|
|
73
|
-
calls.push({ url: String(url), init });
|
|
74
|
-
return calls.length === 1 ? response(200, { token: 'bearer-token' }) : response(status, {});
|
|
75
|
-
},
|
|
76
|
-
});
|
|
77
|
-
assert.equal(exists, expected);
|
|
78
|
-
assert.equal(calls[1].url, `https://ghcr.io/v2/xema-dev/alpha-api/manifests/${HASH}`);
|
|
79
|
-
assert.equal(calls[1].init.method, 'HEAD');
|
|
80
|
-
assert.equal(calls[1].init.headers.Authorization, 'Bearer bearer-token');
|
|
81
|
-
}
|
|
82
|
-
for (const status of [401, 403, 429, 500]) {
|
|
83
|
-
let count = 0;
|
|
84
|
-
await assert.rejects(
|
|
85
|
-
ghcrManifestExists({
|
|
86
|
-
...COMMON,
|
|
87
|
-
contentHash: HASH,
|
|
88
|
-
fetchImpl: async () => {
|
|
89
|
-
count += 1;
|
|
90
|
-
return count === 1 ? response(200, { token: 'bearer-token' }) : response(status, {});
|
|
91
|
-
},
|
|
92
|
-
}),
|
|
93
|
-
new RegExp(`HTTP ${status}`),
|
|
94
|
-
);
|
|
95
|
-
}
|
|
96
|
-
});
|
package/src/registry-fetch.mjs
DELETED
|
@@ -1,82 +0,0 @@
|
|
|
1
|
-
// The one place a registry request is sent and a network failure is named.
|
|
2
|
-
//
|
|
3
|
-
// Node's fetch rejects with `TypeError: fetch failed` and keeps the real reason
|
|
4
|
-
// (ENOTFOUND, ECONNREFUSED, a TLS error, a timeout) in `error.cause`. Letting that
|
|
5
|
-
// escape as a bare message hides which request failed and why.
|
|
6
|
-
|
|
7
|
-
/** The URL with credentials and every query value removed; only the query keys remain. */
|
|
8
|
-
export function redactUrl(input) {
|
|
9
|
-
let url;
|
|
10
|
-
try {
|
|
11
|
-
url = new URL(String(input));
|
|
12
|
-
} catch {
|
|
13
|
-
return '<unparseable url>';
|
|
14
|
-
}
|
|
15
|
-
url.username = '';
|
|
16
|
-
url.password = '';
|
|
17
|
-
const keys = [...new Set(url.searchParams.keys())];
|
|
18
|
-
url.search = '';
|
|
19
|
-
url.hash = '';
|
|
20
|
-
return keys.length > 0 ? `${url.href}?${keys.map((key) => `${key}=<redacted>`).join('&')}` : url.href;
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
function describeOne(error) {
|
|
24
|
-
if (!(error instanceof Error)) return String(error);
|
|
25
|
-
const details = ['code', 'errno', 'syscall', 'hostname', 'address', 'port']
|
|
26
|
-
.filter((key) => error[key] !== undefined)
|
|
27
|
-
.map((key) => `${key}=${error[key]}`);
|
|
28
|
-
const head = `${error.name}: ${error.message}`;
|
|
29
|
-
return details.length > 0 ? `${head} (${details.join(', ')})` : head;
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
/** One line per link of the `cause` chain, outermost first. Bounded against cycles. */
|
|
33
|
-
export function describeErrorChain(error) {
|
|
34
|
-
const lines = [];
|
|
35
|
-
const seen = new Set();
|
|
36
|
-
for (let current = error; current !== undefined && !seen.has(current) && lines.length < 10; ) {
|
|
37
|
-
lines.push(describeOne(current));
|
|
38
|
-
if (!(current instanceof Error)) break;
|
|
39
|
-
seen.add(current);
|
|
40
|
-
current = current.cause;
|
|
41
|
-
}
|
|
42
|
-
return lines;
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
/** Total attempts per request, the first included. */
|
|
46
|
-
export const MAX_ATTEMPTS = 3;
|
|
47
|
-
/** Fixed wait in milliseconds after failed attempt n (1-based); deterministic, no jitter. */
|
|
48
|
-
export const RETRY_BACKOFF_MS = [1000, 3000];
|
|
49
|
-
|
|
50
|
-
const realSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
51
|
-
|
|
52
|
-
function causeCode(error) {
|
|
53
|
-
for (let current = error, depth = 0; current instanceof Error && depth < 10; current = current.cause, depth += 1) {
|
|
54
|
-
if (current.code !== undefined) return String(current.code);
|
|
55
|
-
}
|
|
56
|
-
return 'unknown';
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
/**
|
|
60
|
-
* Send one registry request. Only a transport failure (the fetch promise rejects) is
|
|
61
|
-
* retried (an abort, `AbortError` or an aborted `init.signal`, is rethrown at once), up to MAX_ATTEMPTS with RETRY_BACKOFF_MS between attempts; any HTTP response,
|
|
62
|
-
* whatever its status, is returned as is. The final failure is thrown naming the method,
|
|
63
|
-
* the redacted URL and the attempt count, with the last error as `cause`. Headers are
|
|
64
|
-
* never reported. `options.sleep` replaces the real wait (for tests).
|
|
65
|
-
*/
|
|
66
|
-
export async function registryFetch(fetchImpl, url, init, { sleep = realSleep } = {}) {
|
|
67
|
-
const method = init?.method ?? 'GET';
|
|
68
|
-
for (let attempt = 1; ; attempt += 1) {
|
|
69
|
-
try {
|
|
70
|
-
return await fetchImpl(url, init);
|
|
71
|
-
} catch (error) {
|
|
72
|
-
if (error?.name === 'AbortError' || init?.signal?.aborted === true) throw error;
|
|
73
|
-
if (attempt >= MAX_ATTEMPTS) {
|
|
74
|
-
throw new Error(`${method} ${redactUrl(url)} failed after ${attempt} attempts`, { cause: error });
|
|
75
|
-
}
|
|
76
|
-
process.stderr.write(
|
|
77
|
-
`registry request failed (attempt ${attempt} of ${MAX_ATTEMPTS}): ${method} ${redactUrl(url)} cause=${causeCode(error)}; retrying\n`,
|
|
78
|
-
);
|
|
79
|
-
await sleep(RETRY_BACKOFF_MS[attempt - 1]);
|
|
80
|
-
}
|
|
81
|
-
}
|
|
82
|
-
}
|
|
@@ -1,151 +0,0 @@
|
|
|
1
|
-
import assert from 'node:assert/strict';
|
|
2
|
-
import { spawnSync } from 'node:child_process';
|
|
3
|
-
import fs from 'node:fs';
|
|
4
|
-
import net from 'node:net';
|
|
5
|
-
import os from 'node:os';
|
|
6
|
-
import path from 'node:path';
|
|
7
|
-
import test from 'node:test';
|
|
8
|
-
import { fileURLToPath } from 'node:url';
|
|
9
|
-
|
|
10
|
-
import { MAX_ATTEMPTS, RETRY_BACKOFF_MS, describeErrorChain, redactUrl, registryFetch } from './registry-fetch.mjs';
|
|
11
|
-
|
|
12
|
-
const BIN = path.join(path.dirname(fileURLToPath(import.meta.url)), 'composed-image.mjs');
|
|
13
|
-
|
|
14
|
-
async function closedPort() {
|
|
15
|
-
const server = net.createServer();
|
|
16
|
-
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
|
|
17
|
-
const { port } = server.address();
|
|
18
|
-
await new Promise((resolve) => server.close(resolve));
|
|
19
|
-
return port;
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
test('redactUrl drops credentials, query values and the fragment', () => {
|
|
23
|
-
const out = redactUrl('https://user:pw@ghcr.io/token?service=ghcr.io&scope=secret-scope#frag');
|
|
24
|
-
assert.equal(out, 'https://ghcr.io/token?service=<redacted>&scope=<redacted>');
|
|
25
|
-
assert.equal(redactUrl('not a url'), '<unparseable url>');
|
|
26
|
-
});
|
|
27
|
-
|
|
28
|
-
test('a refused connection names the method, the URL and the cause code, never the headers', async () => {
|
|
29
|
-
const port = await closedPort();
|
|
30
|
-
const url = `http://127.0.0.1:${port}/v2/org/img/manifests/abc?access_token=SECRETQ`;
|
|
31
|
-
const error = await registryFetch(fetch, url, { method: 'HEAD', headers: { Authorization: 'Bearer SECRETH' } }, { sleep: async () => {} }).then(
|
|
32
|
-
() => assert.fail('expected the request to fail'),
|
|
33
|
-
(caught) => caught,
|
|
34
|
-
);
|
|
35
|
-
const text = [error.message, ...describeErrorChain(error)].join('\n');
|
|
36
|
-
assert.match(error.message, new RegExp(`^HEAD http://127\\.0\\.0\\.1:${port}/v2/org/img/manifests/abc\\?access_token=<redacted> failed after 3 attempts$`));
|
|
37
|
-
assert.match(text, /ECONNREFUSED/);
|
|
38
|
-
assert.doesNotMatch(text, /SECRETQ|SECRETH/);
|
|
39
|
-
});
|
|
40
|
-
|
|
41
|
-
test('describeErrorChain walks the cause chain and survives a cycle', () => {
|
|
42
|
-
const inner = Object.assign(new Error('getaddrinfo ENOTFOUND ghcr.io'), { code: 'ENOTFOUND', hostname: 'ghcr.io' });
|
|
43
|
-
const outer = new TypeError('fetch failed', { cause: inner });
|
|
44
|
-
inner.cause = outer;
|
|
45
|
-
const lines = describeErrorChain(outer);
|
|
46
|
-
assert.equal(lines.length, 2);
|
|
47
|
-
assert.match(lines[1], /code=ENOTFOUND, hostname=ghcr\.io/);
|
|
48
|
-
});
|
|
49
|
-
|
|
50
|
-
test('the CLI prints the failing request and the cause chain to stderr and exits 1', () => {
|
|
51
|
-
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'fetch-cause-'));
|
|
52
|
-
try {
|
|
53
|
-
const hashDir = path.join(root, 'node_modules', '@xemahq', 'distribution-source-hash');
|
|
54
|
-
fs.mkdirSync(hashDir, { recursive: true });
|
|
55
|
-
fs.writeFileSync(
|
|
56
|
-
path.join(hashDir, 'package.json'),
|
|
57
|
-
JSON.stringify({ name: '@xemahq/distribution-source-hash', bin: { 'xema-compute-image-tag': 'bin.js' } }),
|
|
58
|
-
);
|
|
59
|
-
fs.writeFileSync(path.join(hashDir, 'bin.js'), `console.log('${'a'.repeat(64)}');\n`);
|
|
60
|
-
fs.writeFileSync(path.join(root, 'package.json'), '{}');
|
|
61
|
-
const preload = path.join(root, 'preload.mjs');
|
|
62
|
-
fs.writeFileSync(
|
|
63
|
-
preload,
|
|
64
|
-
`globalThis.fetch = async () => { throw new TypeError('fetch failed', { cause: Object.assign(new Error('getaddrinfo ENOTFOUND ghcr.io'), { code: 'ENOTFOUND', hostname: 'ghcr.io' }) }); };\n`,
|
|
65
|
-
);
|
|
66
|
-
const result = spawnSync(process.execPath, ['--import', preload, BIN, 'coordinate', '--image-name', 'img'], {
|
|
67
|
-
cwd: root,
|
|
68
|
-
encoding: 'utf8',
|
|
69
|
-
env: {
|
|
70
|
-
PATH: process.env.PATH,
|
|
71
|
-
SHA: 'b'.repeat(40),
|
|
72
|
-
REGISTRY: 'ghcr.io',
|
|
73
|
-
REGISTRY_ORG: 'org',
|
|
74
|
-
REGISTRY_USER: 'u',
|
|
75
|
-
REGISTRY_TOKEN: 'TOPSECRET',
|
|
76
|
-
},
|
|
77
|
-
});
|
|
78
|
-
assert.equal(result.status, 1);
|
|
79
|
-
assert.match(result.stderr, /GET https:\/\/ghcr\.io\/token\?service=<redacted>&scope=<redacted> failed after 3 attempts/);
|
|
80
|
-
assert.match(result.stderr, /caused by TypeError: fetch failed/);
|
|
81
|
-
assert.match(result.stderr, /caused by Error: getaddrinfo ENOTFOUND ghcr\.io \(code=ENOTFOUND, hostname=ghcr\.io\)/);
|
|
82
|
-
assert.doesNotMatch(result.stderr, /TOPSECRET/);
|
|
83
|
-
} finally {
|
|
84
|
-
fs.rmSync(root, { recursive: true, force: true });
|
|
85
|
-
}
|
|
86
|
-
});
|
|
87
|
-
|
|
88
|
-
const transportError = () =>
|
|
89
|
-
new TypeError('fetch failed', { cause: Object.assign(new Error('getaddrinfo EAI_AGAIN ghcr.io'), { code: 'EAI_AGAIN' }) });
|
|
90
|
-
|
|
91
|
-
test('a transient transport failure is retried once and then returns the response', async () => {
|
|
92
|
-
let calls = 0;
|
|
93
|
-
const waits = [];
|
|
94
|
-
const response = new Response('ok', { status: 200 });
|
|
95
|
-
const fetchImpl = async () => {
|
|
96
|
-
calls += 1;
|
|
97
|
-
if (calls === 1) throw transportError();
|
|
98
|
-
return response;
|
|
99
|
-
};
|
|
100
|
-
const result = await registryFetch(fetchImpl, 'https://ghcr.io/token?scope=x', { method: 'GET' }, { sleep: async (ms) => waits.push(ms) });
|
|
101
|
-
assert.equal(calls, 2);
|
|
102
|
-
assert.equal(result, response);
|
|
103
|
-
assert.deepEqual(waits, [RETRY_BACKOFF_MS[0]]);
|
|
104
|
-
});
|
|
105
|
-
|
|
106
|
-
test('three transport failures throw naming 3 attempts and keep the cause chain', async () => {
|
|
107
|
-
let calls = 0;
|
|
108
|
-
const waits = [];
|
|
109
|
-
const fetchImpl = async () => {
|
|
110
|
-
calls += 1;
|
|
111
|
-
throw transportError();
|
|
112
|
-
};
|
|
113
|
-
const error = await registryFetch(fetchImpl, 'https://ghcr.io/v2/o/i/manifests/a', { method: 'HEAD' }, { sleep: async (ms) => waits.push(ms) }).then(
|
|
114
|
-
() => assert.fail('expected failure'),
|
|
115
|
-
(caught) => caught,
|
|
116
|
-
);
|
|
117
|
-
assert.equal(calls, MAX_ATTEMPTS);
|
|
118
|
-
assert.deepEqual(waits, [1000, 3000]);
|
|
119
|
-
assert.equal(error.message, 'HEAD https://ghcr.io/v2/o/i/manifests/a failed after 3 attempts');
|
|
120
|
-
assert.match(describeErrorChain(error).join('\n'), /fetch failed[\s\S]*code=EAI_AGAIN/);
|
|
121
|
-
});
|
|
122
|
-
|
|
123
|
-
test('an HTTP error status is returned after one call, never retried', async () => {
|
|
124
|
-
let calls = 0;
|
|
125
|
-
const fetchImpl = async () => {
|
|
126
|
-
calls += 1;
|
|
127
|
-
return new Response('down', { status: 503 });
|
|
128
|
-
};
|
|
129
|
-
const result = await registryFetch(fetchImpl, 'https://ghcr.io/token', { method: 'GET' }, { sleep: async () => assert.fail('must not sleep') });
|
|
130
|
-
assert.equal(calls, 1);
|
|
131
|
-
assert.equal(result.status, 503);
|
|
132
|
-
});
|
|
133
|
-
|
|
134
|
-
test('an abort is rethrown at once, never retried', async () => {
|
|
135
|
-
let calls = 0;
|
|
136
|
-
const controller = new AbortController();
|
|
137
|
-
controller.abort();
|
|
138
|
-
const fetchImpl = async () => {
|
|
139
|
-
calls += 1;
|
|
140
|
-
throw new Error('request failed');
|
|
141
|
-
};
|
|
142
|
-
const error = await registryFetch(fetchImpl, 'https://ghcr.io/token', { method: 'GET', signal: controller.signal }, { sleep: async () => assert.fail('must not sleep') }).then(
|
|
143
|
-
() => assert.fail('expected failure'),
|
|
144
|
-
(caught) => caught,
|
|
145
|
-
);
|
|
146
|
-
assert.equal(calls, 1);
|
|
147
|
-
assert.equal(error.message, 'request failed');
|
|
148
|
-
let named = 0;
|
|
149
|
-
await registryFetch(async () => { named += 1; throw new DOMException('aborted', 'AbortError'); }, 'https://ghcr.io/token', {}, { sleep: async () => assert.fail('must not sleep') }).catch(() => {});
|
|
150
|
-
assert.equal(named, 1);
|
|
151
|
-
});
|