@xemahq/repo-build-tooling 0.31.0 → 0.32.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 +2 -2
- package/src/composed-image-prebuilt-dockerfile.mjs +6 -4
- package/src/composed-image-prebuilt-dockerfile.test.mjs +13 -1
- package/src/composed-image-stage-tree.mjs +40 -2
- package/src/composed-image-stage-tree.test.mjs +41 -4
- package/src/service-image-stage.mjs +13 -0
- package/src/service-image.mjs +42 -3
- package/src/service-image.test.mjs +49 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@xemahq/repo-build-tooling",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.32.0",
|
|
4
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)",
|
|
@@ -53,6 +53,6 @@
|
|
|
53
53
|
"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-class.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-in-image-closure.test.mjs && node --test src/composed-image-stage.test.mjs && node --test src/composed-image-stage-tree.test.mjs && node --test src/composed-image-install.test.mjs && node --test src/composed-image-proof.test.mjs && node --test src/composed-image-pnpm-context.test.mjs && node --test src/composed-image-sync-injected.test.mjs && node --test src/composed-image-prisma-check.test.mjs && node --test src/composed-image-prisma-engine.test.mjs && node --test src/composed-migrate.test.mjs && node --test src/promote-image.test.mjs && node --test src/arm64-supplement-needed.test.mjs && node --test src/service-image.test.mjs && node --test src/image-cpu-arch.test.mjs && node --test src/staged-image-proof.test.mjs && node --test src/effective-input.test.mjs"
|
|
54
54
|
},
|
|
55
55
|
"xemaRelease": {
|
|
56
|
-
"sourceDigest": "sha256:
|
|
56
|
+
"sourceDigest": "sha256:cda113cf9ccac7c7af99832d6d0ad68bd3b043b0e99a6e590df9d12cf1a36881"
|
|
57
57
|
}
|
|
58
58
|
}
|
|
@@ -96,7 +96,7 @@ export function renderPrebuiltDockerfile({ root = process.cwd(), imageDir = COMP
|
|
|
96
96
|
const node = `${toolDir}/${IN_IMAGE_ENTRY}`;
|
|
97
97
|
const copies = normalizedCopy(copy).map(copyLine).join('\n');
|
|
98
98
|
const stageLayer = (layer) => `COPY ${stageDir}/${layer}/ /`;
|
|
99
|
-
const
|
|
99
|
+
const stableLayers = [STAGE_STORE_LAYER, STAGE_WORKSPACE_LAYER].map(stageLayer).join('\n');
|
|
100
100
|
const toolsetStages = recipe.toolsets.map((name) => `FROM ${toolsets[name]} AS ${name}\n`).join('');
|
|
101
101
|
const toolsetCopies = recipe.toolsets.map((name) => `COPY --from=${name} ${TOOLSET_FILES[name].from} ${TOOLSET_FILES[name].to}\n`).join('');
|
|
102
102
|
const dockerfile = `# @generated by \`xema-composed-image dockerfile --prebuilt --write\` (@xemahq/repo-build-tooling@${toolingVersion}) — DO NOT EDIT.
|
|
@@ -118,7 +118,8 @@ export function renderPrebuiltDockerfile({ root = process.cwd(), imageDir = COMP
|
|
|
118
118
|
# \`${stageDir}/tool/\` is this tool, bind-mounted into the steps below and never copied into a layer.
|
|
119
119
|
#
|
|
120
120
|
# Stages:
|
|
121
|
-
# proof
|
|
121
|
+
# proof (COPYs run least to most often changed: store, workspace, root inputs, \`--copy\` directories, dist, manifest, so a source change keeps the store layer cached.)
|
|
122
|
+
# The staged tree and the repository's inputs meet. The root build inputs that decide the
|
|
122
123
|
# staged tree are COPIED into \`/${PROOF_DIRECTORY}/${CONTEXT_DIRECTORY}/\` and the \`--copy\` directories into
|
|
123
124
|
# \`/${COPIED_DIRECTORY}/\`, which puts them in the image's content tag. \`prove\` refuses the build unless the
|
|
124
125
|
# stage's manifest names BUILD_SHA and those bytes, this tool and this platform, and
|
|
@@ -134,9 +135,10 @@ ${toolsetStages}FROM ${from} AS base
|
|
|
134
135
|
FROM base AS proof
|
|
135
136
|
WORKDIR /${PROOF_DIRECTORY}
|
|
136
137
|
ARG BUILD_SHA
|
|
137
|
-
|
|
138
|
+
${stableLayers}
|
|
138
139
|
COPY ${rootInputs.join(' ')} /${PROOF_DIRECTORY}/${CONTEXT_DIRECTORY}/
|
|
139
|
-
${copies === '' ? '' : `${copies}\n`}${
|
|
140
|
+
${copies === '' ? '' : `${copies}\n`}${stageLayer(STAGE_DIST_LAYER)}
|
|
141
|
+
COPY ${stageDir}/${STAGE_PROOF_DIRECTORY}/${COMPOSED_MANIFEST_FILE} /${PROOF_DIRECTORY}/
|
|
140
142
|
RUN --network=none ${toolMount} \\
|
|
141
143
|
: "\${BUILD_SHA:?BUILD_SHA build-arg is required}" && \\
|
|
142
144
|
T="node ${node}" && \\
|
|
@@ -28,12 +28,24 @@ test('the proof stage COPYs the root build inputs flat, the --copy directories,
|
|
|
28
28
|
assert.match(proof, /^COPY \.xema-stage\/proof\/xema-composed-image\.json \/proof\/$/m);
|
|
29
29
|
assert.match(proof, /^COPY \.npmrc package\.json pnpm-lock\.yaml pnpm-workspace\.yaml \/proof\/context\/$/m);
|
|
30
30
|
assert.match(proof, /^COPY tooling\/dev\/ \/proof\/copied\/tooling\/dev\/$/m);
|
|
31
|
-
assert.match(proof, /^COPY \.xema-stage\/layers\/store\/ \/\nCOPY \.xema-stage\/layers\/workspace\/
|
|
31
|
+
assert.match(proof, /^COPY \.xema-stage\/layers\/store\/ \/\nCOPY \.xema-stage\/layers\/workspace\/ \/$/m);
|
|
32
|
+
assert.match(proof, /^COPY \.xema-stage\/layers\/dist\/ \/$/m);
|
|
32
33
|
assert.match(proof, /prove \/proof --root \/monorepo --commit "\$\{BUILD_SHA\}" --filter @t\/alpha-api\.\.\. --filter @t\/alpha-db-api\.\.\./);
|
|
33
34
|
assert.match(proof, /prisma-check --root \/monorepo/);
|
|
34
35
|
assert.match(proof, /USER node\nRUN --network=none \S+ \\\n {2}NODE_ENV=production node \S+ load --each --root \/monorepo/);
|
|
35
36
|
});
|
|
36
37
|
|
|
38
|
+
test('the proof stage COPYs in stability order: store, workspace, root inputs, --copy trees, dist, manifest', () => {
|
|
39
|
+
const { dockerfile } = renderPrebuiltDockerfile(options(workspace()));
|
|
40
|
+
const proof = dockerfile.split('FROM base AS proof\n')[1].split('\nFROM base\n')[0];
|
|
41
|
+
const copies = proof.split('\n').filter((line) => line.startsWith('COPY '));
|
|
42
|
+
const at = (re) => copies.findIndex((line) => re.test(line));
|
|
43
|
+
const order = [/layers\/store\//, /layers\/workspace\//, /\/proof\/context\/$/, /\/proof\/copied\//, /layers\/dist\//, /xema-composed-image\.json/].map(at);
|
|
44
|
+
assert.ok(order.every((i) => i >= 0), `every COPY is present: ${order}`);
|
|
45
|
+
assert.deepEqual(order, [...order].sort((a, b) => a - b));
|
|
46
|
+
assert.equal(order[0], 0, 'the store COPY precedes every source-tree COPY');
|
|
47
|
+
});
|
|
48
|
+
|
|
37
49
|
test('the root inputs the Dockerfile COPYs are exactly the ROOT_BUILD_INPUT files present', () => {
|
|
38
50
|
const root = workspace();
|
|
39
51
|
const opts = options(root);
|
|
@@ -4,7 +4,9 @@
|
|
|
4
4
|
// <stage>/layers/store/monorepo/node_modules/.pnpm/ the registry packages (stable until the lockfile changes)
|
|
5
5
|
// <stage>/layers/workspace/monorepo/ root manifests, the closure's package.json + declared `files` (the
|
|
6
6
|
// non-member built outputs among them), the injected workspace copies, the Prisma
|
|
7
|
-
// clients and engine, the migrate script
|
|
7
|
+
// clients and engine, the migrate script. No source tree: the `--copy` directories
|
|
8
|
+
// reach the install only as their workspace manifests (removed again unless the
|
|
9
|
+
// closure ships them), and their bytes reach only the proof stage (`/proof/copied`)
|
|
8
10
|
// <stage>/layers/dist/monorepo/ the members' build outputs (default `dist`), the member index and the host link: the
|
|
9
11
|
// only layer a commit that changes no dependency changes
|
|
10
12
|
//
|
|
@@ -68,6 +70,41 @@ function copyPackageFiles({ root, treeRoot, closure, problems }) {
|
|
|
68
70
|
}
|
|
69
71
|
}
|
|
70
72
|
|
|
73
|
+
/**
|
|
74
|
+
* The install's view of the `--copy` directories: every workspace `package.json` and `.patch` file under them (less what
|
|
75
|
+
* the `.dockerignore` keeps out), so pnpm sees the workspace the lockfile describes. Never their sources: those reach
|
|
76
|
+
* only the proof stage, and a source edit must leave the shipped layers byte-identical. Returns the repository-relative
|
|
77
|
+
* paths it wrote.
|
|
78
|
+
*/
|
|
79
|
+
function copyInstallManifests({ root, treeRoot, copy }) {
|
|
80
|
+
const written = [];
|
|
81
|
+
const walk = (rel) => {
|
|
82
|
+
for (const entry of readdirSync(join(root, rel), { withFileTypes: true }).sort((a, b) => (a.name < b.name ? -1 : 1))) {
|
|
83
|
+
const path = posix.join(rel, entry.name);
|
|
84
|
+
if (isIgnoredEntry(entry) || entry.name === '.git') continue;
|
|
85
|
+
if (entry.isDirectory()) walk(path);
|
|
86
|
+
else if (entry.isFile() && (entry.name === 'package.json' || entry.name.endsWith('.patch')) && !existsSync(join(treeRoot, path))) {
|
|
87
|
+
mkdirSync(dirname(join(treeRoot, path)), { recursive: true });
|
|
88
|
+
copyFileSync(join(root, path), join(treeRoot, path));
|
|
89
|
+
written.push(path);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
};
|
|
93
|
+
for (const directory of copy) walk(posix.normalize(directory).replace(/\/$/, ''));
|
|
94
|
+
return written;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Remove the install-only manifests from the tree, and the directories they leave empty: nothing at runtime reads them. */
|
|
98
|
+
function removeInstallManifests({ treeRoot, paths }) {
|
|
99
|
+
for (const rel of paths) {
|
|
100
|
+
rmSync(join(treeRoot, rel), { force: true });
|
|
101
|
+
for (let dir = posix.dirname(rel); dir !== '.' && dir !== ''; dir = posix.dirname(dir)) {
|
|
102
|
+
if (!existsSync(join(treeRoot, dir)) || readdirSync(join(treeRoot, dir)).length > 0) break;
|
|
103
|
+
rmSync(join(treeRoot, dir), { recursive: true });
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
71
108
|
/** `node_modules/.pnpm` entries that are registry packages: directories without an injected (`@file+`) workspace copy, and not pnpm's hoist directory. */
|
|
72
109
|
const isStoreEntry = (entry) => entry.isDirectory() && !entry.name.includes('@file+') && entry.name !== 'node_modules';
|
|
73
110
|
|
|
@@ -131,8 +168,8 @@ export async function stageComposedTree({
|
|
|
131
168
|
problems.push(...inputProblems);
|
|
132
169
|
|
|
133
170
|
for (const name of INSTALL_INPUTS.filter((file) => existsSync(join(root, file)))) copyFileSync(join(root, name), join(treeRoot, name));
|
|
134
|
-
for (const directory of copy) copyEntry(join(root, directory), join(treeRoot, directory));
|
|
135
171
|
copyPackageFiles({ root, treeRoot, closure, problems });
|
|
172
|
+
const installOnly = copyInstallManifests({ root, treeRoot, copy });
|
|
136
173
|
for (const manifest of memberManifests({ root, imageDir })) copyFileSync(join(root, manifest), join(treeRoot, manifest));
|
|
137
174
|
mkdirSync(join(treeRoot, imageDir), { recursive: true });
|
|
138
175
|
copyFileSync(join(root, imageDir, IMAGE_BUILD_INPUTS_FILE), join(treeRoot, imageDir, IMAGE_BUILD_INPUTS_FILE));
|
|
@@ -182,6 +219,7 @@ export async function stageComposedTree({
|
|
|
182
219
|
// Written on the runner: the index resolves modules in the tree, whatever the platform. The host link is relative.
|
|
183
220
|
const index = writeIndex({ root: treeRoot, imageDir, commit, buildSourceRepository: buildSource });
|
|
184
221
|
copyFileSync(join(dirname(fileURLToPath(import.meta.url)), MIGRATE_SCRIPT), join(treeRoot, MIGRATE_TARGET));
|
|
222
|
+
removeInstallManifests({ treeRoot, paths: installOnly });
|
|
185
223
|
const moved = splitLayers(stageRoot);
|
|
186
224
|
const members = readMembers({ root, imageDir }).map(([, directory]) => directory);
|
|
187
225
|
splitDistLayer({ stageRoot, members, outputs });
|
|
@@ -20,7 +20,7 @@ const git = (args) => {
|
|
|
20
20
|
throw new Error(`unexpected git ${args.join(' ')}`);
|
|
21
21
|
};
|
|
22
22
|
|
|
23
|
-
function ready() {
|
|
23
|
+
function ready({ copy = ['tooling/dev'] } = {}) {
|
|
24
24
|
const root = workspace();
|
|
25
25
|
write(root, 'package.json', { name: 'r' });
|
|
26
26
|
write(root, 'pnpm-lock.yaml', "lockfileVersion: '9.0'\n");
|
|
@@ -29,7 +29,7 @@ function ready() {
|
|
|
29
29
|
write(root, 'tooling/dev/node_modules/ignored.js', 'x');
|
|
30
30
|
write(root, 'packages/sdks/lib-a/assets/data.json', '{}');
|
|
31
31
|
write(root, 'biomes/alpha/api/alpha-api/README.md', 'ignored');
|
|
32
|
-
write(root, 'infra/composed-process/Dockerfile', renderPrebuiltDockerfile({ root, imageClass: ComposedImageClass.Common, copy
|
|
32
|
+
write(root, 'infra/composed-process/Dockerfile', renderPrebuiltDockerfile({ root, imageClass: ComposedImageClass.Common, copy, buildSource: 'alpha', base: BASE, nodeMajor: String(process.versions.node.split('.')[0]) }).dockerfile);
|
|
33
33
|
return root;
|
|
34
34
|
}
|
|
35
35
|
|
|
@@ -66,12 +66,12 @@ test('stages the layers, the manifest and the tool; only package.json + declared
|
|
|
66
66
|
assert.equal(calls[0].arch, 'amd64');
|
|
67
67
|
assert.deepEqual(calls[0].filters, ['--filter', '@t/alpha-api...', '--filter', '@t/alpha-db-api...']);
|
|
68
68
|
const appFiles = [...files(path.join(root, '.xema-stage/layers/workspace/monorepo')), ...files(path.join(root, '.xema-stage/layers/dist/monorepo'))];
|
|
69
|
-
for (const expected of ['package.json', 'pnpm-lock.yaml', 'pnpm-workspace.yaml', '.npmrc', '
|
|
69
|
+
for (const expected of ['package.json', 'pnpm-lock.yaml', 'pnpm-workspace.yaml', '.npmrc', 'infra/composed-process/image-build-inputs.json', 'biomes/alpha/xema-biome.json',
|
|
70
70
|
'biomes/alpha/api/alpha-api/package.json', 'biomes/alpha/api/alpha-api/dist/main.js', 'biomes/alpha/api/alpha-db-api/prisma/schema.prisma', 'biomes/alpha/api/alpha-db-api/node_modules/.prisma/alpha-db/index.js',
|
|
71
71
|
'packages/sdks/lib-a/assets/data.json', 'packages/sdks/lib-a/dist/index.js', 'xema-composed-migrate.mjs', 'xema-composed-member-index.json']) {
|
|
72
72
|
assert.ok(appFiles.includes(expected), expected);
|
|
73
73
|
}
|
|
74
|
-
for (const absent of ['turbo.json', 'tooling/dev/node_modules/ignored.js', 'biomes/alpha/api/alpha-api/README.md', 'biomes/alpha/api/alpha-api/src/main.test.ts', 'infra/composed-process/Dockerfile', 'node_modules/.modules.yaml', 'node_modules/.pnpm-workspace-state-v1.json']) {
|
|
74
|
+
for (const absent of ['turbo.json', 'tooling/dev/patch.txt', 'tooling/dev/node_modules/ignored.js', 'biomes/alpha/api/alpha-api/README.md', 'biomes/alpha/api/alpha-api/src/main.test.ts', 'infra/composed-process/Dockerfile', 'node_modules/.modules.yaml', 'node_modules/.pnpm-workspace-state-v1.json']) {
|
|
75
75
|
assert.ok(!appFiles.includes(absent), `${absent} must not ship`);
|
|
76
76
|
}
|
|
77
77
|
assert.ok(fs.existsSync(path.join(root, '.xema-stage/tool/src/composed-image-in-image.mjs')));
|
|
@@ -190,3 +190,40 @@ test('a change to a library (workspace package) changes the workspace layer and
|
|
|
190
190
|
await run(dep, { install: (args) => { fakeInstall()(args); write(args.treeRoot, 'node_modules/.pnpm/left-pad@1.0.1/node_modules/left-pad/package.json', { name: 'left-pad' }); } });
|
|
191
191
|
assert.notEqual(layerDigests(dep).store, before.store);
|
|
192
192
|
});
|
|
193
|
+
|
|
194
|
+
test('the --copy directories reach the install as workspace manifests and the proof as bytes, never a shipped layer: a member src edit leaves every layer byte-identical', async () => {
|
|
195
|
+
const sources = (root) => {
|
|
196
|
+
write(root, 'biomes/alpha/api/alpha-api/src/main.ts', 'export const main = 1;\n');
|
|
197
|
+
write(root, 'packages/sdks/lib-a/src/index.ts', 'export const lib = 1;\n');
|
|
198
|
+
write(root, 'packages/sdks/unused/package.json', { name: '@t/unused', files: ['dist'] });
|
|
199
|
+
write(root, 'packages/sdks/unused/src/index.ts', '// not in the closure\n');
|
|
200
|
+
write(root, 'patches/left-pad.patch', 'diff\n');
|
|
201
|
+
};
|
|
202
|
+
const copy = ['tooling/dev', 'biomes', 'packages', 'patches'];
|
|
203
|
+
const seen = [];
|
|
204
|
+
const install = (args) => {
|
|
205
|
+
seen.push(fs.readdirSync(args.treeRoot, { recursive: true }).map(String).sort());
|
|
206
|
+
fakeInstall()(args);
|
|
207
|
+
};
|
|
208
|
+
const first = ready({ copy });
|
|
209
|
+
sources(first);
|
|
210
|
+
const manifest = await run(first, { install });
|
|
211
|
+
assert.ok(seen[0].includes('packages/sdks/unused/package.json') && seen[0].includes('patches/left-pad.patch'), 'the install sees every workspace manifest and patch');
|
|
212
|
+
assert.ok(!seen[0].some((f) => f.endsWith('.ts') || f === 'tooling/dev/patch.txt'), 'the install sees no source');
|
|
213
|
+
assert.deepEqual(Object.keys(manifest.copied).sort(), ['biomes', 'packages', 'patches', 'tooling/dev'], 'the proof still binds the --copy bytes');
|
|
214
|
+
const shipped = LAYERS.flatMap((layer) => files(path.join(first, '.xema-stage/layers', layer, 'monorepo')));
|
|
215
|
+
for (const absent of ['biomes/alpha/api/alpha-api/src/main.ts', 'biomes/alpha/api/alpha-api/src/main.test.ts', 'packages/sdks/lib-a/src/index.ts', 'packages/sdks/unused/package.json', 'packages/sdks/unused', 'patches/left-pad.patch', 'patches', 'tooling/dev/patch.txt']) {
|
|
216
|
+
assert.ok(!shipped.includes(absent), `${absent} must not ship`);
|
|
217
|
+
}
|
|
218
|
+
for (const present of ['biomes/alpha/api/alpha-api/package.json', 'packages/sdks/lib-a/package.json', 'packages/sdks/lib-a/dist/index.js', 'biomes/alpha/api/alpha-db-api/prisma/schema.prisma']) {
|
|
219
|
+
assert.ok(shipped.includes(present), present);
|
|
220
|
+
}
|
|
221
|
+
const before = layerDigests(first);
|
|
222
|
+
const second = ready({ copy });
|
|
223
|
+
sources(second);
|
|
224
|
+
write(second, 'biomes/alpha/api/alpha-api/src/main.ts', 'export const main = 2; // edited\n');
|
|
225
|
+
write(second, 'biomes/alpha/api/alpha-api/src/added.ts', '// a new source file\n');
|
|
226
|
+
const edited = await run(second);
|
|
227
|
+
assert.deepEqual(layerDigests(second), before);
|
|
228
|
+
assert.notEqual(edited.copied.biomes, manifest.copied.biomes, 'the edit reaches the proof stage');
|
|
229
|
+
});
|
|
@@ -310,3 +310,16 @@ export async function stageService({
|
|
|
310
310
|
`prisma ${prisma === null ? 'none' : `${prisma.enginesVersion} ${prisma.binaryTarget}`}, tool ${toolFiles} files`);
|
|
311
311
|
return manifest;
|
|
312
312
|
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Stage several services for one platform in this process, one after the other (each into its own
|
|
316
|
+
* stage directory, with the outputs `stageService` gives a single service). The process-wide memo of
|
|
317
|
+
* verified schema-engine downloads is shared, so services on the same engines commit and target
|
|
318
|
+
* download it once. `services` is `[{ service, path, stageDir }]`; the first refusal stops the batch.
|
|
319
|
+
*/
|
|
320
|
+
export async function stageServices({ services, ...shared }) {
|
|
321
|
+
if (!Array.isArray(services) || services.length === 0) throw new Error('stage-batch needs at least one --service <pkg>=<dir>=<stage-dir>');
|
|
322
|
+
const manifests = [];
|
|
323
|
+
for (const { service, path, stageDir } of services) manifests.push(await stageService({ ...shared, service, path, stageDir }));
|
|
324
|
+
return manifests;
|
|
325
|
+
}
|
package/src/service-image.mjs
CHANGED
|
@@ -14,13 +14,18 @@
|
|
|
14
14
|
// manifest (commit, root build inputs, tool version) and the tool the Dockerfile's
|
|
15
15
|
// proof stage runs (`service-image-stage.mjs`).
|
|
16
16
|
//
|
|
17
|
+
// xema-service-image stage-batch --platform linux/<amd64|arm64> --service <pkg>=<dir>=<stage-dir> [--service ...]
|
|
18
|
+
// `stage` for N services in ONE process, each into its own stage dir with the same outputs;
|
|
19
|
+
// the verified schema-engine download is reused across them, so a shared Prisma engine is fetched once.
|
|
20
|
+
//
|
|
17
21
|
// The Dockerfile then runs only `prove` (`service-image-in-image.mjs`, on the target, offline)
|
|
18
22
|
// and COPYs `app/`: no install, no build, no registry token in the image build.
|
|
19
23
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
20
24
|
import { realpathSync } from 'node:fs';
|
|
25
|
+
import { isAbsolute, relative, resolve } from 'node:path';
|
|
21
26
|
import { fileURLToPath } from 'node:url';
|
|
22
27
|
|
|
23
|
-
import { DEFAULT_STAGE_DIR, stageService } from './service-image-stage.mjs';
|
|
28
|
+
import { DEFAULT_STAGE_DIR, stageService, stageServices } from './service-image-stage.mjs';
|
|
24
29
|
|
|
25
30
|
const USAGE = 'usage: xema-service-image stage --service <pkg> --path <dir> --platform linux/<amd64|arm64> [--stage-dir <dir>]';
|
|
26
31
|
const OPTIONS = new Set(['service', 'path', 'platform', 'stage-dir']);
|
|
@@ -42,10 +47,44 @@ export function parseStageArgs(argv) {
|
|
|
42
47
|
return { service: options.service, path: options.path, platform: options.platform, stageDir: options['stage-dir'] ?? DEFAULT_STAGE_DIR };
|
|
43
48
|
}
|
|
44
49
|
|
|
50
|
+
const BATCH_USAGE = 'usage: xema-service-image stage-batch --platform linux/<amd64|arm64> --service <pkg>=<dir>=<stage-dir> [--service ...]';
|
|
51
|
+
|
|
52
|
+
/** True when `outer` is `inner` or a directory above it, after resolving both. */
|
|
53
|
+
function containsOrEquals(outer, inner) {
|
|
54
|
+
const rel = relative(resolve(outer), resolve(inner));
|
|
55
|
+
return rel === '' || (rel !== '..' && !rel.startsWith('../') && !isAbsolute(rel));
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function parseBatchArgs(argv) {
|
|
59
|
+
let platform;
|
|
60
|
+
const services = [];
|
|
61
|
+
for (let i = 0; i < argv.length; i += 2) {
|
|
62
|
+
const flag = argv[i];
|
|
63
|
+
const value = argv[i + 1];
|
|
64
|
+
if (flag !== '--platform' && flag !== '--service') throw new Error(`unknown argument '${flag}'\n${BATCH_USAGE}`);
|
|
65
|
+
if (value === undefined || value.startsWith('--')) throw new Error(`${flag} needs a value\n${BATCH_USAGE}`);
|
|
66
|
+
if (flag === '--platform') {
|
|
67
|
+
if (platform !== undefined) throw new Error('--platform is given twice');
|
|
68
|
+
platform = value;
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
const parts = value.split('=');
|
|
72
|
+
if (parts.length !== 3 || parts.some((part) => part === '')) throw new Error(`--service '${value}' is not <pkg>=<dir>=<stage-dir>\n${BATCH_USAGE}`);
|
|
73
|
+
const [service, path, stageDir] = parts;
|
|
74
|
+
const nested = services.find((entry) => containsOrEquals(entry.stageDir, stageDir) || containsOrEquals(stageDir, entry.stageDir));
|
|
75
|
+
if (nested !== undefined) throw new Error(`stage dirs '${nested.stageDir}' and '${stageDir}' are the same or one contains the other`);
|
|
76
|
+
services.push({ service, path, stageDir });
|
|
77
|
+
}
|
|
78
|
+
if (platform === undefined) throw new Error(`--platform is required\n${BATCH_USAGE}`);
|
|
79
|
+
if (services.length === 0) throw new Error(`--service is required\n${BATCH_USAGE}`);
|
|
80
|
+
return { platform, services };
|
|
81
|
+
}
|
|
82
|
+
|
|
45
83
|
async function main(argv) {
|
|
46
84
|
const [verb, ...rest] = argv;
|
|
47
|
-
if (verb
|
|
48
|
-
await
|
|
85
|
+
if (verb === 'stage') await stageService(parseStageArgs(rest));
|
|
86
|
+
else if (verb === 'stage-batch') await stageServices(parseBatchArgs(rest));
|
|
87
|
+
else throw new Error(`${USAGE}\n${BATCH_USAGE}`);
|
|
49
88
|
}
|
|
50
89
|
|
|
51
90
|
// realpath both sides: Node resolves the entry module's symlinks (a pnpm bin shim runs this file
|
|
@@ -10,10 +10,10 @@ import { fileURLToPath } from 'node:url';
|
|
|
10
10
|
import { gzipSync } from 'node:zlib';
|
|
11
11
|
|
|
12
12
|
import { copyTool } from './composed-image-stage.mjs';
|
|
13
|
-
import { parseStageArgs } from './service-image.mjs';
|
|
13
|
+
import { parseBatchArgs, parseStageArgs } from './service-image.mjs';
|
|
14
14
|
import { parseProveArgs, prove } from './service-image-in-image.mjs';
|
|
15
15
|
import { IMAGE_PLATFORMS, currentPlatform, nativeFormat, platformArch, scanNatives } from './service-image-platform.mjs';
|
|
16
|
-
import { PRISMA_ENGINES_URL, SCHEMA_ENGINE_SHA256, deployArgs, downloadSchemaEngine, pnpmEntryName, stageService } from './service-image-stage.mjs';
|
|
16
|
+
import { PRISMA_ENGINES_URL, SCHEMA_ENGINE_SHA256, deployArgs, downloadSchemaEngine, pnpmEntryName, stageService, stageServices } from './service-image-stage.mjs';
|
|
17
17
|
|
|
18
18
|
const temps = [];
|
|
19
19
|
after(() => {
|
|
@@ -311,6 +311,53 @@ test('downloadSchemaEngine downloads once per engines commit and target, however
|
|
|
311
311
|
for (const dir of dirs) assert.deepEqual(fs.readFileSync(path.join(dir, `schema-engine-${target}`)), engine);
|
|
312
312
|
});
|
|
313
313
|
|
|
314
|
+
test('parseBatchArgs reads one platform and repeated <pkg>=<dir>=<stage-dir> services', () => {
|
|
315
|
+
assert.deepEqual(parseBatchArgs(['--platform', 'linux/amd64', '--service', 'a=apps/a=.s/a', '--service', 'b=apps/b=.s/b']), {
|
|
316
|
+
platform: 'linux/amd64', services: [{ service: 'a', path: 'apps/a', stageDir: '.s/a' }, { service: 'b', path: 'apps/b', stageDir: '.s/b' }],
|
|
317
|
+
});
|
|
318
|
+
assert.throws(() => parseBatchArgs(['--service', 'a=b=c']), /--platform is required/);
|
|
319
|
+
assert.throws(() => parseBatchArgs(['--platform', 'linux/amd64']), /--service is required/);
|
|
320
|
+
assert.throws(() => parseBatchArgs(['--platform', 'linux/amd64', '--service', 'a=b']), /not <pkg>=<dir>=<stage-dir>/);
|
|
321
|
+
assert.throws(() => parseBatchArgs(['--platform', 'linux/amd64', '--service', 'a=b=c', '--service', 'd=e=c']), /same or one contains the other/);
|
|
322
|
+
assert.throws(() => parseBatchArgs(['--platform', 'linux/amd64', '--service', 'a=b=.s', '--service', 'd=e=.s/./d']), /same or one contains/);
|
|
323
|
+
assert.throws(() => parseBatchArgs(['--platform', 'linux/amd64', '--service', 'a=b=.s/x/..', '--service', 'd=e=.s/d']), /same or one contains/);
|
|
324
|
+
assert.doesNotThrow(() => parseBatchArgs(['--platform', 'linux/amd64', '--service', 'a=b=.s/a', '--service', 'd=e=.s/ab']));
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
test('stage-batch stages every service into its own stage dir with one engine fetch for a shared engine', async (t) => {
|
|
328
|
+
const engine = elf(183);
|
|
329
|
+
const version = '9'.repeat(40);
|
|
330
|
+
const target = 'linux-arm64-openssl-3.0.x';
|
|
331
|
+
const checksums = { [version]: { [target]: sha256(engine) } };
|
|
332
|
+
const fetches = [];
|
|
333
|
+
t.mock.method(globalThis, 'fetch', async (asked) => {
|
|
334
|
+
fetches.push(asked);
|
|
335
|
+
return new Response(gzipSync(engine));
|
|
336
|
+
});
|
|
337
|
+
const root = fixture({ prisma: true });
|
|
338
|
+
const manifests = await stageServices({
|
|
339
|
+
root,
|
|
340
|
+
platform: 'linux/arm64',
|
|
341
|
+
services: [
|
|
342
|
+
{ service: 'svc', path: 'apps/svc', stageDir: '.stage-a' },
|
|
343
|
+
{ service: 'svc', path: 'apps/svc', stageDir: '.stage-b' },
|
|
344
|
+
],
|
|
345
|
+
log: () => {},
|
|
346
|
+
deploy: fakeDeploy({ prisma: true }),
|
|
347
|
+
git: fakeGit(),
|
|
348
|
+
fetchEngine: (args) => downloadSchemaEngine({ ...args, enginesVersion: version, checksums }),
|
|
349
|
+
});
|
|
350
|
+
assert.equal(fetches.length, 1);
|
|
351
|
+
assert.equal(manifests.length, 2);
|
|
352
|
+
const single = await stage(fixture({ prisma: true }), { deploy: fakeDeploy({ prisma: true }), fetchEngine: async ({ enginesDir, binaryTarget }) => write(path.join(enginesDir, `schema-engine-${binaryTarget}`), engine) });
|
|
353
|
+
for (const dir of ['.stage-a', '.stage-b']) {
|
|
354
|
+
const manifest = JSON.parse(fs.readFileSync(path.join(root, dir, 'service/xema-service-image.json'), 'utf8'));
|
|
355
|
+
assert.deepEqual({ ...manifest, prisma: null }, { ...single, prisma: null });
|
|
356
|
+
assert.deepEqual(manifest.prisma, { enginesVersion: ENGINES_VERSION, binaryTarget: target, sha256: sha256(engine) });
|
|
357
|
+
}
|
|
358
|
+
await assert.rejects(stageServices({ root, platform: 'linux/arm64', services: [] }), /at least one --service/);
|
|
359
|
+
});
|
|
360
|
+
|
|
314
361
|
test('downloadSchemaEngine names the engine, URL, commit, target, bytes and cause when the body read fails', async (t) => {
|
|
315
362
|
const engine = elf(186);
|
|
316
363
|
const version = 'f'.repeat(40);
|