@vzn/vx-reapi 0.0.638 → 0.0.640

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 CHANGED
@@ -39,7 +39,9 @@ one) the plugin **declines** and costs nothing, so it is
39
39
  safe to leave declared. An endpoint that is not `host[:port]`, with an
40
40
  optional `grpc(s)://` or `http(s)://` scheme, or a gRPC resolver target
41
41
  (`unix:`, `unix-abstract:`, `dns:`, `ipv4:`, `ipv6:`), is refused at startup
42
- with a line naming the setting. `VX_REAPI_ENDPOINT` / `VX_REAPI_INSTANCE` configure it
42
+ with a line naming the setting. So is a TLS endpoint at an IP address:
43
+ Bun's TLS refuses an IP as the server name, so name the server by host
44
+ name, or use `grpc://` for plaintext. `VX_REAPI_ENDPOINT` / `VX_REAPI_INSTANCE` configure it
43
45
  from the environment, and `VX_REAPI_EXECUTE=1` turns on remote execution the
44
46
  way `execute: true` does (off by default: a plugin must not move where a
45
47
  build runs merely by being configured for caching). `execute` is a
@@ -199,7 +201,7 @@ to `.git/hooks` and a declared directory under it wrote a hook.
199
201
 
200
202
  ## The existence probe confirms the artifact, not just the entry
201
203
 
202
- `has()` — what `vx run --dry` and `--graph` use to predict hit vs miss —
204
+ `has()` — what `vx run --dry` uses to predict hit vs miss —
203
205
  reads the ActionCache entry AND checks the artifact blob is still in the
204
206
  CAS. The second call is not redundant, because servers disagree: measured
205
207
  against both, bazel-remote validates an ActionResult's referenced blobs
@@ -439,15 +441,15 @@ the packages a build needs. The answer is the design doc's §7.4 recipe,
439
441
  ```ts
440
442
  install: {
441
443
  exec: { command: 'pnpm install --frozen-lockfile', remote: 'only' },
444
+ inputs: { files: ['package.json', 'pnpm-lock.yaml'] },
442
445
  cache: {
443
- inputs: { files: ['package.json', 'pnpm-lock.yaml'] },
444
446
  outputs: { files: ['node_modules/**'] },
445
447
  },
446
448
  },
447
449
  build: {
448
450
  dependsOn: ['install'],
449
451
  exec: { command: 'tsc -p .' },
450
- cache: { inputs: { files: ['src/**'] }, outputs: { files: ['dist/**'] } },
452
+ inputs: { files: ['src/**'] }, cache: { outputs: { files: ['dist/**'] } },
451
453
  },
452
454
  ```
453
455
 
@@ -485,7 +487,7 @@ sorted by name — the proto requires that, so equivalent Commands hash alike:
485
487
  - **`exec.env.define`** — literal `name: value` pairs from the task config.
486
488
  They read the same on every machine, so they are safe to put into the
487
489
  action identity, and they are already in the vx cache key.
488
- - **`cache.inputs.env`** — the values this machine resolved for those names,
490
+ - **`inputs.env`** — the values this machine resolved for those names,
489
491
  for each name the task's local child would get too (it is also in
490
492
  `exec.env.passThrough`). They are in the vx cache key by definition, so a
491
493
  change to one already produces a different action. A name unset here, or
@@ -509,6 +511,6 @@ the worker has its own. Two reasons, and both matter:
509
511
  can name its digest.
510
512
 
511
513
  So a task that needs a value on a worker must `define` it (config literal) or
512
- list it in both `cache.inputs.env` and `exec.env.passThrough` (host value,
514
+ list it in both `inputs.env` and `exec.env.passThrough` (host value,
513
515
  keyed, and seen by a local run the same way). A task whose command reads
514
516
  a `passThrough` secret is one to keep local with `exec: { remote: false }`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vzn/vx-reapi",
3
- "version": "0.0.638",
3
+ "version": "0.0.640",
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.638"
42
+ "@vzn/vx": "^0.0.640"
43
43
  },
44
44
  "repository": {
45
45
  "type": "git",
package/src/cache.ts CHANGED
@@ -165,6 +165,15 @@ export class ReapiRemoteCache {
165
165
  * stream.
166
166
  */
167
167
  async put(hash: string, body: Blob, meta: { durationMs: number }): Promise<void> {
168
+ // An entry is immutable, and UpdateActionResult replaces one: the
169
+ // lookup runs beside the upload (CAS writes are idempotent), so a key
170
+ // stored already costs no round trip of its own and keeps its result.
171
+ // A lookup that fails writes, as before.
172
+ const action = actionDigestFor(hash)
173
+ const stored = this.client.getActionResult(action).then(
174
+ (r) => r?.output_files?.some((f) => f.path === ARTIFACT_PATH) === true,
175
+ () => false,
176
+ )
168
177
  // Read once, and those bytes hashed and sent, wherever `writeBlob` would
169
178
  // read the Blob whole anyway: a second writer of the key renames its
170
179
  // artifact over the file, and two reads sent its bytes under this one's
@@ -186,7 +195,8 @@ export class ReapiRemoteCache {
186
195
  const missing = await this.client.findMissingBlobs([digest])
187
196
  if (missing.length > 0) await this.client.writeBlob(digest, data ?? body)
188
197
  }
189
- await this.client.updateActionResult(actionDigestFor(hash), {
198
+ if (await stored) return
199
+ await this.client.updateActionResult(action, {
190
200
  exit_code: 0,
191
201
  output_files: [{ path: ARTIFACT_PATH, digest, is_executable: false }],
192
202
  stdout_raw: encodeDuration(meta.durationMs),
package/src/executor.ts CHANGED
@@ -1269,7 +1269,7 @@ export function globToOutputPath(glob: string): string {
1269
1269
  }
1270
1270
 
1271
1271
  /**
1272
- * The action's environment: `cache.inputs.env` (values read from THIS
1272
+ * The action's environment: `inputs.env` (values read from THIS
1273
1273
  * machine's environment, already folded into the cache key) plus
1274
1274
  * `exec.env.define` (literals from the task config). A define wins on
1275
1275
  * collision — it is the more explicit statement of intent. An `inputs.env`
package/src/index.ts CHANGED
@@ -10,6 +10,8 @@
10
10
  // plugin, so nothing here depends on core's internal layout.
11
11
 
12
12
  import { readFileSync } from 'node:fs'
13
+ import { isIP } from 'node:net'
14
+ import * as grpc from '@grpc/grpc-js'
13
15
  import {
14
16
  definePlugin,
15
17
  type RemoteCacheLayer,
@@ -21,7 +23,7 @@ import {
21
23
  } from '@vzn/vx'
22
24
  import { ReapiRemoteCache } from './cache.js'
23
25
  import { reapiExecutor } from './executor.js'
24
- import { ReapiClient, type ReapiOptions } from './wire.js'
26
+ import { ReapiClient, type ReapiOptions, usesTls } from './wire.js'
25
27
 
26
28
  // `ReapiRemoteCache` is public for a workspace that composes cache
27
29
  // layers by hand; the wire, the Merkle encoders and the executor are
@@ -149,6 +151,11 @@ function connection(options: ReapiPluginOptions): ReapiOptions | undefined {
149
151
  'vx/reapi: a client certificate and its key go together — set both tlsClientCertificate and tlsClientKey (VX_REAPI_TLS_CLIENT_CERTIFICATE / VX_REAPI_TLS_CLIENT_KEY), or neither',
150
152
  )
151
153
  }
154
+ if (usesTls({ endpoint, tls: options.tls, tlsCaPem, tlsClientCertPem }) && ipTarget(endpoint)) {
155
+ throw new UserError(
156
+ `vx/reapi: ${from} is ${JSON.stringify(endpoint)}, TLS to an IP address, which Bun cannot open (it refuses an IP as the TLS server name) — name the server by host name, or use grpc:// for plaintext`,
157
+ )
158
+ }
152
159
  return {
153
160
  ...options,
154
161
  endpoint,
@@ -179,6 +186,20 @@ function assertEndpoint(endpoint: string, from: string): void {
179
186
  }
180
187
  }
181
188
 
189
+ /**
190
+ * The endpoint names its server by IP address. grpc-js sends the host as
191
+ * the TLS server name, and Bun's TLS throws on an IP there (Node only
192
+ * warns), so every request failed and the remote was never reached.
193
+ */
194
+ function ipTarget(endpoint: string): boolean {
195
+ if (/^ipv[46]:/.test(endpoint)) return true
196
+ const host = endpoint
197
+ .replace(/^(https?|grpcs?|dns):\/*/, '')
198
+ .replace(/:\d+$/, '')
199
+ .replace(/^\[(.*)\]$/, '$1')
200
+ return isIP(host) !== 0
201
+ }
202
+
182
203
  /** Each option `ReapiPluginOptions` names, with its kind: derived from the type, so the two cannot drift. */
183
204
  const REAPI_PLUGIN_KEYS: PluginOptionKinds<ReapiPluginOptions> = {
184
205
  executeTimeoutMs: 'number',
@@ -226,6 +247,14 @@ export function reapi(options: ReapiPluginOptions = {}): VxPlugin {
226
247
  executorClient.close()
227
248
  executorClient = undefined
228
249
  const why = err instanceof Error ? err.message : String(err)
250
+ // A server that answered with a refusal was reached: the endpoint
251
+ // advice sent the user to check everything but the token.
252
+ const code = (err as { code?: unknown } | null)?.code
253
+ if (code === grpc.status.UNAUTHENTICATED || code === grpc.status.PERMISSION_DENIED) {
254
+ throw new Error(
255
+ `the REAPI server at ${conn.endpoint} refused the credentials for remote execution: ${why} — check the token in \`headers\`, or drop \`execute\` to use it as a cache only`,
256
+ )
257
+ }
229
258
  throw new Error(
230
259
  `cannot reach the REAPI server at ${conn.endpoint} for remote execution: ${why} — check the endpoint, that the server is running, and that this host can reach it, or drop \`execute\` to use it as a cache only`,
231
260
  )
package/src/wire.ts CHANGED
@@ -421,6 +421,24 @@ async function* messagesOf(
421
421
  if (carry.length > 0) yield carry
422
422
  }
423
423
 
424
+ /**
425
+ * TLS unless the endpoint says plaintext, as Bazel reads a schemeless
426
+ * `--remote_cache`; a unix socket stays plaintext.
427
+ */
428
+ export function usesTls(opts: {
429
+ endpoint: string
430
+ tls?: boolean | undefined
431
+ tlsCaPem?: string | undefined
432
+ tlsClientCertPem?: string | undefined
433
+ }): boolean {
434
+ return (
435
+ opts.tls ??
436
+ (opts.tlsCaPem !== undefined ||
437
+ opts.tlsClientCertPem !== undefined ||
438
+ !/^((http|grpc):\/\/|unix:|unix-abstract:)/.test(opts.endpoint))
439
+ )
440
+ }
441
+
424
442
  export class ReapiClient {
425
443
  private readonly svc: ServiceClients
426
444
  private readonly instance: string
@@ -453,13 +471,7 @@ export class ReapiClient {
453
471
  assertBunSupportsChunking()
454
472
  const pem = (text: string | undefined): Buffer | null =>
455
473
  text === undefined ? null : Buffer.from(text)
456
- // TLS unless the endpoint says plaintext, as Bazel reads a schemeless
457
- // `--remote_cache`; a unix socket stays plaintext.
458
- const tls =
459
- opts.tls ??
460
- (opts.tlsCaPem !== undefined ||
461
- opts.tlsClientCertPem !== undefined ||
462
- !/^((http|grpc):\/\/|unix:|unix-abstract:)/.test(opts.endpoint))
474
+ const tls = usesTls(opts)
463
475
  const target = opts.endpoint.replace(/^(https?|grpcs?):\/\//, '')
464
476
  this.svc = loadServices(
465
477
  target,