@xemahq/repo-build-tooling 0.29.0 → 0.31.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 +3 -1
- package/package.json +4 -3
- package/src/composed-image-class.mjs +60 -0
- package/src/composed-image-class.test.mjs +218 -0
- package/src/composed-image-committed-class.mjs +19 -0
- package/src/composed-image-coordinate-cli.test.mjs +2 -2
- package/src/composed-image-fixture.test.mjs +73 -1
- package/src/composed-image-in-image-closure.test.mjs +2 -0
- package/src/composed-image-manifest-components.mjs +28 -0
- package/src/composed-image-members.mjs +6 -6
- package/src/composed-image-paths.mjs +4 -2
- package/src/composed-image-prebuilt-dockerfile.mjs +59 -15
- package/src/composed-image-prebuilt-dockerfile.test.mjs +8 -5
- package/src/composed-image-stage-tree.mjs +42 -13
- package/src/composed-image-stage-tree.test.mjs +64 -5
- package/src/composed-image.mjs +104 -32
- package/src/composed-image.test.mjs +15 -15
- package/src/effective-input.mjs +15 -15
- package/src/effective-input.test.mjs +24 -10
package/README.md
CHANGED
|
@@ -40,7 +40,9 @@ pnpm exec xema-scrub-swagger-paths dist
|
|
|
40
40
|
|
|
41
41
|
`dockerfile --prebuilt` renders the Dockerfile and `Dockerfile.dockerignore` of an image that only COPIES a tree the CI runner staged: no `RUN pnpm install`, no corepack, no secret, no network (every RUN is `--network=none`). It requires `--build-source`, `--base <ref@sha256:…>` (the digest-pinned image the stages start FROM; a reference without a digest is refused) and `--node-major <n>` (that base's Node major, rendered as the `dev.xema.composed-image.node-major` label, which the stage compares with the runner's Node); `--copy` and `--stage-dir` are optional and `--cache-id` is refused (there is no cache). The proof stage COPIES the root build inputs (`pnpm-lock.yaml`, root `package.json`, `pnpm-workspace.yaml`, `.npmrc`, `.pnpmfile.cjs`, `turbo.json`, `tsconfig*.json`, whichever exist) into `/proof/context/` and the `--copy` directories into `/proof/copied/`, which puts the inputs that decided the staged tree in the image's content tag, and runs `prove`, `prisma-check` and `load --each`; the final stage COPIES the two staged layers and the proof's verdict. The `--filter <package>...` words in the `prove` line are literal: the content hash reads them to narrow the lockfile.
|
|
42
42
|
|
|
43
|
-
|
|
43
|
+
**Image classes.** One build produces one image per class: `common | privileged | web-access` (the closed enum `ComposedImageClass`). A component's class is `imageClass` in its biome manifest (absent means common, the contracts default; the grammar is the contracts component schema, which the packager parses every manifest component through and never restates); `members --write|--check` derives the member set of every class and writes `<image-dir>/<class>/image-build-inputs.json` for each class that has members, so there is no separate list. A declared class (privileged, web-access) is authoritative: a worker or a runtime-scheduled service is a member, no derived exclusion applies, and `load --each` is the gate that refuses a member whose entry the host cannot load. Every other image command takes `--class <class>` and works in `<image-dir>/<class>/` (`dockerfile --prebuilt`, `stage`, `stage-tree`, `index`, `load`, `coordinate`, …). The class decides the recipe, not the members: web-access is built on the Chromium base (`--chromium-base <ref@sha256:…>`, refused for any other class); privileged carries the cosign and oras toolsets (`--toolset cosign=<ref@sha256:…> --toolset oras=<ref@sha256:…>`, pinned by digest into the Dockerfile so the content tag covers them; the set must match the recipe exactly). A class's image is `FROM base@digest`, its toolsets, the store layer, the workspace layer, then ONE tail layer (the dist layer and the proof verdict). Every member of a class runs in ONE node process behind the `xema-composed-process` host; the deployment selects the member set with `XEMA_PROCESS_DEFINITION`, and no class spawns a child process per member.
|
|
44
|
+
|
|
45
|
+
`stage-tree <dir> --platform linux/<amd64|arm64> [--output <rel>]…` is the runner half, run after the build and the job's own `pnpm install --frozen-lockfile`. It checks the members' build outputs (as `stage` does), then runs an OFFLINE, frozen, `--ignore-scripts` production install of the members' closures into a staging tree from the job's pnpm store, selected for the target platform (`--os=linux --cpu=<arch> --libc=glibc`, workspace packages injected, the re-resolved lockfile checked to pin nothing new); copies in only each package's `package.json` and declared `files` (plus the built outputs among them), the `--copy` directories, the biome manifests and `image-build-inputs.json`; writes the member index and host link; fetches the platform's Prisma schema engine (checked against a sha256 COMMITTED in this tool per engines commit and binaryTarget, never against the checksum Prisma publishes beside it; an engines commit or target not in the table is refused; never cached) when the closure carries the prisma CLI; and refuses a dependency that needs its build script on the target, a package whose native code holds no ELF for the platform, an uncommitted change to a tracked file and an untracked root build input. Output, three layers from least to most often changed: `<dir>/layers/store` (the registry packages, stable until the lockfile changes), `<dir>/layers/workspace` (the injected workspace packages, Prisma clients and engine, package files: changes with a library or the lockfile), `<dir>/layers/dist` (the members' `--output` directories, the member index and its host link: the only layer a commit that changes no dependency changes), files keep their real mtimes (an epoch mtime would let BuildKit's context transfer skip a same-size edit and ship stale bytes); the build must run with `SOURCE_DATE_EPOCH=0` and `--output type=image,rewrite-timestamp=true` so an unchanged layer keeps its digest build after build; `<dir>/proof/xema-composed-image.json` (commit, sha256 of each root build input and `--copy` directory, the literal filters, members, natives, engine, tool version, platform) and `<dir>/tool`. `--also` is not accepted. A refused stage leaves nothing behind. The in-image `prove` binds the stage to the build: it refuses unless the manifest names `BUILD_SHA`, the context's root build inputs and `--copy` directories, this tool version, the platform the build runs on and the Dockerfile's filters, and every addon loads and the engine runs there. It does not detect a stage altered on the runner after `stage-tree` wrote it (manifest and layers come from the same job); the image digest and provenance cover what ships. The lockfile re-resolve runs `--prefer-offline` (its pins are checked unchanged; the production install is `--offline`). `stage <dir> [--output <rel>]… [--also <pkg>]…` remains as the outputs-only stage (`<dir>/out`, `<dir>/tool`) for per-service prebuilt images that still read it. `pnpm-context <dir> --subject <svc>…` builds the offline `xema-pnpm` context for those images (`staged.json`, `corepack/pnpm.tgz`); it prints `XEMA_PNPM_CONTEXT=<dir>` and `XEMA_PNPM_STORE=<store root>` and refuses a non-empty `<dir>`. `load --each` loads every member in its own process. Run `xema-composed-image` with no arguments for the full usage.
|
|
44
46
|
|
|
45
47
|
Every step refuses rather than skips. The package has no dependencies.
|
|
46
48
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@xemahq/repo-build-tooling",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.31.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)",
|
|
@@ -46,12 +46,13 @@
|
|
|
46
46
|
"./service-image": "./src/service-image.mjs"
|
|
47
47
|
},
|
|
48
48
|
"dependencies": {
|
|
49
|
+
"@xemahq/contracts": "^0.118.1",
|
|
49
50
|
"@xemahq/release-policy": "^16.0.0"
|
|
50
51
|
},
|
|
51
52
|
"scripts": {
|
|
52
|
-
"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-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"
|
|
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"
|
|
53
54
|
},
|
|
54
55
|
"xemaRelease": {
|
|
55
|
-
"sourceDigest": "sha256:
|
|
56
|
+
"sourceDigest": "sha256:724bdf902b48fed78564a3b6d52abb15d5ed2a6962da85e7a88f17eab1b085c2"
|
|
56
57
|
}
|
|
57
58
|
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// What an image CLASS is to the packager: which directory holds its declaration and Dockerfile, which base it is
|
|
2
|
+
// built on and which toolsets its image carries. A class is DECLARED by a component's `imageClass` in its biome manifest
|
|
3
|
+
// (absent means the contracts default, common); the recipe below only says what each class needs, never which services are in it.
|
|
4
|
+
//
|
|
5
|
+
// Build-time only: nothing the image runs (`composed-image-in-image.mjs`'s closure) may import this file.
|
|
6
|
+
import { posix } from 'node:path';
|
|
7
|
+
|
|
8
|
+
import { ComposedImageClass, ComposedImageClassSchema, DEFAULT_COMPOSED_IMAGE_CLASS } from '@xemahq/contracts/distribution';
|
|
9
|
+
|
|
10
|
+
export { ComposedImageClass, DEFAULT_COMPOSED_IMAGE_CLASS };
|
|
11
|
+
export const IMAGE_CLASSES = Object.freeze(Object.values(ComposedImageClass));
|
|
12
|
+
|
|
13
|
+
/** Which pinned base a class is built `FROM`. */
|
|
14
|
+
export const BaseKind = Object.freeze({ NodeRuntime: 'node-runtime', Chromium: 'chromium' });
|
|
15
|
+
|
|
16
|
+
/** A toolset is a binary copied from a pinned upstream image into the class's image. */
|
|
17
|
+
export const Toolset = Object.freeze({ Cosign: 'cosign', Oras: 'oras' });
|
|
18
|
+
export const TOOLSET_FILES = Object.freeze({
|
|
19
|
+
[Toolset.Cosign]: Object.freeze({ from: '/ko-app/cosign', to: '/usr/local/bin/cosign' }),
|
|
20
|
+
[Toolset.Oras]: Object.freeze({ from: '/bin/oras', to: '/usr/local/bin/oras' }),
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
/** The recipe: what a class's image is made of. Membership is never here. */
|
|
24
|
+
export const CLASS_RECIPE = Object.freeze({
|
|
25
|
+
[ComposedImageClass.Common]: Object.freeze({ base: BaseKind.NodeRuntime, toolsets: Object.freeze([]) }),
|
|
26
|
+
[ComposedImageClass.Privileged]: Object.freeze({ base: BaseKind.NodeRuntime, toolsets: Object.freeze([Toolset.Cosign, Toolset.Oras]) }),
|
|
27
|
+
[ComposedImageClass.WebAccess]: Object.freeze({ base: BaseKind.Chromium, toolsets: Object.freeze([]) }),
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
export function assertImageClass(imageClass) {
|
|
31
|
+
if (!ComposedImageClassSchema.safeParse(imageClass).success) throw new Error(`image class must be one of ${IMAGE_CLASSES.join(', ')}, got "${imageClass}"`);
|
|
32
|
+
return imageClass;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Where a class keeps its `image-build-inputs.json`, Dockerfile and Dockerfile.dockerignore: `<image-dir>/<class>`. */
|
|
36
|
+
export function classImageDir(imageDir, imageClass) {
|
|
37
|
+
return posix.join(imageDir, assertImageClass(imageClass));
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** A composed image's members do not agree on one class: they declare several, or the image has no member to declare one. */
|
|
41
|
+
export class ComposedImageClassError extends Error {
|
|
42
|
+
constructor({ image, members, reason }) {
|
|
43
|
+
super(`composed image ${image}: ${reason} (members: ${members.length === 0 ? 'none' : members.map(({ key, imageClass }) => `${key}=${imageClass}`).join(', ')})`);
|
|
44
|
+
this.name = 'ComposedImageClassError';
|
|
45
|
+
this.image = image;
|
|
46
|
+
this.members = members;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* The class of a composed image, derived from its members' biome-manifest `imageClass`, as the contracts component schema parsed it (absent means the contracts default). Never
|
|
52
|
+
* hand-written: `members` is `[{ key, imageClass }]` read from the manifests. Refuses an image with no member, and one
|
|
53
|
+
* whose members declare different classes.
|
|
54
|
+
*/
|
|
55
|
+
export function deriveImageClass({ image, members }) {
|
|
56
|
+
const classes = [...new Set(members.map(({ imageClass }) => imageClass))];
|
|
57
|
+
if (classes.length === 0) throw new ComposedImageClassError({ image, members, reason: 'has no member, so no class can be derived' });
|
|
58
|
+
if (classes.length > 1) throw new ComposedImageClassError({ image, members, reason: `members declare different classes (${classes.sort().join(', ')})` });
|
|
59
|
+
return assertImageClass(classes[0]);
|
|
60
|
+
}
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { execFileSync } 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
|
+
|
|
8
|
+
import { CLASS_RECIPE, ComposedImageClass, DEFAULT_COMPOSED_IMAGE_CLASS, ComposedImageClassError, IMAGE_CLASSES, TOOLSET_FILES, assertImageClass, classImageDir, deriveImageClass } from './composed-image-class.mjs';
|
|
9
|
+
import { deriveComposedMembers, main, parseToolsets, syncImageBuildInputs } from './composed-image.mjs';
|
|
10
|
+
import { readImageClass } from './composed-image-committed-class.mjs';
|
|
11
|
+
import { renderPrebuiltDockerfile } from './composed-image-prebuilt-dockerfile.mjs';
|
|
12
|
+
import { BASE, CONTENT_COMPONENT, workspace, write } from './composed-image-fixture.test.mjs';
|
|
13
|
+
|
|
14
|
+
const COMPOSABLE = 'void bootstrapXemaService({ appModule: AppModule });\n';
|
|
15
|
+
const WORKER = 'void bootstrapXemaWorker({ appModule: AppModule });\n';
|
|
16
|
+
const digest = (name) => `ghcr.io/example/${name}@sha256:${name.length.toString(16).padStart(64, 'd')}`;
|
|
17
|
+
const CHROMIUM = digest('chromium');
|
|
18
|
+
const TOOLSETS = { cosign: digest('cosign'), oras: digest('oras') };
|
|
19
|
+
|
|
20
|
+
const component = (key, extra = {}) => ({ key, kind: 'service', scheduler: 'platform', artifact: { kind: 'oci-image', path: `api/${key}` }, ...extra });
|
|
21
|
+
|
|
22
|
+
function repository(components) {
|
|
23
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'xema-classes-'));
|
|
24
|
+
write(root, 'biomes/alpha/xema-biome.json', { xema: { id: 'alpha', components } });
|
|
25
|
+
for (const { key, kind } of components) write(root, `biomes/alpha/api/${key}/src/main.ts`, kind === 'worker' ? WORKER : COMPOSABLE);
|
|
26
|
+
execFileSync('git', ['init', '-q'], { cwd: root });
|
|
27
|
+
execFileSync('git', ['add', '-A'], { cwd: root });
|
|
28
|
+
return root;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const classed = () =>
|
|
32
|
+
repository([
|
|
33
|
+
component('plain-api'),
|
|
34
|
+
component('fetch-api', { imageClass: ComposedImageClass.Privileged }),
|
|
35
|
+
component('platform-worker', { kind: 'worker', imageClass: ComposedImageClass.Privileged }),
|
|
36
|
+
component('runtime-server', { scheduler: 'runtime', imageClass: ComposedImageClass.Privileged }),
|
|
37
|
+
component('browser-api', { imageClass: ComposedImageClass.WebAccess }),
|
|
38
|
+
component('explicit-api', { imageClass: ComposedImageClass.Common }),
|
|
39
|
+
component('runtime-api', { scheduler: 'runtime' }),
|
|
40
|
+
]);
|
|
41
|
+
|
|
42
|
+
test('the class set is the closed enum and the recipe covers exactly it', () => {
|
|
43
|
+
assert.deepEqual(IMAGE_CLASSES, ['common', 'privileged', 'web-access']);
|
|
44
|
+
assert.deepEqual(Object.keys(CLASS_RECIPE).sort(), [...IMAGE_CLASSES].sort());
|
|
45
|
+
assert.deepEqual([...CLASS_RECIPE[ComposedImageClass.Privileged].toolsets].sort(), Object.keys(TOOLSET_FILES).sort());
|
|
46
|
+
assert.throws(() => assertImageClass('root'), /image class must be one of common, privileged, web-access/);
|
|
47
|
+
assert.equal(classImageDir('infra/composed-process', 'web-access'), 'infra/composed-process/web-access');
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test('members come from each component\'s imageClass: absent is common, and a declared class takes workers and runtime-scheduled services whole', () => {
|
|
51
|
+
const derived = deriveComposedMembers({ root: classed() });
|
|
52
|
+
assert.deepEqual(derived.members, {
|
|
53
|
+
common: { 'alpha/explicit-api': 'biomes/alpha/api/explicit-api', 'alpha/plain-api': 'biomes/alpha/api/plain-api' },
|
|
54
|
+
privileged: {
|
|
55
|
+
'alpha/fetch-api': 'biomes/alpha/api/fetch-api',
|
|
56
|
+
'alpha/runtime-server': 'biomes/alpha/api/runtime-server',
|
|
57
|
+
'alpha/platform-worker': 'biomes/alpha/api/platform-worker',
|
|
58
|
+
},
|
|
59
|
+
'web-access': { 'alpha/browser-api': 'biomes/alpha/api/browser-api' },
|
|
60
|
+
});
|
|
61
|
+
assert.deepEqual(derived.excluded, {});
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test('a class with no member is absent; an unknown class or a class on a non-service is refused', () => {
|
|
65
|
+
assert.deepEqual(Object.keys(deriveComposedMembers({ root: repository([component('plain-api')]) }).members), ['common']);
|
|
66
|
+
assert.throws(() => deriveComposedMembers({ root: repository([component('x-api', { imageClass: 'root' })]) }), /component "x-api" is not a valid biome component: imageClass: Invalid option/);
|
|
67
|
+
// The refusal is the contracts schema's, not the packager's: the same content component is accepted without a class.
|
|
68
|
+
assert.doesNotThrow(() => deriveComposedMembers({ root: repository([component('plain-api'), CONTENT_COMPONENT]) }));
|
|
69
|
+
const content = repository([component('plain-api'), { ...CONTENT_COMPONENT, imageClass: ComposedImageClass.Privileged }]);
|
|
70
|
+
assert.throws(() => deriveComposedMembers({ root: content }), /component "alpha-content" is not a valid biome component: imageClass: imageClass is only valid on a service or worker component, not content/);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test('members --write declares one image per class that has members; --check refuses a missing, stale or unclassed declaration', () => {
|
|
74
|
+
const root = classed();
|
|
75
|
+
syncImageBuildInputs({ mode: 'write', root });
|
|
76
|
+
for (const imageClass of IMAGE_CLASSES) {
|
|
77
|
+
assert.ok(fs.existsSync(path.join(root, 'infra/composed-process', imageClass, 'image-build-inputs.json')), imageClass);
|
|
78
|
+
}
|
|
79
|
+
assert.deepEqual(JSON.parse(fs.readFileSync(path.join(root, 'infra/composed-process/web-access/image-build-inputs.json'), 'utf8')), { imageClass: ComposedImageClass.WebAccess, workspacePackages: ['biomes/alpha/api/browser-api'] });
|
|
80
|
+
syncImageBuildInputs({ mode: 'check', root });
|
|
81
|
+
write(root, 'infra/composed-process/image-build-inputs.json', { workspacePackages: [] });
|
|
82
|
+
assert.throws(() => syncImageBuildInputs({ mode: 'check', root }), /infra\/composed-process\/image-build-inputs\.json is stale/);
|
|
83
|
+
syncImageBuildInputs({ mode: 'write', root });
|
|
84
|
+
assert.equal(fs.existsSync(path.join(root, 'infra/composed-process/image-build-inputs.json')), false);
|
|
85
|
+
|
|
86
|
+
// The web-access service leaves the class: its declaration must go, and --check says so before --write does.
|
|
87
|
+
const manifest = path.join(root, 'biomes/alpha/xema-biome.json');
|
|
88
|
+
const edited = JSON.parse(fs.readFileSync(manifest, 'utf8'));
|
|
89
|
+
delete edited.xema.components.find((c) => c.key === 'browser-api').imageClass;
|
|
90
|
+
fs.writeFileSync(manifest, JSON.stringify(edited));
|
|
91
|
+
assert.throws(() => syncImageBuildInputs({ mode: 'check', root }), /web-access\/image-build-inputs\.json.* stale/);
|
|
92
|
+
syncImageBuildInputs({ mode: 'write', root });
|
|
93
|
+
assert.equal(fs.existsSync(path.join(root, 'infra/composed-process/web-access/image-build-inputs.json')), false);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test('an image command needs --class; members refuses one', () => {
|
|
97
|
+
assert.throws(() => main(['dirs'], { log() {} }), /dirs works on one image class: pass --class <common\|privileged\|web-access>/);
|
|
98
|
+
assert.throws(() => main(['dirs', '--class', 'root'], { log() {} }), /image class must be one of/);
|
|
99
|
+
assert.throws(() => main(['members', '--check', '--class', 'common'], { log() {} }), /--class does not apply to members/);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test('--toolset parses name=digest reference pairs and refuses a malformed or repeated one', () => {
|
|
103
|
+
assert.deepEqual(parseToolsets(['cosign=a@sha256:x', 'oras=b']), { cosign: 'a@sha256:x', oras: 'b' });
|
|
104
|
+
assert.throws(() => parseToolsets(['cosign']), /--toolset must be <name>=/);
|
|
105
|
+
assert.throws(() => parseToolsets(['cosign=a', 'cosign=b']), /given twice/);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
function render(imageClass, extra = {}) {
|
|
109
|
+
const root = workspace();
|
|
110
|
+
write(root, 'pnpm-lock.yaml', "lockfileVersion: '9.0'\n");
|
|
111
|
+
write(root, 'package.json', { name: 'r' });
|
|
112
|
+
return renderPrebuiltDockerfile({ root, imageClass, copy: [], buildSource: 'alpha', base: BASE, nodeMajor: '22', ...extra }).dockerfile;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
test('common is built on the node-runtime base with no toolset; it carries its class label', () => {
|
|
116
|
+
const dockerfile = render(ComposedImageClass.Common);
|
|
117
|
+
assert.match(dockerfile, /^FROM ghcr\.io\/example\/base\/node-runtime@sha256:a{64} AS base$/m);
|
|
118
|
+
assert.doesNotMatch(dockerfile, /AS cosign|AS oras|chromium/);
|
|
119
|
+
assert.match(dockerfile, /^LABEL dev\.xema\.composed-image\.class="common"$/m);
|
|
120
|
+
assert.throws(() => render(ComposedImageClass.Common, { chromiumBase: CHROMIUM }), /--chromium-base belongs to the web-access class/);
|
|
121
|
+
assert.throws(() => render(ComposedImageClass.Common, { toolsets: TOOLSETS }), /carries the toolset\(s\) none; --toolset gave cosign, oras/);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test('web-access is its own third class, built FROM the Chromium base and not the node-runtime base', () => {
|
|
125
|
+
const dockerfile = render(ComposedImageClass.WebAccess, { chromiumBase: CHROMIUM });
|
|
126
|
+
assert.match(dockerfile, new RegExp(`^FROM ${CHROMIUM.replaceAll('.', '\\.')} AS base$`, 'm'));
|
|
127
|
+
assert.doesNotMatch(dockerfile, /node-runtime/);
|
|
128
|
+
assert.match(dockerfile, /^LABEL dev\.xema\.composed-image\.class="web-access"$/m);
|
|
129
|
+
assert.throws(() => render(ComposedImageClass.WebAccess), /--chromium-base must be pinned by digest/);
|
|
130
|
+
assert.throws(() => render(ComposedImageClass.WebAccess, { chromiumBase: 'chromium:latest' }), /pinned by digest/);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test('privileged carries its toolsets, pinned by digest, in the layers right after the base and before the store layer', () => {
|
|
134
|
+
const dockerfile = render(ComposedImageClass.Privileged, { toolsets: TOOLSETS });
|
|
135
|
+
assert.match(dockerfile, new RegExp(`^FROM ${TOOLSETS.cosign.replaceAll('.', '\\.')} AS cosign$`, 'm'));
|
|
136
|
+
assert.match(dockerfile, new RegExp(`^FROM ${TOOLSETS.oras.replaceAll('.', '\\.')} AS oras$`, 'm'));
|
|
137
|
+
const final = dockerfile.split('\nFROM base\n')[1];
|
|
138
|
+
const copies = final.split('\n').filter((line) => line.startsWith('COPY '));
|
|
139
|
+
assert.deepEqual(copies, [
|
|
140
|
+
'COPY --from=cosign /ko-app/cosign /usr/local/bin/cosign',
|
|
141
|
+
'COPY --from=oras /bin/oras /usr/local/bin/oras',
|
|
142
|
+
'COPY .xema-stage/layers/store/ /',
|
|
143
|
+
'COPY .xema-stage/layers/workspace/ /',
|
|
144
|
+
'COPY --from=tail / /',
|
|
145
|
+
]);
|
|
146
|
+
assert.throws(() => render(ComposedImageClass.Privileged), /carries the toolset\(s\) cosign, oras; --toolset gave none/);
|
|
147
|
+
assert.throws(() => render(ComposedImageClass.Privileged, { toolsets: { cosign: TOOLSETS.cosign, oras: 'oras:latest' } }), /--toolset oras must be an image reference pinned by digest/);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test('every class: the image layers are base, [toolsets], store, workspace, ONE tail (dist + verdict); a single process hosts the members', () => {
|
|
151
|
+
for (const [imageClass, extra] of [[ComposedImageClass.Common, {}], [ComposedImageClass.Privileged, { toolsets: TOOLSETS }], [ComposedImageClass.WebAccess, { chromiumBase: CHROMIUM }]]) {
|
|
152
|
+
const dockerfile = render(imageClass, extra);
|
|
153
|
+
const final = dockerfile.split('\nFROM base\n')[1];
|
|
154
|
+
const layerSteps = final.split('\n').filter((line) => /^(COPY|RUN|ADD) /.test(line));
|
|
155
|
+
assert.ok(layerSteps.every((line) => line.startsWith('COPY ')), `${imageClass}: the final stage adds layers only by COPY`);
|
|
156
|
+
assert.equal(layerSteps.at(-1), 'COPY --from=tail / /', `${imageClass}: dist is the last layer`);
|
|
157
|
+
assert.equal(layerSteps.filter((line) => line.includes('layers/dist')).length, 0, `${imageClass}: dist reaches the image only through the tail`);
|
|
158
|
+
assert.ok(final.indexOf('COPY .xema-stage/layers/store/ /') < final.indexOf('\nWORKDIR /monorepo\n'), 'WORKDIR follows the store layer, which creates /monorepo');
|
|
159
|
+
assert.match(final, /CMD \["node", "\/monorepo\/xema-composed-process"\]\n$/);
|
|
160
|
+
assert.doesNotMatch(dockerfile, /child_process|fork\(|spawn/);
|
|
161
|
+
}
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
test('an image class is derived from its members\' manifests: one agreed class passes, mixed and none are refused with the image and members named', () => {
|
|
165
|
+
const root = repository([
|
|
166
|
+
component('a-api', { imageClass: ComposedImageClass.Privileged }),
|
|
167
|
+
component('b-api', { imageClass: ComposedImageClass.Privileged }),
|
|
168
|
+
component('c-api'),
|
|
169
|
+
]);
|
|
170
|
+
const declare = (...keys) => write(root, 'infra/composed-process/privileged/image-build-inputs.json', { workspacePackages: keys.map((key) => `biomes/alpha/api/${key}`) });
|
|
171
|
+
const imageDir = 'infra/composed-process/privileged';
|
|
172
|
+
|
|
173
|
+
declare('a-api', 'b-api');
|
|
174
|
+
assert.equal(readImageClass({ root, imageDir }), ComposedImageClass.Privileged);
|
|
175
|
+
assert.equal(deriveImageClass({ image: 'x', members: [{ key: 'alpha/a-api', imageClass: ComposedImageClass.WebAccess }] }), ComposedImageClass.WebAccess);
|
|
176
|
+
|
|
177
|
+
declare('a-api', 'c-api');
|
|
178
|
+
assert.throws(() => readImageClass({ root, imageDir }), (error) => error.image === imageDir && /alpha\/a-api=privileged, alpha\/c-api=common/.test(error.message) && /different classes/.test(error.message));
|
|
179
|
+
|
|
180
|
+
declare('c-api');
|
|
181
|
+
assert.equal(readImageClass({ root, imageDir }), ComposedImageClass.Common, 'a member that declares no class is common');
|
|
182
|
+
|
|
183
|
+
declare();
|
|
184
|
+
assert.throws(() => readImageClass({ root, imageDir }), (error) => error instanceof ComposedImageClassError && error.image === imageDir && /has no member/.test(error.message));
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
test('a worker member carries its imageClass through the contracts schema; absent resolves to the contracts default; a mixed set is refused', () => {
|
|
188
|
+
const worker = (key, extra = {}) => component(key, { kind: 'worker', ...extra });
|
|
189
|
+
const root = repository([
|
|
190
|
+
worker('priv-worker', { imageClass: ComposedImageClass.Privileged }),
|
|
191
|
+
worker('web-worker', { imageClass: ComposedImageClass.WebAccess }),
|
|
192
|
+
worker('plain-worker'),
|
|
193
|
+
component('priv-api', { imageClass: ComposedImageClass.Privileged }),
|
|
194
|
+
]);
|
|
195
|
+
const imageDir = 'infra/composed-process/privileged';
|
|
196
|
+
const declare = (...keys) => write(root, `${imageDir}/image-build-inputs.json`, { workspacePackages: keys.map((key) => `biomes/alpha/api/${key}`) });
|
|
197
|
+
|
|
198
|
+
declare('priv-worker');
|
|
199
|
+
assert.equal(readImageClass({ root, imageDir }), ComposedImageClass.Privileged, 'a worker alone declares its class');
|
|
200
|
+
declare('priv-worker', 'priv-api');
|
|
201
|
+
assert.equal(readImageClass({ root, imageDir }), ComposedImageClass.Privileged, 'a worker and a service agree');
|
|
202
|
+
declare('web-worker');
|
|
203
|
+
assert.equal(readImageClass({ root, imageDir }), ComposedImageClass.WebAccess);
|
|
204
|
+
|
|
205
|
+
assert.equal(DEFAULT_COMPOSED_IMAGE_CLASS, 'common');
|
|
206
|
+
declare('plain-worker');
|
|
207
|
+
assert.equal(readImageClass({ root, imageDir }), DEFAULT_COMPOSED_IMAGE_CLASS, 'a worker that declares no class is the contracts default');
|
|
208
|
+
|
|
209
|
+
declare('priv-worker', 'plain-worker');
|
|
210
|
+
assert.throws(() => readImageClass({ root, imageDir }), (error) => error instanceof ComposedImageClassError && /alpha\/priv-worker=privileged, alpha\/plain-worker=common/.test(error.message));
|
|
211
|
+
declare('priv-worker', 'web-worker');
|
|
212
|
+
assert.throws(() => readImageClass({ root, imageDir }), ComposedImageClassError);
|
|
213
|
+
|
|
214
|
+
const derived = deriveComposedMembers({ root });
|
|
215
|
+
assert.deepEqual(Object.keys(derived.members.privileged).sort(), ['alpha/priv-api', 'alpha/priv-worker']);
|
|
216
|
+
assert.deepEqual(Object.keys(derived.members['web-access']), ['alpha/web-worker']);
|
|
217
|
+
assert.equal(derived.memberClasses['alpha/plain-worker'], undefined, 'an undeclared worker is no member');
|
|
218
|
+
});
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// The class of a COMMITTED composed image, read back from its members' biome manifests. Build-time only: it imports the
|
|
2
|
+
// class module, so nothing the image runs (`composed-image-in-image.mjs`'s closure) may import this file.
|
|
3
|
+
import { readFileSync } from 'node:fs';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
|
|
6
|
+
import { deriveImageClass } from './composed-image-class.mjs';
|
|
7
|
+
import { parseManifestComponent, componentImageClass } from './composed-image-manifest-components.mjs';
|
|
8
|
+
import { IMAGE_BUILD_INPUTS_FILE, memberComponent } from './composed-image-members.mjs';
|
|
9
|
+
import { COMPOSED_IMAGE_DIR } from './composed-image-paths.mjs';
|
|
10
|
+
|
|
11
|
+
/** The class of the image declared at `imageDir`, derived from its members' manifests (absent means the contracts default); refuses members that disagree. */
|
|
12
|
+
export function readImageClass({ root = process.cwd(), imageDir = COMPOSED_IMAGE_DIR } = {}) {
|
|
13
|
+
const directories = JSON.parse(readFileSync(join(root, imageDir, IMAGE_BUILD_INPUTS_FILE), 'utf8')).workspacePackages ?? [];
|
|
14
|
+
const members = directories.map((directory) => {
|
|
15
|
+
const { key, component } = memberComponent(root, directory);
|
|
16
|
+
return { key, imageClass: componentImageClass(parseManifestComponent(component, key)) };
|
|
17
|
+
});
|
|
18
|
+
return deriveImageClass({ image: imageDir, members });
|
|
19
|
+
}
|
|
@@ -29,7 +29,7 @@ test('the CLI prints content_tag and cross_repo_pins under an environment with n
|
|
|
29
29
|
try {
|
|
30
30
|
const tagArgs = path.join(root, 'tag-args.txt');
|
|
31
31
|
fs.writeFileSync(tagArgs, `--vendored\nd=r@${'b'.repeat(40)}:s\n`);
|
|
32
|
-
const result = spawnSync(process.execPath, [BIN, 'coordinate', '--image-name', 'img'], {
|
|
32
|
+
const result = spawnSync(process.execPath, [BIN, 'coordinate', '--class', 'common', '--image-name', 'img'], {
|
|
33
33
|
cwd: root,
|
|
34
34
|
encoding: 'utf8',
|
|
35
35
|
env: { PATH: process.env.PATH, SHA: 'b'.repeat(40), REGISTRY: 'ghcr.io', REGISTRY_ORG: 'org', TAG_ARGS_FILE: tagArgs },
|
|
@@ -47,7 +47,7 @@ test('the CLI fails with the real message when the hash package is not installed
|
|
|
47
47
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'coordinate-cli-'));
|
|
48
48
|
try {
|
|
49
49
|
fs.writeFileSync(path.join(root, 'package.json'), '{}');
|
|
50
|
-
const result = spawnSync(process.execPath, [BIN, 'coordinate', '--image-name', 'img'], {
|
|
50
|
+
const result = spawnSync(process.execPath, [BIN, 'coordinate', '--class', 'common', '--image-name', 'img'], {
|
|
51
51
|
cwd: root,
|
|
52
52
|
encoding: 'utf8',
|
|
53
53
|
env: { PATH: process.env.PATH, SHA: 'b'.repeat(40), REGISTRY: 'ghcr.io', REGISTRY_ORG: 'org' },
|
|
@@ -4,9 +4,81 @@ import fs from 'node:fs';
|
|
|
4
4
|
import os from 'node:os';
|
|
5
5
|
import path from 'node:path';
|
|
6
6
|
|
|
7
|
+
const REQUIREMENTS = Object.freeze({
|
|
8
|
+
tenancy: { allowed: ['org', 'project'], tenantContext: 'verified' },
|
|
9
|
+
isolation: { minimum: 'none' },
|
|
10
|
+
trust: { minimum: 'verified' },
|
|
11
|
+
locality: { allowed: ['cloud', 'customer-private'] },
|
|
12
|
+
state: { kind: 'stateless' },
|
|
13
|
+
resources: { minimum: { cpu: '100m', memory: '128Mi', ephemeralStorage: '64Mi' }, preferred: { cpu: '250m', memory: '256Mi', ephemeralStorage: '128Mi' } },
|
|
14
|
+
runtime: { kind: 'node', versionRange: '>=24', architectures: ['amd64', 'arm64'] },
|
|
15
|
+
io: { ingress: 'http', egress: 'platform', rawBody: false, devices: [] },
|
|
16
|
+
scaling: {
|
|
17
|
+
mode: 'horizontal',
|
|
18
|
+
concurrency: { handling: 'parallel', maximumPerInstance: 100 },
|
|
19
|
+
readiness: { kind: 'protocol', initialDelaySeconds: 2 },
|
|
20
|
+
drain: { kind: 'graceful', maximumSeconds: 30 },
|
|
21
|
+
hints: { cpu: 'latency', memory: 'steady', startup: 'fast' },
|
|
22
|
+
},
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
const protocolOf = (kind, key) =>
|
|
26
|
+
kind === 'service' ? { kind: 'http', revision: 'v1', serviceName: key, authScopes: ['org'] } : PROTOCOLS[kind] ?? { kind: 'none' };
|
|
27
|
+
|
|
28
|
+
const PROTOCOLS = Object.freeze({
|
|
29
|
+
worker: { kind: 'worker', revision: 'v2', taskQueues: [{ name: 'default', serves: ['workflows', 'activities'], fairness: 'org' }] },
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* A component the contracts component schema accepts: the fields a test names win, every other required field is
|
|
34
|
+
* filled. The packager parses each manifest component through that schema (the one grammar), so a fixture that names only
|
|
35
|
+
* `key`, `kind`, `scheduler` and `artifact` must still be a whole component.
|
|
36
|
+
*/
|
|
37
|
+
export function validComponent(partial) {
|
|
38
|
+
const artifact = partial.artifact?.kind === undefined && partial.artifact?.path !== undefined ? { kind: 'oci-image', ...partial.artifact } : partial.artifact;
|
|
39
|
+
return {
|
|
40
|
+
entrypoint: artifact?.kind === 'shared-oci-image' ? { kind: 'node-module', module: './dist/main.js' } : { kind: 'oci-default' },
|
|
41
|
+
protocol: protocolOf(partial.kind, partial.key),
|
|
42
|
+
executionModes: ['isolated'],
|
|
43
|
+
requirements: REQUIREMENTS,
|
|
44
|
+
...partial,
|
|
45
|
+
artifact,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** A whole `content` component: a kind that is neither service nor worker, so it cannot declare an `imageClass`. */
|
|
50
|
+
export const CONTENT_COMPONENT = Object.freeze({
|
|
51
|
+
key: 'alpha-content',
|
|
52
|
+
kind: 'content',
|
|
53
|
+
artifact: { kind: 'package-content', path: '.' },
|
|
54
|
+
entrypoint: { kind: 'materialize' },
|
|
55
|
+
protocol: { kind: 'none' },
|
|
56
|
+
executionModes: ['materialized'],
|
|
57
|
+
requirements: {
|
|
58
|
+
tenancy: { allowed: ['org'], tenantContext: 'verified' },
|
|
59
|
+
isolation: { minimum: 'none' },
|
|
60
|
+
trust: { minimum: 'untrusted' },
|
|
61
|
+
locality: { allowed: ['cloud', 'customer-private'] },
|
|
62
|
+
state: { kind: 'stateless' },
|
|
63
|
+
resources: { minimum: { cpu: '1m', memory: '1Mi', ephemeralStorage: '1Mi' } },
|
|
64
|
+
runtime: { kind: 'none' },
|
|
65
|
+
io: { ingress: 'none', egress: 'none', rawBody: false, devices: [] },
|
|
66
|
+
scaling: {
|
|
67
|
+
mode: 'singleton',
|
|
68
|
+
concurrency: { handling: 'serial', maximumPerInstance: 1 },
|
|
69
|
+
readiness: { kind: 'none' },
|
|
70
|
+
drain: { kind: 'none' },
|
|
71
|
+
hints: { cpu: 'batch', memory: 'steady', startup: 'fast' },
|
|
72
|
+
},
|
|
73
|
+
},
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
/** Write a fixture file. A biome manifest's components are completed with `validComponent`, so a test names only what it varies. */
|
|
7
77
|
export function write(root, rel, content) {
|
|
78
|
+
const manifest = typeof content !== 'string' && path.basename(rel) === 'xema-biome.json' && Array.isArray(content.xema?.components);
|
|
79
|
+
const body = manifest ? { ...content, xema: { ...content.xema, components: content.xema.components.map(validComponent) } } : content;
|
|
8
80
|
fs.mkdirSync(path.join(root, path.dirname(rel)), { recursive: true });
|
|
9
|
-
fs.writeFileSync(path.join(root, rel), typeof
|
|
81
|
+
fs.writeFileSync(path.join(root, rel), typeof body === 'string' ? body : JSON.stringify(body, null, 2));
|
|
10
82
|
}
|
|
11
83
|
|
|
12
84
|
export const BASE = 'ghcr.io/example/base/node-runtime@sha256:' + 'a'.repeat(64);
|
|
@@ -7,6 +7,7 @@ import os from 'node:os';
|
|
|
7
7
|
import path from 'node:path';
|
|
8
8
|
import test, { after } from 'node:test';
|
|
9
9
|
|
|
10
|
+
import { ComposedImageClass } from './composed-image-class.mjs';
|
|
10
11
|
import { renderPrebuiltDockerfile } from './composed-image-prebuilt-dockerfile.mjs';
|
|
11
12
|
import { copyTool } from './composed-image-stage.mjs';
|
|
12
13
|
import { BASE, workspace } from './composed-image-fixture.test.mjs';
|
|
@@ -26,6 +27,7 @@ function renderedEntries() {
|
|
|
26
27
|
const root = workspace();
|
|
27
28
|
const { dockerfile } = renderPrebuiltDockerfile({
|
|
28
29
|
root,
|
|
30
|
+
imageClass: ComposedImageClass.Common,
|
|
29
31
|
copy: ['tooling/dev'],
|
|
30
32
|
buildSource: 'alpha', base: BASE, nodeMajor: '22',
|
|
31
33
|
});
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// The ONE grammar for a biome manifest component is the contracts component schema, `imageClass` included: which kinds
|
|
2
|
+
// may declare a class, which values it may take. The packager parses through it and keeps no copy of any of it.
|
|
3
|
+
//
|
|
4
|
+
// Build-time only: it imports `@xemahq/contracts`, so nothing the image runs (`composed-image-in-image.mjs`'s closure)
|
|
5
|
+
// may import this file.
|
|
6
|
+
import { BiomeComponentSchema } from '@xemahq/contracts/biome';
|
|
7
|
+
|
|
8
|
+
import { DEFAULT_COMPOSED_IMAGE_CLASS } from './composed-image-class.mjs';
|
|
9
|
+
|
|
10
|
+
/** One manifest component parsed by the contracts schema; a component it refuses names the manifest and the component. */
|
|
11
|
+
export function parseManifestComponent(component, manifestPath) {
|
|
12
|
+
const parsed = BiomeComponentSchema.safeParse(component);
|
|
13
|
+
if (!parsed.success) {
|
|
14
|
+
const issues = parsed.error.issues.map((issue) => `${issue.path.join('.') || '(component)'}: ${issue.message}`).join('; ');
|
|
15
|
+
throw new Error(`${manifestPath}: component "${component?.key}" is not a valid biome component: ${issues}`);
|
|
16
|
+
}
|
|
17
|
+
return parsed.data;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** The components of a biome manifest, each parsed by the contracts schema. */
|
|
21
|
+
export function parseManifestComponents(manifest, manifestPath) {
|
|
22
|
+
return (manifest.xema?.components ?? []).map((component) => parseManifestComponent(component, manifestPath));
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** The class a parsed component runs in: its `imageClass`, or the contracts default when it declares none. */
|
|
26
|
+
export function componentImageClass(component) {
|
|
27
|
+
return component.imageClass ?? DEFAULT_COMPOSED_IMAGE_CLASS;
|
|
28
|
+
}
|
|
@@ -9,22 +9,22 @@ import { COMPOSED_IMAGE_DIR } from './composed-image-paths.mjs';
|
|
|
9
9
|
|
|
10
10
|
export const IMAGE_BUILD_INPUTS_FILE = 'image-build-inputs.json';
|
|
11
11
|
|
|
12
|
-
/** The `<biomeId>/<componentKey>` of the one service whose artifact is `directory`. */
|
|
13
|
-
function
|
|
12
|
+
/** The `<biomeId>/<componentKey>` and the manifest component (raw: this module runs in the image, so it parses nothing) of the one service or worker whose artifact is `directory`. */
|
|
13
|
+
export function memberComponent(root, directory) {
|
|
14
14
|
for (let dir = dirname(directory); dir !== '.' && dir !== dirname(dir); dir = dirname(dir)) {
|
|
15
15
|
const manifestPath = join(root, dir, 'xema-biome.json');
|
|
16
16
|
if (!existsSync(manifestPath)) continue;
|
|
17
17
|
const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
|
|
18
18
|
const matches = (manifest.xema?.components ?? []).filter(
|
|
19
19
|
(component) =>
|
|
20
|
-
component.kind === 'service' &&
|
|
20
|
+
(component.kind === 'service' || component.kind === 'worker') &&
|
|
21
21
|
typeof component.artifact?.path === 'string' &&
|
|
22
22
|
posix.normalize(posix.join(dir, component.artifact.path)) === directory,
|
|
23
23
|
);
|
|
24
24
|
if (matches.length !== 1) {
|
|
25
|
-
throw new Error(`${dir}/xema-biome.json declares ${matches.length} service(s) at ${directory}, not exactly one`);
|
|
25
|
+
throw new Error(`${dir}/xema-biome.json declares ${matches.length} service or worker component(s) at ${directory}, not exactly one`);
|
|
26
26
|
}
|
|
27
|
-
return `${manifest.xema.id}/${matches[0].key}
|
|
27
|
+
return { key: `${manifest.xema.id}/${matches[0].key}`, component: matches[0] };
|
|
28
28
|
}
|
|
29
29
|
throw new Error(`no biome manifest above ${directory}`);
|
|
30
30
|
}
|
|
@@ -34,7 +34,7 @@ export function readMembers({ root = process.cwd(), imageDir = COMPOSED_IMAGE_DI
|
|
|
34
34
|
const inputs = join(root, imageDir, IMAGE_BUILD_INPUTS_FILE);
|
|
35
35
|
const directories = JSON.parse(readFileSync(inputs, 'utf8')).workspacePackages ?? [];
|
|
36
36
|
if (directories.length === 0) throw new Error(`${inputs} declares no workspacePackages`);
|
|
37
|
-
return directories.map((directory) => [
|
|
37
|
+
return directories.map((directory) => [memberComponent(root, directory).key, directory]);
|
|
38
38
|
}
|
|
39
39
|
|
|
40
40
|
function packageName(root, directory) {
|
|
@@ -10,8 +10,10 @@ export const NODE_MAJOR_LABEL = 'dev.xema.composed-image.node-major';
|
|
|
10
10
|
// The layout of the stage directory the prebuilt Dockerfile COPYs from (relative to the stage dir).
|
|
11
11
|
/** Production packages (`node_modules/.pnpm`): stable until the lockfile changes. Copied to `/`. */
|
|
12
12
|
export const STAGE_STORE_LAYER = 'layers/store';
|
|
13
|
-
/**
|
|
14
|
-
export const
|
|
13
|
+
/** The injected workspace packages, the Prisma clients, the closure's package files and root manifests: everything of `/monorepo` that is neither a registry package nor a member's own output. Copied to `/`. */
|
|
14
|
+
export const STAGE_WORKSPACE_LAYER = 'layers/workspace';
|
|
15
|
+
/** The members' build outputs (default `dist`) and the member index with its host link: what changes on every commit. Copied to `/` LAST, so a commit that changes no dependency rebuilds only this layer. */
|
|
16
|
+
export const STAGE_DIST_LAYER = 'layers/dist';
|
|
15
17
|
/** The stage manifest the proof reads. */
|
|
16
18
|
export const STAGE_PROOF_DIRECTORY = 'proof';
|
|
17
19
|
/** The image's root directory, under each layer. */
|