@xemahq/repo-build-tooling 0.21.3 → 0.22.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 -10
- package/package.json +7 -7
- package/src/composed-image-prisma-engine.mjs +14 -6
- package/src/prepush-sanity.mjs +19 -1
- package/src/prepush-sanity.test.mjs +27 -1
- package/src/service-image-in-image.mjs +194 -0
- package/src/service-image-platform.mjs +128 -0
- package/src/service-image-stage.mjs +253 -0
- package/src/service-image.mjs +60 -0
- package/src/service-image.test.mjs +404 -0
- package/src/buildx-builder-host.mjs +0 -61
- package/src/buildx-builder-limits.mjs +0 -40
- package/src/buildx-builder.mjs +0 -175
- package/src/buildx-builder.test.mjs +0 -232
package/README.md
CHANGED
|
@@ -30,9 +30,9 @@ pnpm exec xema-scrub-swagger-paths dist
|
|
|
30
30
|
| `xema-service-bootstrap [--check] [--filter <key>] [--root <dir>]` | Generates `src/generated/*.generated.ts` service and worker descriptors from the repository's `xema-biome.json` manifests; `--check` fails on drift |
|
|
31
31
|
| `xema-scrub-swagger-paths <dist-dir>...` | Rewrites `@nestjs/swagger` plugin output that names a private install path, then fails the build if any leak remains (`--self-test` available) |
|
|
32
32
|
| `xema-composed-image <command>` | Build-time half of a composed-process image: `members`, `filters`, `dirs`, `pins`, `index`, `load`, `stage`, `pnpm-context`, `sync-injected`, `prisma-check`, `migrate`, `coordinate`, `load-image`, `smoke-refusal`, `dockerfile` |
|
|
33
|
+
| `xema-service-image stage --service <pkg> --path <dir> --platform linux/<amd64\|arm64> [--stage-dir <dir>]` | Runner half of a per-service image: `pnpm deploy --prod --offline --ignore-scripts` of a service already built on the runner, selected for the TARGET platform (`--os=linux --cpu=<arch> --libc=glibc`), into `<stage>/service/app`, with its Prisma client and the target's schema engine. Refuses a package whose native code holds no ELF for the target (a multi-platform package that also ships the target's binary is accepted) and production dependencies whose build script would have to run, uncommitted changes to tracked files, an untracked root build input, and running as any tool but the one the root installs. Fetches the schema engine from Prisma for every stage, checks it against both published checksums, and caches nothing. Writes `<stage>/service/xema-service-image.json` (commit, sha256 of each root build input: lockfile, root `package.json`, `pnpm-workspace.yaml`, `.npmrc`, `.pnpmfile.cjs`, `turbo.json`, `tsconfig*.json`; tool version; natives per package; engine sha256) and `<stage>/tool`. The Dockerfile COPYs those root inputs into `/proof/context/`, which puts them in the content tag, and its proof stage runs `src/service-image-in-image.mjs prove <app> <proof dir> --service <svc> --commit <BUILD_SHA>` on the target, offline: it checks the commit, the root inputs, the tool version, the platform and Node ABI, that each package's addons `dlopen`, and the engine's sha256 and version. The final stage only COPYs |
|
|
33
34
|
| `xema-arm64-supplement-needed` | Decides whether an arm64 supplement is still owed at `REGISTRY/IMAGE:CONTENT_TAG` (env: `REGISTRY`, `IMAGE`, `CONTENT_TAG`, `REGISTRY_USER`, `REGISTRY_TOKEN`). `needed=false` only when the coordinate serves an index whose linux/arm64 child is proven by its own image config; any other resolvable state is `needed=true`; a 401/403/5xx or an unresolvable child is an error. Writes `needed=true\|false` to `$GITHUB_OUTPUT`. Replaces the generated `arm64-supplement-needed.mjs` copy. |
|
|
34
35
|
| `xema-promote-image --service <name> --dev-ref <repo> --release-ref <repo> --tag <tag> --commit <sha> --main-ref <ref> --signer-repository <owner/repo> --develop-workflow <file> --release-workflow <file>... [--release-index-workflow <file>] --promotion-predicate-out <path> -- <tag command>` | Publishes the develop image's digest under the release tag instead of rebuilding: `reused` (the release digest carries an `image-promotion/v1` attestation signed by a `--release-workflow` identity on main, with exactly one verified statement about the digest and a `ghcr.io/<owner>/dev/<name>` source; no dev-ref probe), `copied` (the proven develop digest, digest-preserving; fails closed if the release digest differs) or `miss` (build it; a reuse whose release digest has no `image-promotion/v1` attestation, a legacy image, is a miss with `reason=reuse-no-promotion` on the stdout line; with `--release-index-workflow`, a release tag that names an image index signed by that workflow on main, with exactly one `linux/amd64` child whose own digest passes that same reuse check, is instead `reused` with `evidence=release-index`, `digest` = the amd64 child A, `index_digest` = the index D and no predicate, but only when A's selected promotion statement promotes `--commit` itself (a rerun; a later commit with the same content takes the existing miss and attests its own promotion). On `evidence=release-index` the caller skips sign/attest, tags `:<sha>` and `main-<sha>` to the amd64 subject `digest` (release-arm64 resolves `:<sha>` to it and verifies its promotion), and points only the content tag (and `latest`, if it moves it) at the index `index_digest`. Any failed step keeps the miss, and two amd64 children are an error; a signature or identity failure, or a source outside `dev/`, is an error; a copy whose dev digest carries no SLSA v1 provenance at all, an image reused on develop from before provenance, is a miss with `reason=copy-no-develop-provenance`, while provenance that is present but fails a leg is an error). A develop digest is proven only if its SLSA v1 provenance is signed by the develop workflow at `refs/heads/develop`, its revision is an ancestor of the main ref, and the tag command prints the same tag at that revision and at `--commit`; any failed leg is an error, never a miss. On every non-miss result it writes the `https://xema.dev/attestations/image-promotion/v1` predicate for the caller to attest: a copy states the facts just proved; a reuse states this run's `release` and `promotedCommit` and carries `source` and `developProvenance` from the earlier promotion. Writes `result`, `digest`, `source`, `revision`, `evidence`, `provenance_digest`, `promotion_predicate`, `promotion_predicate_type` to `$GITHUB_OUTPUT`. Needs `docker`, `cosign` (major v2, the same as the develop attest step and xema-deploy's verify; it runs `cosign version` first and refuses any other major or an unparsable version), `git` and full history. It does not sign |
|
|
35
|
-
| `xema-buildx-builder ensure` | Ensures the runner's persistent, capped buildkitd container (`xema-buildkit-signed-<runner>`, state volume `-state`) is up and is the current buildx builder. Env: `RUNNER_NAME`, `BUILDKIT_IMAGE`, `BUILDKIT_LIMITS` (JSON `cpus`, `memory`, `maxParallelism`, `gcMaxUsedSpace`, all required and positive), optional `BUILDKIT_READY_DEADLINE_SECONDS` (positive integer, default 180). The slot is `RUNNER_NAME` with a trailing `-<hostname>` stripped only when it equals this container's own hostname, so a recreated runner container reuses its builder and cache. Pinned to the runner's cgroup cpuset; caps are clamped so every `xema-buildkit-signed-*` builder on the host fits (memory at most half of `docker info` MemTotal divided by N, CPUs at most NCPU divided by N). On each run it removes this verb's labelled builders, and their `-state` volumes, whose recorded runner container is no longer running; unlabelled containers are never touched. Recreated once when the image, effective caps, config or runner container change. Prints `buildx-boot path= secs= builder=`. |
|
|
36
36
|
| `xema-check-no-vendored-deps` | Fails on a `workspace:` range for a package the repository does not own |
|
|
37
37
|
| `xema-prepush-sanity` | Lefthook `pre-push`: turbo `lint` and `typecheck` on what the push changes plus dependents (root `lint` only without turbo; typecheck is CI-only there); CI is the proof |
|
|
38
38
|
| `xema-check-workspace-ranges` | Fails on a `workspace:` range the local package does not satisfy |
|
|
@@ -41,15 +41,6 @@ pnpm exec xema-scrub-swagger-paths dist
|
|
|
41
41
|
|
|
42
42
|
Every step refuses rather than skips. The package has no dependencies.
|
|
43
43
|
|
|
44
|
-
## Security model of `xema-buildx-builder`
|
|
45
|
-
|
|
46
|
-
The persistent builder and its cache mounts are for signed develop and main builds only.
|
|
47
|
-
|
|
48
|
-
- The verb refuses any `GITHUB_EVENT_NAME` other than `push` and `workflow_dispatch`, and any `GITHUB_REF` other than `refs/heads/main` and `refs/heads/develop`, before any docker call. A pull request never touches the persistent builder or its cache.
|
|
49
|
-
- Callers also gate the step on the opt-in org variable: the develop-images lane runs the verb only `if: vars.XEMA_BUILDKIT_MODE == 'persistent'` and uses the stock `setup-buildx-action` otherwise.
|
|
50
|
-
- The builder, its container and its state volume are named `xema-buildkit-signed-<runner>`; the `xema-buildkit-signed-*` prefix is reserved for signed lanes, so no other lane shares those cache mounts.
|
|
51
|
-
- Owner decision D97 (2026-10-14): pull request jobs run on the same runners. The in-process guards above are the accepted mitigation, because only we push to our repositories.
|
|
52
|
-
|
|
53
44
|
## Resolution
|
|
54
45
|
|
|
55
46
|
Inside the `xema-monorepo` aggregator, `@xemahq/repo-build-tooling` resolves to this directory through a workspace override. In an independent repository's CI it resolves from npm by the declared range, so a change here reaches a consumer only after it is published and the consumer's range admits it.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@xemahq/repo-build-tooling",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.22.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)",
|
|
@@ -22,7 +22,6 @@
|
|
|
22
22
|
],
|
|
23
23
|
"bin": {
|
|
24
24
|
"xema-arm64-supplement-needed": "src/arm64-supplement-needed.mjs",
|
|
25
|
-
"xema-buildx-builder": "src/buildx-builder.mjs",
|
|
26
25
|
"xema-check-no-vendored-deps": "src/check-no-vendored-deps.mjs",
|
|
27
26
|
"xema-check-workspace-ranges": "src/check-workspace-range-matches-local.mjs",
|
|
28
27
|
"xema-composed-image": "src/composed-image.mjs",
|
|
@@ -30,11 +29,11 @@
|
|
|
30
29
|
"xema-promote-image": "src/promote-image.mjs",
|
|
31
30
|
"xema-readiness": "src/readiness.mjs",
|
|
32
31
|
"xema-scrub-swagger-paths": "src/scrub-swagger-plugin-paths.mjs",
|
|
33
|
-
"xema-service-bootstrap": "src/service-bootstrap.mjs"
|
|
32
|
+
"xema-service-bootstrap": "src/service-bootstrap.mjs",
|
|
33
|
+
"xema-service-image": "src/service-image.mjs"
|
|
34
34
|
},
|
|
35
35
|
"exports": {
|
|
36
36
|
"./arm64-supplement-needed": "./src/arm64-supplement-needed.mjs",
|
|
37
|
-
"./buildx-builder": "./src/buildx-builder.mjs",
|
|
38
37
|
"./check-no-vendored-deps": "./src/check-no-vendored-deps.mjs",
|
|
39
38
|
"./check-workspace-range-matches-local": "./src/check-workspace-range-matches-local.mjs",
|
|
40
39
|
"./composed-image": "./src/composed-image.mjs",
|
|
@@ -43,15 +42,16 @@
|
|
|
43
42
|
"./promote-image": "./src/promote-image.mjs",
|
|
44
43
|
"./readiness": "./src/readiness.mjs",
|
|
45
44
|
"./scrub-swagger-plugin-paths": "./src/scrub-swagger-plugin-paths.mjs",
|
|
46
|
-
"./service-bootstrap": "./src/service-bootstrap.mjs"
|
|
45
|
+
"./service-bootstrap": "./src/service-bootstrap.mjs",
|
|
46
|
+
"./service-image": "./src/service-image.mjs"
|
|
47
47
|
},
|
|
48
48
|
"dependencies": {
|
|
49
49
|
"@xemahq/release-policy": "^14.0.0"
|
|
50
50
|
},
|
|
51
51
|
"scripts": {
|
|
52
|
-
"test": "node
|
|
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-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/prepush-sanity.test.mjs && node --test src/service-image.test.mjs"
|
|
53
53
|
},
|
|
54
54
|
"xemaRelease": {
|
|
55
|
-
"sourceDigest": "sha256:
|
|
55
|
+
"sourceDigest": "sha256:0b63df80f6c68f70cb652dfc51f08288aeac11bbee2445cfa6fc440242a7bcf9"
|
|
56
56
|
}
|
|
57
57
|
}
|
|
@@ -52,6 +52,19 @@ export function prismaEnginesDirectories(root) {
|
|
|
52
52
|
|
|
53
53
|
const runVersion = (file) => spawnSync(file, ['--version'], { encoding: 'utf8' });
|
|
54
54
|
|
|
55
|
+
/**
|
|
56
|
+
* Run a schema engine and refuse it unless it reports `enginesVersion`: the check Prisma makes before it
|
|
57
|
+
* decides to download one. Returns the engine's version line.
|
|
58
|
+
*/
|
|
59
|
+
export function verifySchemaEngine(file, enginesVersion, version = runVersion) {
|
|
60
|
+
const result = version(file);
|
|
61
|
+
if (result.error || result.status !== 0 || !String(result.stdout ?? '').includes(enginesVersion)) {
|
|
62
|
+
const detail = String(result.stdout || result.stderr || result.error?.message || '').trim();
|
|
63
|
+
throw new Error(`${file} --version did not report ${enginesVersion}: status ${result.status}${detail === '' ? '' : `, ${detail}`}`);
|
|
64
|
+
}
|
|
65
|
+
return String(result.stdout).trim();
|
|
66
|
+
}
|
|
67
|
+
|
|
55
68
|
/**
|
|
56
69
|
* Install the staged schema engine into every @prisma/engines directory a prisma CLI resolves.
|
|
57
70
|
* `binaryTarget` and `version` are injectable for tests; by default they are Prisma's own
|
|
@@ -77,12 +90,7 @@ export async function installPrismaSchemaEngine({ root = process.cwd(), stagedDi
|
|
|
77
90
|
const destination = join(enginesDir, fileName);
|
|
78
91
|
copyFileSync(source, destination);
|
|
79
92
|
chmodSync(destination, 0o755);
|
|
80
|
-
|
|
81
|
-
if (result.error || result.status !== 0 || !String(result.stdout ?? '').includes(enginesVersion)) {
|
|
82
|
-
const detail = String(result.stdout || result.stderr || result.error?.message || '').trim();
|
|
83
|
-
throw new Error(`${destination} --version did not report ${enginesVersion}: status ${result.status}${detail === '' ? '' : `, ${detail}`}`);
|
|
84
|
-
}
|
|
85
|
-
log(`prisma schema engine: ${destination} (${String(result.stdout).trim()})`);
|
|
93
|
+
log(`prisma schema engine: ${destination} (${verifySchemaEngine(destination, enginesVersion, version)})`);
|
|
86
94
|
installed.push(destination);
|
|
87
95
|
}
|
|
88
96
|
return installed;
|
package/src/prepush-sanity.mjs
CHANGED
|
@@ -13,7 +13,9 @@
|
|
|
13
13
|
// The pushed SHA is what is graded. In place when the tree is clean and the SHA
|
|
14
14
|
// is HEAD, or when no differing path lies in a package the run reads or at the
|
|
15
15
|
// repository root; otherwise from a `git archive` export. A delete-only push
|
|
16
|
-
// (or no ref on stdin) grades nothing.
|
|
16
|
+
// (or no ref on stdin) grades nothing. A push whose every remote ref is a
|
|
17
|
+
// session park (`refs/xema-session/*`) grades nothing either: a park is not a
|
|
18
|
+
// landing, and the work is graded when it lands on a branch.
|
|
17
19
|
//
|
|
18
20
|
// Never builds for its own sake, never runs tests, docker or `pnpm check`.
|
|
19
21
|
//
|
|
@@ -80,6 +82,18 @@ export function parseRefs(stdin) {
|
|
|
80
82
|
return refs;
|
|
81
83
|
}
|
|
82
84
|
|
|
85
|
+
/** Remote refs under this prefix are session parks, not landings. */
|
|
86
|
+
export const PARK_REF_PREFIX = 'refs/xema-session/';
|
|
87
|
+
|
|
88
|
+
/** True when stdin names at least one ref and every remote ref is a session park. A branch anywhere in the push grades the push. */
|
|
89
|
+
export function parkOnly(stdin) {
|
|
90
|
+
const remoteRefs = stdin
|
|
91
|
+
.split('\n')
|
|
92
|
+
.map((line) => line.trim().split(/\s+/)[2])
|
|
93
|
+
.filter(Boolean);
|
|
94
|
+
return remoteRefs.length > 0 && remoteRefs.every((ref) => ref.startsWith(PARK_REF_PREFIX));
|
|
95
|
+
}
|
|
96
|
+
|
|
83
97
|
/** True when two root package.json texts differ only in the top-level `scripts` key; a missing side or unparseable JSON is false. */
|
|
84
98
|
export function scriptsOnlyChange(baseText, tipText) {
|
|
85
99
|
try {
|
|
@@ -318,6 +332,10 @@ export function main(stdin) {
|
|
|
318
332
|
console.error('pre-push: not inside a git repository — FAILED');
|
|
319
333
|
return 1;
|
|
320
334
|
}
|
|
335
|
+
if (parkOnly(stdin)) {
|
|
336
|
+
console.log(`pre-push: skipped — every ref is a session park under ${PARK_REF_PREFIX}; parks are not landings, the gate runs when the work lands.`);
|
|
337
|
+
return 0;
|
|
338
|
+
}
|
|
321
339
|
const ctx = createContext(top.stdout.trim());
|
|
322
340
|
const refs = parseRefs(stdin);
|
|
323
341
|
if (refs.length === 0) {
|
|
@@ -5,7 +5,7 @@ import { tmpdir } from 'node:os';
|
|
|
5
5
|
import { join } from 'node:path';
|
|
6
6
|
import { test } from 'node:test';
|
|
7
7
|
import { fileURLToPath } from 'node:url';
|
|
8
|
-
import { blockingPath, isFleetWide, parseRefs, scriptsOnlyChange, stripJsonc, turboCacheDir, turboTasks } from './prepush-sanity.mjs';
|
|
8
|
+
import { blockingPath, isFleetWide, parkOnly, parseRefs, scriptsOnlyChange, stripJsonc, turboCacheDir, turboTasks } from './prepush-sanity.mjs';
|
|
9
9
|
|
|
10
10
|
const VERB = fileURLToPath(new URL('./prepush-sanity.mjs', import.meta.url));
|
|
11
11
|
const SHA_A = 'a'.repeat(40);
|
|
@@ -136,3 +136,29 @@ test('the turbo invocation receives TURBO_CACHE_DIR', () => {
|
|
|
136
136
|
rmSync(r.dir, { recursive: true, force: true });
|
|
137
137
|
}
|
|
138
138
|
});
|
|
139
|
+
|
|
140
|
+
test('parkOnly: only a push whose every remote ref is a session park', () => {
|
|
141
|
+
const park = `refs/heads/x ${SHA_A} refs/xema-session/lead/T1/x ${'0'.repeat(40)}`;
|
|
142
|
+
const dev = `refs/heads/x ${SHA_A} refs/heads/develop ${SHA_B}`;
|
|
143
|
+
assert.equal(parkOnly(`${park}\n`), true);
|
|
144
|
+
assert.equal(parkOnly(`(delete) ${'0'.repeat(40)} refs/xema-session/lead/T1/x ${SHA_A}\n`), true);
|
|
145
|
+
assert.equal(parkOnly(`${park}\n${dev}\n`), false);
|
|
146
|
+
assert.equal(parkOnly(`${dev}\n`), false);
|
|
147
|
+
assert.equal(parkOnly(`refs/heads/x ${SHA_A} refs/heads/main ${SHA_B}\n`), false);
|
|
148
|
+
assert.equal(parkOnly(`refs/heads/x ${SHA_A} refs/heads/xema-session/x ${SHA_B}\n`), false);
|
|
149
|
+
assert.equal(parkOnly(''), false);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test('a park-only push skips and says why; a park beside develop still grades', () => {
|
|
153
|
+
const r = repo({ typecheck: 'exit 1', lint: 'exit 1' });
|
|
154
|
+
try {
|
|
155
|
+
const parked = hook(r.dir, `refs/heads/x ${r.sha} refs/xema-session/lead/T1/x ${'0'.repeat(40)}\n`);
|
|
156
|
+
assert.equal(parked.status, 0, parked.stdout + parked.stderr);
|
|
157
|
+
assert.match(parked.stdout, /skipped — every ref is a session park/);
|
|
158
|
+
const mixed = hook(r.dir, `refs/heads/x ${r.sha} refs/xema-session/lead/T1/x ${'0'.repeat(40)}\nrefs/heads/x ${r.sha} refs/heads/develop ${'0'.repeat(40)}\n`);
|
|
159
|
+
assert.equal(mixed.status, 1, mixed.stdout + mixed.stderr);
|
|
160
|
+
assert.match(mixed.stdout, /pre-push: FAILED/);
|
|
161
|
+
} finally {
|
|
162
|
+
rmSync(r.dir, { recursive: true, force: true });
|
|
163
|
+
}
|
|
164
|
+
});
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// `prove`: the IN-IMAGE half of a per-service image (`xema-service-image`). The Dockerfile's
|
|
3
|
+
// proof stage runs it on the TARGET platform, `--network=none`, with the staged app and this
|
|
4
|
+
// tool bind-mounted, the stage manifest COPYed into <proof dir>, and the repository's root build
|
|
5
|
+
// inputs COPYed from the build context into <proof dir>/context:
|
|
6
|
+
//
|
|
7
|
+
// node <tool>/src/service-image-in-image.mjs prove <app dir> <proof dir> --service <service> --commit <sha>
|
|
8
|
+
//
|
|
9
|
+
// The image's content tag is computed from the build context, not from the stage, so the stage
|
|
10
|
+
// must be proved to be the build context's. `prove` refuses the image unless: the manifest names
|
|
11
|
+
// <service>; it was staged at <sha> (the BUILD_SHA the image is built for, so a stale stage
|
|
12
|
+
// cannot ship); the root build inputs in the build context (`ROOT_BUILD_INPUT`: the lockfile,
|
|
13
|
+
// the root manifest, the workspace, .npmrc, .pnpmfile.cjs, turbo.json, tsconfig*.json) are
|
|
14
|
+
// exactly the ones the stage hashed, which also proves the Dockerfile COPYs every one of them
|
|
15
|
+
// (and so the content tag covers them); this tool is the version the stage recorded; this
|
|
16
|
+
// process runs on the manifest's platform; the Node ABI is the stager's when the app carries
|
|
17
|
+
// Node addons; every package's addons load here (`process.dlopen`: an ELF for the right
|
|
18
|
+
// machine can still need a newer glibc than the image has), where a package that ships several
|
|
19
|
+
// builds needs one that loads; Prisma computes, HERE, the binaryTarget whose schema engine was
|
|
20
|
+
// staged, and that engine has the staged sha256 and reports the pinned version; dist/main.js
|
|
21
|
+
// exists. It then writes <proof dir>/verified.json, which the final stage ships, so the image
|
|
22
|
+
// cannot be built without the proof.
|
|
23
|
+
//
|
|
24
|
+
// Loads only `node:` builtins and relative modules that do too (Prisma's own modules are
|
|
25
|
+
// resolved from the app).
|
|
26
|
+
import { createHash } from 'node:crypto';
|
|
27
|
+
import { copyFileSync, existsSync, readFileSync, readdirSync, realpathSync } from 'node:fs';
|
|
28
|
+
import { createRequire } from 'node:module';
|
|
29
|
+
import { dirname, join, resolve } from 'node:path';
|
|
30
|
+
import { fileURLToPath } from 'node:url';
|
|
31
|
+
|
|
32
|
+
import { prismaEnginesDirectories, verifySchemaEngine } from './composed-image-prisma-engine.mjs';
|
|
33
|
+
import { currentPlatform } from './service-image-platform.mjs';
|
|
34
|
+
|
|
35
|
+
export const MANIFEST_FILE = 'xema-service-image.json';
|
|
36
|
+
export const MANIFEST_SCHEMA = 2;
|
|
37
|
+
export const VERIFIED_FILE = 'verified.json';
|
|
38
|
+
export const LOCKFILE = 'pnpm-lock.yaml';
|
|
39
|
+
/** The proof dir's subdirectory the Dockerfile COPYs the root build inputs into. */
|
|
40
|
+
export const CONTEXT_DIRECTORY = 'context';
|
|
41
|
+
/**
|
|
42
|
+
* Repository-root files that steer the runner's install and build, so they decide the staged
|
|
43
|
+
* bytes. The stage hashes every one present; the Dockerfile must COPY each into the proof
|
|
44
|
+
* context, which puts it in the content tag.
|
|
45
|
+
*/
|
|
46
|
+
export const ROOT_BUILD_INPUT = /^(pnpm-lock\.yaml|package\.json|pnpm-workspace\.yaml|\.npmrc|\.pnpmfile\.cjs|turbo\.json|tsconfig[^/]*\.json)$/;
|
|
47
|
+
export const TOOL_NAME = '@xemahq/repo-build-tooling';
|
|
48
|
+
const TOOL_DIRECTORY = join(dirname(fileURLToPath(import.meta.url)), '..');
|
|
49
|
+
|
|
50
|
+
export const sha256File = (file) => createHash('sha256').update(readFileSync(file)).digest('hex');
|
|
51
|
+
|
|
52
|
+
/** The version of the tool running this code (the stage's copy, in the image). */
|
|
53
|
+
export const ownToolVersion = () => JSON.parse(readFileSync(join(TOOL_DIRECTORY, 'package.json'), 'utf8')).version;
|
|
54
|
+
|
|
55
|
+
/** Load one Node addon the way `require` does. */
|
|
56
|
+
const dlopen = (file) => process.dlopen({ exports: {} }, file);
|
|
57
|
+
|
|
58
|
+
const getBinaryTarget = (enginesDir) =>
|
|
59
|
+
createRequire(join(enginesDir, 'package.json'))('@prisma/get-platform').getBinaryTargetForCurrentPlatform();
|
|
60
|
+
|
|
61
|
+
/** The problems that keep the root build inputs in `contextDir` from being the ones `inputs` recorded. */
|
|
62
|
+
function contextProblems(contextDir, inputs) {
|
|
63
|
+
const problems = [];
|
|
64
|
+
const carried = existsSync(contextDir) ? readdirSync(contextDir).sort() : [];
|
|
65
|
+
for (const name of Object.keys(inputs).sort()) {
|
|
66
|
+
if (!carried.includes(name)) {
|
|
67
|
+
problems.push(`${name} is a root build input of the stage, and the build context does not carry it: ` +
|
|
68
|
+
`COPY it into /proof/${CONTEXT_DIRECTORY}/ in the Dockerfile and admit it in its .dockerignore, so the content tag covers it`);
|
|
69
|
+
} else if (sha256File(join(contextDir, name)) !== inputs[name]) {
|
|
70
|
+
problems.push(`the build context's ${name} is not the one the stage was built from: re-stage`);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
for (const name of carried) {
|
|
74
|
+
if (!Object.hasOwn(inputs, name)) problems.push(`the build context carries ${name}, which the stage did not hash as a root build input`);
|
|
75
|
+
}
|
|
76
|
+
return problems;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Each package's addons (`natives`: package dir -> its target ELF files) must load: one per package. */
|
|
80
|
+
function loadProblems(appDir, natives, load) {
|
|
81
|
+
const problems = [];
|
|
82
|
+
for (const [owner, files] of Object.entries(natives)) {
|
|
83
|
+
const addons = files.filter((file) => file.endsWith('.node'));
|
|
84
|
+
const failures = [];
|
|
85
|
+
for (const addon of addons) {
|
|
86
|
+
try {
|
|
87
|
+
load(join(appDir, addon));
|
|
88
|
+
break;
|
|
89
|
+
} catch (error) {
|
|
90
|
+
failures.push(`${addon}: ${String(error.message).split('\n')[0]}`);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
if (addons.length > 0 && failures.length === addons.length) {
|
|
94
|
+
problems.push(`no Node addon of ${owner} loads on this image:\n${failures.map((f) => ` ${f}`).join('\n')}`);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return problems;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Check a staged app against its manifest and the build context, on this platform. Throws once,
|
|
102
|
+
* naming every failed check. `platform`, `nodeAbi`, `binaryTarget`, `version`, `load` and
|
|
103
|
+
* `toolVersion` are injectable for tests.
|
|
104
|
+
*/
|
|
105
|
+
export async function prove({
|
|
106
|
+
appDir,
|
|
107
|
+
proofDir,
|
|
108
|
+
service,
|
|
109
|
+
commit,
|
|
110
|
+
platform = currentPlatform(),
|
|
111
|
+
nodeAbi = process.versions.modules,
|
|
112
|
+
binaryTarget = getBinaryTarget,
|
|
113
|
+
version,
|
|
114
|
+
load = dlopen,
|
|
115
|
+
toolVersion = ownToolVersion(),
|
|
116
|
+
log = console.log,
|
|
117
|
+
}) {
|
|
118
|
+
const manifest = JSON.parse(readFileSync(join(proofDir, MANIFEST_FILE), 'utf8'));
|
|
119
|
+
if (manifest.schema !== MANIFEST_SCHEMA) {
|
|
120
|
+
throw new Error(`the stage manifest is schema ${manifest.schema}, this proof reads schema ${MANIFEST_SCHEMA}: re-stage with this tool`);
|
|
121
|
+
}
|
|
122
|
+
const problems = [];
|
|
123
|
+
if (manifest.service !== service) problems.push(`the stage is for '${manifest.service}', this build is for '${service}'`);
|
|
124
|
+
if (manifest.commit !== commit) problems.push(`the stage was built at ${manifest.commit}, this image is for ${commit}: re-stage`);
|
|
125
|
+
problems.push(...contextProblems(join(proofDir, CONTEXT_DIRECTORY), manifest.inputs));
|
|
126
|
+
if (manifest.tool?.name !== TOOL_NAME || manifest.tool?.version !== toolVersion) {
|
|
127
|
+
problems.push(`the stage was made by ${manifest.tool?.name}@${manifest.tool?.version}, this proof is ${TOOL_NAME}@${toolVersion}`);
|
|
128
|
+
}
|
|
129
|
+
const onPlatform = manifest.platform === platform;
|
|
130
|
+
if (!onPlatform) problems.push(`the stage is for ${manifest.platform}, this build runs on ${platform}`);
|
|
131
|
+
const addons = Object.values(manifest.natives).flat().filter((file) => file.endsWith('.node'));
|
|
132
|
+
const sameAbi = addons.length === 0 || manifest.nodeAbi === nodeAbi;
|
|
133
|
+
if (!sameAbi) {
|
|
134
|
+
problems.push(`the app carries Node addons (${addons.join(', ')}) built for Node ABI ${manifest.nodeAbi}, the image's Node is ABI ${nodeAbi}`);
|
|
135
|
+
}
|
|
136
|
+
if (onPlatform && sameAbi) problems.push(...loadProblems(appDir, manifest.natives, load));
|
|
137
|
+
if (!existsSync(join(appDir, 'dist', 'main.js'))) problems.push('dist/main.js does not exist');
|
|
138
|
+
|
|
139
|
+
const enginesDirs = existsSync(join(appDir, 'node_modules', '.pnpm')) ? prismaEnginesDirectories(appDir) : [];
|
|
140
|
+
if (manifest.prisma === null && enginesDirs.length > 0) problems.push('the app carries a prisma CLI, but the stage staged no schema engine');
|
|
141
|
+
if (manifest.prisma !== null && enginesDirs.length === 0) problems.push('the stage staged a schema engine, but the app carries no prisma CLI');
|
|
142
|
+
for (const enginesDir of manifest.prisma === null ? [] : enginesDirs) {
|
|
143
|
+
const target = await binaryTarget(enginesDir);
|
|
144
|
+
if (target !== manifest.prisma.binaryTarget) {
|
|
145
|
+
problems.push(`Prisma computes binaryTarget ${target} here, the stage staged ${manifest.prisma.binaryTarget}`);
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
const engine = join(enginesDir, `schema-engine-${target}`);
|
|
149
|
+
if (!existsSync(engine)) {
|
|
150
|
+
problems.push(`${engine} does not exist`);
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
if (sha256File(engine) !== manifest.prisma.sha256) {
|
|
154
|
+
problems.push(`${engine} is not the schema engine the stage fetched and verified (sha256 ${manifest.prisma.sha256})`);
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
157
|
+
try {
|
|
158
|
+
log(`prisma schema engine: ${engine} (${verifySchemaEngine(engine, manifest.prisma.enginesVersion, version)})`);
|
|
159
|
+
} catch (error) {
|
|
160
|
+
problems.push(error.message);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
if (problems.length > 0) throw new Error(`the staged ${manifest.service} is not this image's:\n${problems.map((p) => ` - ${p}`).join('\n')}`);
|
|
164
|
+
copyFileSync(join(proofDir, MANIFEST_FILE), join(proofDir, VERIFIED_FILE));
|
|
165
|
+
log(`proved ${service} at ${commit} on ${platform}`);
|
|
166
|
+
return manifest;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const USAGE = 'usage: service-image-in-image.mjs prove <app dir> <proof dir> --service <service> --commit <40-hex sha>';
|
|
170
|
+
|
|
171
|
+
export function parseProveArgs(argv) {
|
|
172
|
+
const [verb, appDir, proofDir, ...rest] = argv;
|
|
173
|
+
if (verb !== 'prove' || appDir === undefined || proofDir === undefined || rest.length !== 4) throw new Error(USAGE);
|
|
174
|
+
const options = {};
|
|
175
|
+
for (let i = 0; i < rest.length; i += 2) {
|
|
176
|
+
const name = { '--service': 'service', '--commit': 'commit' }[rest[i]];
|
|
177
|
+
if (name === undefined || Object.hasOwn(options, name)) throw new Error(USAGE);
|
|
178
|
+
options[name] = rest[i + 1];
|
|
179
|
+
}
|
|
180
|
+
if (!/^[a-z0-9@][a-z0-9@/._-]*$/.test(options.service ?? '')) throw new Error(`--service must name a workspace package\n${USAGE}`);
|
|
181
|
+
if (!/^[0-9a-f]{40}$/.test(options.commit ?? '')) throw new Error(`--commit must be the 40-hex commit the image is built for (BUILD_SHA)\n${USAGE}`);
|
|
182
|
+
return { appDir: resolve(appDir), proofDir: resolve(proofDir), service: options.service, commit: options.commit };
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// realpath both sides: Node resolves the entry module's symlinks, `process.argv[1]` keeps them.
|
|
186
|
+
const invokedPath = process.argv[1] ? realpathSync(process.argv[1]) : undefined;
|
|
187
|
+
if (invokedPath === fileURLToPath(import.meta.url)) {
|
|
188
|
+
Promise.resolve()
|
|
189
|
+
.then(() => prove(parseProveArgs(process.argv.slice(2))))
|
|
190
|
+
.catch((error) => {
|
|
191
|
+
console.error(error.message);
|
|
192
|
+
process.exit(1);
|
|
193
|
+
});
|
|
194
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
// The target platform of a per-service image (`xema-service-image`), and the proof that a
|
|
2
|
+
// staged tree holds native code for that platform only. Loads only `node:` builtins: the
|
|
3
|
+
// in-image `prove` step imports it from the bind-mounted tool directory.
|
|
4
|
+
import { closeSync, existsSync, openSync, readSync, readdirSync } from 'node:fs';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The image platforms this recipe builds, keyed by Docker's `TARGETARCH`. `node` is Node's
|
|
9
|
+
* `process.arch`, `elfMachine` the ELF `e_machine` of native code for it, `prismaBinaryTarget`
|
|
10
|
+
* the binaryTarget Prisma's get-platform computes inside the runtime base image
|
|
11
|
+
* (node:22-bookworm-slim + openssl 3.0). The in-image `prove` step re-computes the last one on
|
|
12
|
+
* the target and refuses the image when it differs, so a base image change cannot ship a
|
|
13
|
+
* schema engine Prisma would not use.
|
|
14
|
+
*/
|
|
15
|
+
export const IMAGE_PLATFORMS = Object.freeze({
|
|
16
|
+
amd64: Object.freeze({ node: 'x64', elfMachine: 62, prismaBinaryTarget: 'debian-openssl-3.0.x' }),
|
|
17
|
+
arm64: Object.freeze({ node: 'arm64', elfMachine: 183, prismaBinaryTarget: 'linux-arm64-openssl-3.0.x' }),
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
/** `linux/<arch>` → `<arch>`; refuses any platform this recipe does not build. */
|
|
21
|
+
export function platformArch(platform) {
|
|
22
|
+
const match = /^linux\/([a-z0-9]+)$/.exec(String(platform ?? ''));
|
|
23
|
+
if (match === null || !Object.hasOwn(IMAGE_PLATFORMS, match[1])) {
|
|
24
|
+
throw new Error(`--platform must be one of ${Object.keys(IMAGE_PLATFORMS).map((a) => `linux/${a}`).join(', ')}, got '${platform}'`);
|
|
25
|
+
}
|
|
26
|
+
return match[1];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** The `linux/<arch>` this Node process runs on (the in-image check of the target). */
|
|
30
|
+
export function currentPlatform(nodeArch = process.arch) {
|
|
31
|
+
const arch = Object.keys(IMAGE_PLATFORMS).find((key) => IMAGE_PLATFORMS[key].node === nodeArch);
|
|
32
|
+
if (arch === undefined) throw new Error(`Node arch '${nodeArch}' is not an image platform this recipe builds`);
|
|
33
|
+
return `linux/${arch}`;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const MACH_O = new Set([0xfeedface, 0xfeedfacf, 0xcefaedfe, 0xcffaedfe, 0xcafebabe]);
|
|
37
|
+
|
|
38
|
+
/** What the first bytes of a file say it is: `{ format: 'elf', machine }`, `{ format: 'mach-o' }`, `{ format: 'pe' }` or `undefined`. */
|
|
39
|
+
export function nativeFormat(header) {
|
|
40
|
+
if (header.length >= 20 && header[0] === 0x7f && header[1] === 0x45 && header[2] === 0x4c && header[3] === 0x46) {
|
|
41
|
+
const littleEndian = header[5] === 1;
|
|
42
|
+
return { format: 'elf', machine: littleEndian ? header.readUInt16LE(18) : header.readUInt16BE(18) };
|
|
43
|
+
}
|
|
44
|
+
if (header.length >= 4 && MACH_O.has(header.readUInt32BE(0))) return { format: 'mach-o' };
|
|
45
|
+
if (header.length >= 2 && header[0] === 0x4d && header[1] === 0x5a) return { format: 'pe' };
|
|
46
|
+
return undefined;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function readHeader(file) {
|
|
50
|
+
const fd = openSync(file, 'r');
|
|
51
|
+
try {
|
|
52
|
+
const buffer = Buffer.alloc(20);
|
|
53
|
+
const read = readSync(fd, buffer, 0, 20, 0);
|
|
54
|
+
return buffer.subarray(0, read);
|
|
55
|
+
} finally {
|
|
56
|
+
closeSync(fd);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** The directory of the nearest `package.json` at or above `dir`, stopping at `root` (`root` itself when none). */
|
|
61
|
+
function owningPackage(root, rel) {
|
|
62
|
+
const parts = rel.split('/').slice(0, -1);
|
|
63
|
+
for (let n = parts.length; n > 0; n -= 1) {
|
|
64
|
+
const candidate = parts.slice(0, n).join('/');
|
|
65
|
+
if (existsSync(join(root, candidate, 'package.json'))) return candidate;
|
|
66
|
+
}
|
|
67
|
+
return '.';
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Every native file under `root` for `arch` (relative, sorted), and the problems that keep the tree out
|
|
72
|
+
* of a linux/`arch` image. Native code is judged PER PACKAGE (the nearest `package.json`): a package may
|
|
73
|
+
* ship prebuilt binaries for several platforms and load the one for the platform it runs on
|
|
74
|
+
* (`@temporalio/core-bridge` carries `releases/<triple>/index.node`). A package is refused when it
|
|
75
|
+
* carries native code (an ELF object, a Mach-O, a `.node` addon; a PE header counts only on
|
|
76
|
+
* `.node`/`.exe`/`.dll`, as `MZ` also opens ordinary data) and NONE of it is an ELF for `arch`: then the
|
|
77
|
+
* binary it would load was built for another machine (a host build) and cannot run here.
|
|
78
|
+
* `natives` lists the ELF objects for `arch`; `byPackage` groups them by owning package (the in-image
|
|
79
|
+
* proof loads each package's addons on the target); `foreign` the other platforms' files a refused
|
|
80
|
+
* package did not cause. Symlinks are not followed.
|
|
81
|
+
*/
|
|
82
|
+
export function scanNatives(root, arch) {
|
|
83
|
+
const { elfMachine } = IMAGE_PLATFORMS[arch];
|
|
84
|
+
const packages = new Map(); // package dir -> { target: [], foreign: [] }
|
|
85
|
+
const record = (rel, isTarget, why) => {
|
|
86
|
+
const owner = owningPackage(root, rel);
|
|
87
|
+
if (!packages.has(owner)) packages.set(owner, { target: [], foreign: [] });
|
|
88
|
+
const entry = packages.get(owner);
|
|
89
|
+
if (isTarget) entry.target.push(rel);
|
|
90
|
+
else entry.foreign.push(`${rel}: ${why}`);
|
|
91
|
+
};
|
|
92
|
+
const walk = (dir, prefix) => {
|
|
93
|
+
for (const entry of readdirSync(dir, { withFileTypes: true }).sort((a, b) => (a.name < b.name ? -1 : 1))) {
|
|
94
|
+
const rel = prefix === '' ? entry.name : `${prefix}/${entry.name}`;
|
|
95
|
+
if (entry.isDirectory()) {
|
|
96
|
+
walk(join(dir, entry.name), rel);
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
if (!entry.isFile()) continue;
|
|
100
|
+
const kind = nativeFormat(readHeader(join(dir, entry.name)));
|
|
101
|
+
if (kind?.format === 'elf') {
|
|
102
|
+
if (kind.machine === elfMachine) record(rel, true);
|
|
103
|
+
else record(rel, false, `ELF e_machine ${kind.machine}, the linux/${arch} image needs ${elfMachine}`);
|
|
104
|
+
} else if (kind?.format === 'mach-o') {
|
|
105
|
+
record(rel, false, 'a Mach-O binary');
|
|
106
|
+
} else if (kind?.format === 'pe' && /\.(node|exe|dll)$/.test(entry.name)) {
|
|
107
|
+
record(rel, false, 'a Windows binary');
|
|
108
|
+
} else if (entry.name.endsWith('.node')) {
|
|
109
|
+
record(rel, false, 'a native addon that is not an ELF object');
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
};
|
|
113
|
+
walk(root, '');
|
|
114
|
+
const natives = [];
|
|
115
|
+
const byPackage = {};
|
|
116
|
+
const foreign = [];
|
|
117
|
+
const problems = [];
|
|
118
|
+
for (const [owner, { target, foreign: others }] of [...packages].sort(([a], [b]) => (a < b ? -1 : 1))) {
|
|
119
|
+
natives.push(...target);
|
|
120
|
+
if (target.length > 0) byPackage[owner] = [...target].sort();
|
|
121
|
+
if (target.length === 0) {
|
|
122
|
+
problems.push(`${owner} carries native code and none of it is for linux/${arch}:\n${others.map((o) => ` ${o}`).join('\n')}`);
|
|
123
|
+
} else {
|
|
124
|
+
foreign.push(...others.map((o) => o.slice(0, o.indexOf(': '))));
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
return { natives: natives.sort(), byPackage, foreign: foreign.sort(), problems };
|
|
128
|
+
}
|