@xemahq/repo-build-tooling 0.11.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/README.md +1 -1
- 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-members.mjs +25 -11
- package/src/composed-image-prebuilt-dockerfile.mjs +17 -2
- package/src/composed-image-prebuilt-dockerfile.test.mjs +14 -1
- package/src/composed-image-stage.mjs +4 -2
- package/src/composed-image-stage.test.mjs +51 -0
- package/src/composed-image.mjs +18 -16
- 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/README.md
CHANGED
|
@@ -33,7 +33,7 @@ pnpm exec xema-scrub-swagger-paths dist
|
|
|
33
33
|
| `xema-check-no-vendored-deps` | Fails on a `workspace:` range for a package the repository does not own |
|
|
34
34
|
| `xema-check-workspace-ranges` | Fails on a `workspace:` range the local package does not satisfy |
|
|
35
35
|
|
|
36
|
-
`xema-composed-image members --write|--check` accepts `--manifests <glob>` (repeatable, default `**/xema-biome.json`), `--canonical-dockerfile <path>` and `--cross-repo-manifest <path>`. `xema-composed-image dockerfile` requires `--tooling-version`, `--copy`, `--cache-id` and `--build-source`. `dockerfile --prebuilt` renders the Dockerfile and `Dockerfile.dockerignore` of an image assembled from `stage <dir> [--output <rel>]…` output (requires `--tooling-version`, `--cache-id`, `--build-source`; `--copy` and `--stage-dir` optional); `load --each` loads every member in its own process. Run `xema-composed-image` with no arguments for the full usage.
|
|
36
|
+
`xema-composed-image members --write|--check` accepts `--manifests <glob>` (repeatable, default `**/xema-biome.json`), `--canonical-dockerfile <path>` and `--cross-repo-manifest <path>`. `xema-composed-image dockerfile` requires `--tooling-version`, `--copy`, `--cache-id` and `--build-source`. `dockerfile --prebuilt` renders the Dockerfile and `Dockerfile.dockerignore` of an image assembled from `stage <dir> [--output <rel>]… [--also <pkg>]…` output (requires `--tooling-version`, `--cache-id`, `--build-source`; `--copy` and `--stage-dir` optional); `load --each` loads every member in its own process. `stage` and `filters` accept `--also <workspace package name>` (repeatable): the package's closure is built and staged (built outputs only, no Prisma checks) alongside the members; an unknown name is refused. Run `xema-composed-image` with no arguments for the full usage.
|
|
37
37
|
|
|
38
38
|
Every step refuses rather than skips. The package has no dependencies.
|
|
39
39
|
|
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', () => {
|
|
@@ -43,10 +43,15 @@ function packageName(root, directory) {
|
|
|
43
43
|
return manifest.name;
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
-
/**
|
|
46
|
+
/**
|
|
47
|
+
* The pnpm `--filter <package>...` arguments covering every member's closure, then
|
|
48
|
+
* each `also` package's (a workspace package name); a name a member already
|
|
49
|
+
* selected is listed once.
|
|
50
|
+
*/
|
|
47
51
|
export function memberFilters(options = {}) {
|
|
48
52
|
const root = options.root ?? process.cwd();
|
|
49
|
-
|
|
53
|
+
const names = readMembers(options).map(([, directory]) => packageName(root, directory));
|
|
54
|
+
return [...new Set([...names, ...(options.also ?? [])])].map((name) => `--filter ${name}...`);
|
|
50
55
|
}
|
|
51
56
|
|
|
52
57
|
|
|
@@ -66,21 +71,30 @@ export function memberManifests({ root = process.cwd(), imageDir = COMPOSED_IMAG
|
|
|
66
71
|
|
|
67
72
|
const defaultExec = (file, args, { cwd }) => execFileSync(file, args, { cwd, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 });
|
|
68
73
|
|
|
69
|
-
|
|
70
|
-
* The workspace packages the members' filters select (the members and everything
|
|
71
|
-
* they depend on inside the workspace): repository-relative directories, sorted.
|
|
72
|
-
* `exec(file, args, { cwd })` returns stdout; injectable for tests.
|
|
73
|
-
*/
|
|
74
|
-
export function memberClosure({ root = process.cwd(), imageDir = COMPOSED_IMAGE_DIR, exec = defaultExec } = {}) {
|
|
75
|
-
const words = memberFilters({ root, imageDir }).flatMap((filter) => filter.split(' '));
|
|
76
|
-
const listing = exec('pnpm', ['ls', '-r', '--depth', '-1', '--json', ...words], { cwd: root });
|
|
74
|
+
function parseListing(listing) {
|
|
77
75
|
let packages;
|
|
78
76
|
try {
|
|
79
77
|
packages = JSON.parse(listing);
|
|
80
78
|
} catch (error) {
|
|
81
79
|
throw new Error(`pnpm ls did not print JSON: ${error.message}`);
|
|
82
80
|
}
|
|
83
|
-
if (!Array.isArray(packages)
|
|
81
|
+
if (!Array.isArray(packages)) throw new Error('pnpm ls did not print a JSON array');
|
|
82
|
+
return packages;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* The workspace packages the members' filters select (the members and everything
|
|
87
|
+
* they depend on inside the workspace): repository-relative directories, sorted.
|
|
88
|
+
* `exec(file, args, { cwd })` returns stdout; injectable for tests.
|
|
89
|
+
*/
|
|
90
|
+
export function memberClosure({ root = process.cwd(), imageDir = COMPOSED_IMAGE_DIR, exec = defaultExec, also = [] } = {}) {
|
|
91
|
+
for (const name of also) {
|
|
92
|
+
const found = parseListing(exec('pnpm', ['ls', '-r', '--depth', '-1', '--json', '--filter', name], { cwd: root }));
|
|
93
|
+
if (!found.some((pkg) => pkg?.name === name)) throw new Error(`--also ${name}: no workspace package has that name`);
|
|
94
|
+
}
|
|
95
|
+
const words = memberFilters({ root, imageDir, also }).flatMap((filter) => filter.split(' '));
|
|
96
|
+
const packages = parseListing(exec('pnpm', ['ls', '-r', '--depth', '-1', '--json', ...words], { cwd: root }));
|
|
97
|
+
if (packages.length === 0) throw new Error('pnpm ls selected no workspace package');
|
|
84
98
|
const real = realpathSync(root);
|
|
85
99
|
const directories = new Set();
|
|
86
100
|
for (const pkg of packages) {
|
|
@@ -123,7 +123,9 @@ export function renderPrebuiltDockerfile({
|
|
|
123
123
|
# assemble source of the closure + the staged outputs, the injected copies synced to
|
|
124
124
|
# their \`files\`, then the proofs on the target arch: Prisma clients run, the
|
|
125
125
|
# member index is written, every member loads ALONE as \`node\`, production.
|
|
126
|
-
# (final) \`/monorepo\`
|
|
126
|
+
# (final) \`/monorepo\` of assemble, shipped as THREE layers so a commit that changes only
|
|
127
|
+
# members' code re-pushes only the layers that changed (see the final stage).
|
|
128
|
+
# Nothing of the tool ships.
|
|
127
129
|
#
|
|
128
130
|
# The \`--filter <package>...\` words below are LITERAL: the content hash of this image reads
|
|
129
131
|
# them to narrow the lockfile to the members' closures.
|
|
@@ -168,9 +170,22 @@ USER node
|
|
|
168
170
|
RUN ${toolMount} \\
|
|
169
171
|
NODE_ENV=production node ${node} load --each${dirFlag}
|
|
170
172
|
|
|
173
|
+
# The final stage is layered by how often each part changes, so a registry and a node
|
|
174
|
+
# already holding the stable layers fetch only the rest. The three copies overlay to
|
|
175
|
+
# exactly assemble's /monorepo (proven by a tree diff of both images).
|
|
176
|
+
# 1 deps root manifests, the external .pnpm store, and the injected stubs that hold
|
|
177
|
+
# only package.json. Stable while the lockfile and the closure's package.json
|
|
178
|
+
# files are unchanged.
|
|
179
|
+
# 2 assemble members, dist, Prisma clients, member index, migrate script: everything
|
|
180
|
+
# but the .pnpm store, whose packages layer 1 already carries.
|
|
181
|
+
# 3 injected the workspace copies \`node_modules/.pnpm/*@file+*\`: \`sync-injected\` fills
|
|
182
|
+
# them from the members' files, so they change with every commit. \`--parents\`
|
|
183
|
+
# keeps their paths under /.
|
|
171
184
|
FROM base
|
|
172
185
|
WORKDIR /monorepo
|
|
173
|
-
COPY --from=
|
|
186
|
+
COPY --from=deps /monorepo /monorepo
|
|
187
|
+
COPY --from=assemble --exclude=node_modules/.pnpm /monorepo /monorepo
|
|
188
|
+
COPY --from=assemble --parents /monorepo/node_modules/.pnpm/*@file+* /
|
|
174
189
|
ENV NODE_ENV=production
|
|
175
190
|
# Set by the IMAGE, never by a deployment (kernel \`COMPOSED_PROCESS_ENV.MemberIndex\`).
|
|
176
191
|
ENV XEMA_COMPOSED_MEMBER_INDEX=/monorepo/xema-composed-member-index.json
|
|
@@ -39,7 +39,7 @@ test('the tool is only bind-mounted, and the final stage carries nothing of it',
|
|
|
39
39
|
assert.doesNotMatch(dockerfile, /\bnpm install|COPY \.xema-stage\/tool/);
|
|
40
40
|
const final = dockerfile.split('\nFROM base\n')[1];
|
|
41
41
|
assert.doesNotMatch(final, /xema-composed-image|repo-build-tooling|npm install|\/opt\//);
|
|
42
|
-
assert.
|
|
42
|
+
assert.doesNotMatch(final, /COPY --from=assemble \/monorepo \/monorepo/);
|
|
43
43
|
assert.match(dockerfile, /USER node\nRUN --mount=\S+ \\\n {2}NODE_ENV=production node \/opt\/xema-composed-image\/src\/composed-image\.mjs load --each\n/);
|
|
44
44
|
assert.match(dockerfile, /cp \/opt\/xema-composed-image\/src\/composed-migrate\.mjs \/monorepo\/xema-composed-migrate\.mjs/);
|
|
45
45
|
});
|
|
@@ -101,3 +101,16 @@ test('a token-using repository gets single-backslash line continuations (a doubl
|
|
|
101
101
|
assert.match(dockerfile, /--mount=type=secret,id=npm_token,required=true \\\n/);
|
|
102
102
|
for (const line of dockerfile.split('\n')) assert.doesNotMatch(line, /\\\\\s*$/, `line ends with a doubled backslash: ${line}`);
|
|
103
103
|
});
|
|
104
|
+
|
|
105
|
+
test('the final stage is layered: deps, then assemble without the .pnpm store, then the injected copies', () => {
|
|
106
|
+
const { dockerfile } = renderPrebuiltDockerfile(options(workspace()));
|
|
107
|
+
const final = dockerfile.split('\nFROM base\n')[1];
|
|
108
|
+
const copies = final.split('\n').filter((line) => line.startsWith('COPY '));
|
|
109
|
+
assert.deepEqual(copies, [
|
|
110
|
+
'COPY --from=deps /monorepo /monorepo',
|
|
111
|
+
'COPY --from=assemble --exclude=node_modules/.pnpm /monorepo /monorepo',
|
|
112
|
+
'COPY --from=assemble --parents /monorepo/node_modules/.pnpm/*@file+* /',
|
|
113
|
+
]);
|
|
114
|
+
assert.ok(final.indexOf('ENV NODE_ENV=production') > final.lastIndexOf('COPY --from=assemble'));
|
|
115
|
+
assert.match(final, /USER node\nCMD \["node", "\/monorepo\/xema-composed-process"\]\n$/);
|
|
116
|
+
});
|
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
// directory the `--prebuilt` Dockerfile copies from. Docker compiles nothing; this
|
|
4
4
|
// is the only place outputs enter an image, so it is also where they are refused.
|
|
5
5
|
//
|
|
6
|
-
// <dir>/out/<package dir>/<output>/ every closure package's outputs (default: dist)
|
|
6
|
+
// <dir>/out/<package dir>/<output>/ every closure package's outputs (default: dist);
|
|
7
|
+
// `--also <pkg>` adds that package's closure (no member checks)
|
|
7
8
|
// <dir>/out/<member>/node_modules/.prisma/ every member's generated Prisma clients
|
|
8
9
|
// <dir>/tool/ this package (package.json + src, no tests):
|
|
9
10
|
// bind-mounted into the build, never copied into a layer
|
|
@@ -65,6 +66,7 @@ export async function stageBuildOutputs({
|
|
|
65
66
|
imageDir = COMPOSED_IMAGE_DIR,
|
|
66
67
|
dir,
|
|
67
68
|
outputs = DEFAULT_OUTPUTS,
|
|
69
|
+
also = [],
|
|
68
70
|
exec,
|
|
69
71
|
nodeMajor = process.versions.node.split('.')[0],
|
|
70
72
|
formatSpawn,
|
|
@@ -90,7 +92,7 @@ export async function stageBuildOutputs({
|
|
|
90
92
|
|
|
91
93
|
const members = readMembers({ root, imageDir }).map(([key, directory]) => ({ key, directory }));
|
|
92
94
|
const memberDirectories = new Set(members.map((m) => m.directory));
|
|
93
|
-
const closure = memberClosure({ root, imageDir, exec });
|
|
95
|
+
const closure = memberClosure({ root, imageDir, exec, also });
|
|
94
96
|
const schemaJobs = [];
|
|
95
97
|
for (const { key, directory } of members) {
|
|
96
98
|
if (!closure.includes(directory)) problems.push(`${key}: ${directory} is not in the closure pnpm selected`);
|
|
@@ -100,3 +100,54 @@ test('refuses a client generated from another schema than the member carries; th
|
|
|
100
100
|
await assert.rejects(stage(stale), /alpha-db-api: .*differs from the member's prisma\/schema\.prisma .*stale/);
|
|
101
101
|
assert.equal((await stage(ready())).packages, 3);
|
|
102
102
|
});
|
|
103
|
+
|
|
104
|
+
/** An `exec` that answers per-name probes (`--filter <name>`) and the closure listing (`--filter <name>...`). */
|
|
105
|
+
function alsoPnpm(root, known) {
|
|
106
|
+
return (_file, args) => {
|
|
107
|
+
const filters = args.flatMap((a, i) => (args[i - 1] === '--filter' ? [a] : []));
|
|
108
|
+
const names = filters.filter((f) => !f.endsWith('...'));
|
|
109
|
+
if (names.length > 0) return JSON.stringify(known.filter((p) => p.name === names[0]).map((p) => ({ name: p.name, path: path.join(root, p.dir) })));
|
|
110
|
+
const wanted = new Set(filters.map((f) => f.slice(0, -3)));
|
|
111
|
+
const dirs = new Set(['biomes/alpha/api/alpha-api', 'biomes/alpha/api/alpha-db-api', 'packages/sdks/lib-a']);
|
|
112
|
+
for (const p of known) if (wanted.has(p.name)) for (const d of p.closure) dirs.add(d);
|
|
113
|
+
return JSON.stringify([...dirs].map((dir) => ({ name: dir, path: path.join(root, dir) })));
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
const EXTRA = [{ name: '@t/extra', dir: 'packages/sdks/extra', closure: ['packages/sdks/extra', 'packages/sdks/extra-dep'] },
|
|
117
|
+
{ name: '@t/lib-a', dir: 'packages/sdks/lib-a', closure: ['packages/sdks/lib-a'] }];
|
|
118
|
+
|
|
119
|
+
function withExtra() {
|
|
120
|
+
const root = ready();
|
|
121
|
+
write(root, 'packages/sdks/extra/package.json', { name: '@t/extra' });
|
|
122
|
+
write(root, 'packages/sdks/extra/dist/index.js', '// extra\n');
|
|
123
|
+
write(root, 'packages/sdks/extra-dep/dist/index.js', '// dep\n');
|
|
124
|
+
return root;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
test('--also stages the named package closure without member checks, and a member named again is idempotent', async () => {
|
|
128
|
+
const root = withExtra();
|
|
129
|
+
const result = await stage(root, { exec: alsoPnpm(root, EXTRA), also: ['@t/extra', '@t/lib-a'] });
|
|
130
|
+
assert.equal(result.packages, 5);
|
|
131
|
+
const staged = files(path.join(root, '.xema-stage/out'));
|
|
132
|
+
for (const expected of ['packages/sdks/extra/dist/index.js', 'packages/sdks/extra-dep/dist/index.js', 'packages/sdks/lib-a/dist/index.js']) {
|
|
133
|
+
assert.ok(staged.includes(expected), expected);
|
|
134
|
+
}
|
|
135
|
+
assert.ok(!staged.some((f) => f.startsWith('packages/sdks/extra/node_modules')));
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test('--also refuses an unknown package and a native addon in the extra closure', async () => {
|
|
139
|
+
const root = withExtra();
|
|
140
|
+
await assert.rejects(stage(root, { exec: alsoPnpm(root, EXTRA), also: ['@t/nope'] }), /--also @t\/nope: no workspace package/);
|
|
141
|
+
assert.equal(fs.existsSync(path.join(root, '.xema-stage')), false);
|
|
142
|
+
write(root, 'packages/sdks/extra-dep/dist/a.node', 'x');
|
|
143
|
+
await assert.rejects(stage(root, { exec: alsoPnpm(root, EXTRA), also: ['@t/extra'] }), /extra-dep\/dist\/a\.node/);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
test('filters and the argument parser carry --also', async () => {
|
|
147
|
+
const root = ready();
|
|
148
|
+
const { memberFilters } = await import('./composed-image-members.mjs');
|
|
149
|
+
const { parseArguments } = await import('./composed-image.mjs');
|
|
150
|
+
assert.deepEqual(memberFilters({ root, also: ['@t/extra', '@t/lib-a', '@t/extra'] }).slice(-2), ['--filter @t/extra...', '--filter @t/lib-a...']);
|
|
151
|
+
assert.equal(memberFilters({ root, also: ['@t/alpha-api'] }).filter((f) => f === '--filter @t/alpha-api...').length, 1);
|
|
152
|
+
assert.deepEqual(parseArguments(['stage', 'd', '--also', 'a', '--also', 'b']).options.also, ['a', 'b']);
|
|
153
|
+
});
|
package/src/composed-image.mjs
CHANGED
|
@@ -24,7 +24,8 @@
|
|
|
24
24
|
// derive the member set and write / verify
|
|
25
25
|
// `<image-dir>/image-build-inputs.json` (its existence at a commit IS
|
|
26
26
|
// the repository's composed-process declaration).
|
|
27
|
-
// filters
|
|
27
|
+
// filters [--also <pkg>]…
|
|
28
|
+
// print `--filter <package>...` for every member (and each --also package), so install and
|
|
28
29
|
// build cover exactly the members' closures.
|
|
29
30
|
// dirs print every member's directory, one per line.
|
|
30
31
|
// pins <committed-lockfile>
|
|
@@ -38,10 +39,11 @@
|
|
|
38
39
|
// load --each
|
|
39
40
|
// the same check, one `node` process PER member, so a member that only
|
|
40
41
|
// loads because another loaded first is caught (the prebuilt image's gate).
|
|
41
|
-
// stage <dir> [--output <rel>]…
|
|
42
|
+
// stage <dir> [--output <rel>]… [--also <pkg>]…
|
|
42
43
|
// gather the runner's build outputs (default `dist`) and the members'
|
|
43
44
|
// generated Prisma clients into a build-step directory, with this tool,
|
|
44
|
-
// for the `--prebuilt` Dockerfile (`composed-image-stage.mjs`).
|
|
45
|
+
// for the `--prebuilt` Dockerfile (`composed-image-stage.mjs`). Each `--also`
|
|
46
|
+
// workspace package adds its closure's built outputs (no Prisma checks).
|
|
45
47
|
// sync-injected | prisma-check
|
|
46
48
|
// IN-IMAGE proofs of the prebuilt build: the injected workspace copies hold
|
|
47
49
|
// their packages' `files`; every Prisma client runs on the target arch.
|
|
@@ -53,8 +55,9 @@
|
|
|
53
55
|
// user and NODE_ENV: `load` runs in the builder, which is not what ships.
|
|
54
56
|
// coordinate --image-name <name>
|
|
55
57
|
// the image's content tag (computed ONLY by the repository's own
|
|
56
|
-
// `xema-compute-image-tag`) and
|
|
57
|
-
// (`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).
|
|
58
61
|
// smoke-refusal <image> [--member <biomeId>/<componentKey>]x2
|
|
59
62
|
// boot the pushed image with a declaration the host must refuse
|
|
60
63
|
// (`smoke-refusal.mjs`).
|
|
@@ -93,7 +96,7 @@ import { pathToFileURL } from 'node:url';
|
|
|
93
96
|
|
|
94
97
|
import { COMPOSED_IMAGE_DIR } from './composed-image-paths.mjs';
|
|
95
98
|
import { composedCoordinate, emitCoordinate } from './composed-image-coordinate.mjs';
|
|
96
|
-
import { describeErrorChain } from '
|
|
99
|
+
import { describeErrorChain } from '@xemahq/release-policy/registry';
|
|
97
100
|
import { syncComposedDockerfile } from './composed-image-dockerfile.mjs';
|
|
98
101
|
import { IMAGE_BUILD_INPUTS_FILE, memberClosure, memberFilters, readMembers } from './composed-image-members.mjs';
|
|
99
102
|
import { syncPrebuiltDockerfile } from './composed-image-prebuilt-dockerfile.mjs';
|
|
@@ -454,18 +457,18 @@ export function loadMembersEach({ root = process.cwd(), imageDir = COMPOSED_IMAG
|
|
|
454
457
|
const USAGE =
|
|
455
458
|
'usage: xema-composed-image <command> [--root <dir>] [--image-dir <dir>]\n' +
|
|
456
459
|
' members --write|--check [--manifests <glob>]… [--canonical-dockerfile <path>] [--cross-repo-manifest <path>]\n' +
|
|
457
|
-
' filters | dirs | pins <committed-lockfile> | index <commit> --build-source <repo> | load [--each]\n' +
|
|
458
|
-
' stage <dir> [--output <rel>]… | sync-injected | prisma-check | migrate <member> [--root <dir>]\n' +
|
|
459
|
-
' coordinate --image-name <name> (env: SHA REGISTRY REGISTRY_ORG
|
|
460
|
+
' filters [--also <pkg>]… | dirs | pins <committed-lockfile> | index <commit> --build-source <repo> | load [--each]\n' +
|
|
461
|
+
' stage <dir> [--output <rel>]… [--also <pkg>]… | sync-injected | prisma-check | migrate <member> [--root <dir>]\n' +
|
|
462
|
+
' coordinate --image-name <name> (env: SHA REGISTRY REGISTRY_ORG [TAG_ARGS_FILE])\n' +
|
|
460
463
|
' load-image <image>\n' +
|
|
461
464
|
' smoke-refusal <image> [--member <biomeId>/<componentKey>]x2\n' +
|
|
462
465
|
' dockerfile --write|--check --tooling-version <x.y.z> --copy <dir>… --cache-id <id> --build-source <repo>\n' +
|
|
463
466
|
' dockerfile --prebuilt --write|--check --tooling-version <x.y.z> --cache-id <id> --build-source <repo> [--copy <dir>]… [--stage-dir <dir>]';
|
|
464
467
|
|
|
465
|
-
const REPEATABLE = new Set(['manifests', 'copy', 'member', 'output']);
|
|
468
|
+
const REPEATABLE = new Set(['manifests', 'copy', 'member', 'output', 'also']);
|
|
466
469
|
const BOOLEAN = new Set(['prebuilt', 'each']);
|
|
467
470
|
const VALUED = new Set(['root', 'image-dir', 'manifests', 'canonical-dockerfile', 'cross-repo-manifest', 'build-source',
|
|
468
|
-
'image-name', 'tooling-version', 'cache-id', 'copy', 'member', 'stage-dir', 'output',
|
|
471
|
+
'image-name', 'tooling-version', 'cache-id', 'copy', 'member', 'stage-dir', 'output', 'also',
|
|
469
472
|
]);
|
|
470
473
|
|
|
471
474
|
export function parseArguments(argv) {
|
|
@@ -518,7 +521,7 @@ export function main(argv, { log = console.log, write = (text) => process.stdout
|
|
|
518
521
|
return;
|
|
519
522
|
}
|
|
520
523
|
case 'filters':
|
|
521
|
-
write(`${memberFilters({ root, imageDir }).join(' ')}\n`);
|
|
524
|
+
write(`${memberFilters({ root, imageDir, also: options.also }).join(' ')}\n`);
|
|
522
525
|
return;
|
|
523
526
|
case 'dirs':
|
|
524
527
|
write(`${readMembers({ root, imageDir }).map(([, directory]) => directory).join('\n')}\n`);
|
|
@@ -541,7 +544,7 @@ export function main(argv, { log = console.log, write = (text) => process.stdout
|
|
|
541
544
|
log(`composed members: all ${loadMembers({ root, imageDir })} module graph(s) load`);
|
|
542
545
|
return;
|
|
543
546
|
case 'stage': {
|
|
544
|
-
return stageBuildOutputs({ root, imageDir, dir: argument, outputs: options.output }).then((staged) => {
|
|
547
|
+
return stageBuildOutputs({ root, imageDir, dir: argument, outputs: options.output, also: options.also }).then((staged) => {
|
|
545
548
|
log(`composed stage ${argument}: ${staged.packages} package(s), ${staged.outputs} output dir(s), ${staged.files} file(s), tool ${staged.toolFiles} file(s)`);
|
|
546
549
|
});
|
|
547
550
|
}
|
|
@@ -556,9 +559,8 @@ export function main(argv, { log = console.log, write = (text) => process.stdout
|
|
|
556
559
|
|
|
557
560
|
}
|
|
558
561
|
case 'coordinate':
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
);
|
|
562
|
+
emitCoordinate(composedCoordinate({ root, imageDir, imageName: options['image-name'] }), { log });
|
|
563
|
+
return;
|
|
562
564
|
case 'load-image':
|
|
563
565
|
runImageLoad({ image: argument, log });
|
|
564
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
|
-
});
|