@vzn/vx-reapi 0.0.635 → 0.0.637
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 +14 -7
- package/package.json +2 -2
- package/src/executor.ts +36 -10
- package/src/index.ts +8 -15
package/README.md
CHANGED
|
@@ -12,7 +12,7 @@ npm install -D @vzn/vx @vzn/vx-reapi # or: pnpm add -D -w · yarn add -D (-W o
|
|
|
12
12
|
```
|
|
13
13
|
|
|
14
14
|
```ts
|
|
15
|
-
// vx
|
|
15
|
+
// .vx/workspace.ts
|
|
16
16
|
import { defineWorkspace } from '@vzn/vx/config'
|
|
17
17
|
import { reapi } from '@vzn/vx-reapi'
|
|
18
18
|
|
|
@@ -22,6 +22,11 @@ export default defineWorkspace({
|
|
|
22
22
|
})
|
|
23
23
|
```
|
|
24
24
|
|
|
25
|
+
Off CI a run reads the remote and does not write it unless its policy names
|
|
26
|
+
the remote. Give the write credential only to the default branch's CI and a
|
|
27
|
+
read-only one to laptops and pull request jobs: the server's token is the
|
|
28
|
+
boundary (`docs/security.md` § Cache poisoning).
|
|
29
|
+
|
|
25
30
|
`ReapiRemoteCache` is the layer class behind `reapi()`, for a workspace that
|
|
26
31
|
composes cache layers by hand. The package exports `reapi`, `ReapiPluginOptions`,
|
|
27
32
|
`ReapiRemoteCache` and its `ReapiOptions`; the wire client, the Merkle
|
|
@@ -134,10 +139,11 @@ held to the git blob id the key folded for it. A file edited or removed
|
|
|
134
139
|
in between (an edit mid-run under `vx watch`) still runs, but the
|
|
135
140
|
execution is not recorded under a key that no longer describes it; the
|
|
136
141
|
record once replayed the edited outputs on every machine after the file
|
|
137
|
-
was restored. The
|
|
138
|
-
|
|
142
|
+
was restored. The input root holds exactly the files the key folded: core
|
|
143
|
+
keys no package file, so a project's `package.json` is in it only as a
|
|
144
|
+
listed input (`@vzn/vx-lockfile` lists it on every cached task).
|
|
139
145
|
|
|
140
|
-
This matters most under `--download=none`, where deferral leaves no
|
|
146
|
+
This matters most under `--cache-download=none`, where deferral leaves no
|
|
141
147
|
local cache entry behind, so vx's own probe misses on every later run
|
|
142
148
|
and the record is what makes the second run cheap. Records are checked
|
|
143
149
|
against the CAS first (`FindMissingBlobs`) — the action cache and the
|
|
@@ -187,7 +193,9 @@ generated files. Only what a declared glob names (or what sits under a
|
|
|
187
193
|
directory it names) is written back; the rest stays as it is on disk.
|
|
188
194
|
Writing all of it once put the worker's copy of the sources over the
|
|
189
195
|
user's, so an edit made while the action ran was lost. A directory a
|
|
190
|
-
literal glob names is written whole.
|
|
196
|
+
literal glob names is written whole. Where a write really lands, after
|
|
197
|
+
the result's own links are placed, must be declared too: a declared link
|
|
198
|
+
to `.git/hooks` and a declared directory under it wrote a hook.
|
|
191
199
|
|
|
192
200
|
## The existence probe confirms the artifact, not just the entry
|
|
193
201
|
|
|
@@ -408,8 +416,7 @@ A cache-only server (bazel-remote advertises `exec_enabled: false`) makes the
|
|
|
408
416
|
plugin **decline the executor with a warning** rather than submit work that
|
|
409
417
|
will never be answered. Only cacheable tasks are eligible — a task with no
|
|
410
418
|
`cache` block has no described inputs, so a worker would run it against an
|
|
411
|
-
empty input root. A cacheable task's root holds its declared input files
|
|
412
|
-
and its project's `package.json`.
|
|
419
|
+
empty input root. A cacheable task's root holds its declared input files.
|
|
413
420
|
|
|
414
421
|
Verified end-to-end against a live NativeLink scheduler + worker: input tree
|
|
415
422
|
uploaded, QUEUED → EXECUTING → COMPLETED streamed, stdout returned inline,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vzn/vx-reapi",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.637",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"description": "Bazel Remote Execution API plugin for vx — remote cache (ActionCache + CAS) against NativeLink, BuildBuddy, Buildbarn or bazel-remote.",
|
|
6
6
|
"keywords": [
|
|
@@ -39,7 +39,7 @@
|
|
|
39
39
|
"access": "public"
|
|
40
40
|
},
|
|
41
41
|
"peerDependencies": {
|
|
42
|
-
"@vzn/vx": "^0.0.
|
|
42
|
+
"@vzn/vx": "^0.0.637"
|
|
43
43
|
},
|
|
44
44
|
"repository": {
|
|
45
45
|
"type": "git",
|
package/src/executor.ts
CHANGED
|
@@ -802,14 +802,7 @@ export function reapiExecutor(client: ReapiClient, opts: ReapiExecutorOptions =
|
|
|
802
802
|
symlinkGrafts.push(...(g.symlinks ?? []))
|
|
803
803
|
}
|
|
804
804
|
|
|
805
|
-
// The key folds the project's package.json whether or not a glob
|
|
806
|
-
// lists it, so the worker sees it too: a `"type": "module"` changes
|
|
807
|
-
// what a bundler emits, and a tree without it ran as another task.
|
|
808
805
|
const expected = new Map(req.inputs.files.map((f) => [f.path, f.digest]))
|
|
809
|
-
const packageJson = projectRel === '' ? 'package.json' : `${projectRel}/package.json`
|
|
810
|
-
if (req.inputs.packageJsonDigest !== '' && !expected.has(packageJson)) {
|
|
811
|
-
expected.set(packageJson, req.inputs.packageJsonDigest)
|
|
812
|
-
}
|
|
813
806
|
const inputPaths = [...expected.keys(), ...localUpstreamPaths]
|
|
814
807
|
const tree = await buildInputTree({
|
|
815
808
|
expected,
|
|
@@ -1008,7 +1001,7 @@ export function reapiExecutor(client: ReapiClient, opts: ReapiExecutorOptions =
|
|
|
1008
1001
|
|
|
1009
1002
|
// A remote-only task's outputs stay remote — materialising node_modules
|
|
1010
1003
|
// onto the submitter's disk is precisely what `remote: 'only'` forbids.
|
|
1011
|
-
// `--download=none` defers the same transfer WITHOUT making it
|
|
1004
|
+
// `--cache-download=none` defers the same transfer WITHOUT making it
|
|
1012
1005
|
// permanent: the bytes stay in the CAS and core gets a closure to pull
|
|
1013
1006
|
// them if a locally-placed consumer turns out to need them.
|
|
1014
1007
|
// Started before the logs are read, not after: a server that keeps
|
|
@@ -1418,7 +1411,14 @@ export async function materialiseOutputs(
|
|
|
1418
1411
|
const rel = toPosix(path.relative(req.workspaceRoot, abs))
|
|
1419
1412
|
return declared.includes(rel) || matchers.some((m) => m.match(rel))
|
|
1420
1413
|
}
|
|
1421
|
-
|
|
1414
|
+
// A declared output, or a path under one a literal glob names whole.
|
|
1415
|
+
const underDeclared = (rel: string): boolean => {
|
|
1416
|
+
for (let r = rel; r !== '.' && r !== ''; r = path.posix.dirname(r)) {
|
|
1417
|
+
if (declared.includes(r) || matchers.some((m) => m.match(r))) return true
|
|
1418
|
+
}
|
|
1419
|
+
return false
|
|
1420
|
+
}
|
|
1421
|
+
const fence = new Fence(req.workspaceRoot, underDeclared)
|
|
1422
1422
|
// A file or link no declared glob names is neither fetched nor written:
|
|
1423
1423
|
// the workspace root was the only fence, so a result (or a record planted
|
|
1424
1424
|
// in the action cache) could write `.git/hooks/post-checkout` 0755 or
|
|
@@ -1435,6 +1435,7 @@ export async function materialiseOutputs(
|
|
|
1435
1435
|
const abs = fence.lexical(req.cwd, f.path)
|
|
1436
1436
|
await fence.dir(path.dirname(abs))
|
|
1437
1437
|
await makeDir(path.dirname(abs), created)
|
|
1438
|
+
await fence.landing(abs)
|
|
1438
1439
|
// Inlined only when it has bytes: an ActionResult read back through
|
|
1439
1440
|
// proto-loader (the execution-record replay) carries `contents` as an
|
|
1440
1441
|
// EMPTY Buffer on every file, and taking that as inline wrote each
|
|
@@ -1506,11 +1507,35 @@ class Fence {
|
|
|
1506
1507
|
private readonly checked = new Set<string>()
|
|
1507
1508
|
private readonly links: { abs: string; target: string }[] = []
|
|
1508
1509
|
private readonly root: string
|
|
1510
|
+
private readonly realDirs = new Map<string, Promise<string>>()
|
|
1509
1511
|
|
|
1510
|
-
constructor(
|
|
1512
|
+
constructor(
|
|
1513
|
+
root: string,
|
|
1514
|
+
/** A workspace-relative path the task declared as output, or under one. */
|
|
1515
|
+
private readonly declared: (rel: string) => boolean = () => true,
|
|
1516
|
+
) {
|
|
1511
1517
|
this.root = path.resolve(root)
|
|
1512
1518
|
}
|
|
1513
1519
|
|
|
1520
|
+
/**
|
|
1521
|
+
* Where a file write really lands, judged after the result's links are
|
|
1522
|
+
* placed: a declared link to `.git/hooks`, then a declared directory
|
|
1523
|
+
* under it, wrote an executable hook (both inside the root, so the
|
|
1524
|
+
* containment checks passed). The landing must itself be declared.
|
|
1525
|
+
*/
|
|
1526
|
+
async landing(abs: string): Promise<void> {
|
|
1527
|
+
this.realRoot ??= await realpath(this.root).catch(() => this.root)
|
|
1528
|
+
const dir = path.dirname(abs)
|
|
1529
|
+
let real = this.realDirs.get(dir)
|
|
1530
|
+
if (real === undefined) {
|
|
1531
|
+
real = realpath(dir)
|
|
1532
|
+
this.realDirs.set(dir, real)
|
|
1533
|
+
}
|
|
1534
|
+
const rel = toPosix(path.relative(this.realRoot, path.join(await real, path.basename(abs))))
|
|
1535
|
+
if (rel === '..' || rel.startsWith('../') || path.isAbsolute(rel) || !this.declared(rel))
|
|
1536
|
+
throw this.refuse(abs)
|
|
1537
|
+
}
|
|
1538
|
+
|
|
1514
1539
|
/** `rel` joined under `base`, refused when the join leaves the root. */
|
|
1515
1540
|
lexical(base: string, rel: string): string {
|
|
1516
1541
|
// A NUL reached the file system as a raw ERR_INVALID_ARG_VALUE.
|
|
@@ -1763,6 +1788,7 @@ async function materialiseTree(
|
|
|
1763
1788
|
missing(abs, f.digest.hash, abs)
|
|
1764
1789
|
return
|
|
1765
1790
|
}
|
|
1791
|
+
await fence.landing(abs)
|
|
1766
1792
|
await writeOutput(abs, bytes, created)
|
|
1767
1793
|
if (f.is_executable) await chmod(abs, 0o755)
|
|
1768
1794
|
// NodeProperties.unix_mode is authoritative when the server sent it.
|
package/src/index.ts
CHANGED
|
@@ -12,8 +12,7 @@
|
|
|
12
12
|
import { readFileSync } from 'node:fs'
|
|
13
13
|
import {
|
|
14
14
|
definePlugin,
|
|
15
|
-
|
|
16
|
-
type CacheLayer,
|
|
15
|
+
type RemoteCacheLayer,
|
|
17
16
|
type TaskExecutor,
|
|
18
17
|
type VxPlugin,
|
|
19
18
|
UserError,
|
|
@@ -84,7 +83,7 @@ export interface ReapiPluginOptions extends Partial<
|
|
|
84
83
|
}
|
|
85
84
|
|
|
86
85
|
/**
|
|
87
|
-
* Declare in
|
|
86
|
+
* Declare in `.vx/workspace.ts`; the local store is the floor beneath it and is
|
|
88
87
|
* read first, so the remote is asked only on a local miss:
|
|
89
88
|
*
|
|
90
89
|
* ```ts
|
|
@@ -257,7 +256,7 @@ export function reapi(options: ReapiPluginOptions = {}): VxPlugin {
|
|
|
257
256
|
executorClient?.close()
|
|
258
257
|
executorClient = undefined
|
|
259
258
|
// The cache layer holds a client too, and `RemoteCacheLayer` has no
|
|
260
|
-
// close hook for core to call —
|
|
259
|
+
// close hook for core to call — core closes only its
|
|
261
260
|
// LOCAL handle. Closing it here is lifecycle hygiene rather than a
|
|
262
261
|
// measured fix: @grpc/grpc-js pools subchannels per target, so 20
|
|
263
262
|
// unclosed clients were MEASURED to share one connection. It matters
|
|
@@ -266,19 +265,13 @@ export function reapi(options: ReapiPluginOptions = {}): VxPlugin {
|
|
|
266
265
|
remoteCache?.close()
|
|
267
266
|
remoteCache = undefined
|
|
268
267
|
},
|
|
269
|
-
cache(ctx):
|
|
268
|
+
cache(ctx): RemoteCacheLayer | undefined {
|
|
270
269
|
const conn = connection(options)
|
|
271
270
|
if (conn === undefined) return undefined
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
// and the remote upload drains in the background. All of that is core's
|
|
277
|
-
// LayeredCache — this plugin supplies only the wire.
|
|
278
|
-
return new LayeredCache(ctx.localCache, remote, {
|
|
279
|
-
policy: ctx.policy,
|
|
280
|
-
onRemoteError: (err) => ctx.warn(`vx/reapi: ${err.message}`),
|
|
281
|
-
})
|
|
271
|
+
// Only the wire: core layers it over the local store (reads try local,
|
|
272
|
+
// then remote, hydrating local on a hit; uploads drain in the background).
|
|
273
|
+
remoteCache = new ReapiRemoteCache({ ...conn, onWarn: (m) => ctx.warn(m) })
|
|
274
|
+
return remoteCache
|
|
282
275
|
},
|
|
283
276
|
})
|
|
284
277
|
}
|