@vzn/vx-reapi 0.0.0 → 0.0.485
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/LICENSE +21 -0
- package/README.md +502 -0
- package/index.ts +4 -0
- package/package.json +45 -6
- package/protos/build/bazel/remote/execution/v2/remote_execution.proto +2516 -0
- package/protos/build/bazel/semver/semver.proto +41 -0
- package/protos/google/api/annotations.proto +31 -0
- package/protos/google/api/client.proto +598 -0
- package/protos/google/api/field_behavior.proto +104 -0
- package/protos/google/api/http.proto +370 -0
- package/protos/google/api/launch_stage.proto +72 -0
- package/protos/google/bytestream/bytestream.proto +178 -0
- package/protos/google/longrunning/operations.proto +265 -0
- package/protos/google/rpc/code.proto +186 -0
- package/protos/google/rpc/status.proto +48 -0
- package/src/cache.ts +199 -0
- package/src/executor.ts +1805 -0
- package/src/index.ts +277 -0
- package/src/merkle.ts +867 -0
- package/src/wire.ts +1658 -0
package/src/index.ts
ADDED
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
// @vzn/vx-reapi — a vx `cache` plugin backed by any server speaking Bazel's
|
|
2
|
+
// Remote Execution API (NativeLink, BuildBuddy, Buildbarn, bazel-remote).
|
|
3
|
+
//
|
|
4
|
+
// Phase 1 is the remote CACHE only: artifacts live in the CAS, addressed
|
|
5
|
+
// through an ActionCache entry derived from the vx cache key. Remote
|
|
6
|
+
// EXECUTION (the `executor` capability) is a later phase — see
|
|
7
|
+
// docs/design/plugin-executor-reapi-2026-08.md.
|
|
8
|
+
//
|
|
9
|
+
// Imports core only through the public `@vzn/vx` specifier, like every other
|
|
10
|
+
// plugin, so nothing here depends on core's internal layout.
|
|
11
|
+
|
|
12
|
+
import { readFileSync } from 'node:fs'
|
|
13
|
+
import {
|
|
14
|
+
definePlugin,
|
|
15
|
+
LayeredCache,
|
|
16
|
+
type CacheLayer,
|
|
17
|
+
type TaskExecutor,
|
|
18
|
+
type VxPlugin,
|
|
19
|
+
UserError,
|
|
20
|
+
refuseUnknownOptions,
|
|
21
|
+
type PluginOptionKinds,
|
|
22
|
+
} from '@vzn/vx'
|
|
23
|
+
import { ReapiRemoteCache } from './cache.js'
|
|
24
|
+
import { reapiExecutor } from './executor.js'
|
|
25
|
+
import { ReapiClient, type ReapiOptions } from './wire.js'
|
|
26
|
+
|
|
27
|
+
// `ReapiRemoteCache` is public for a workspace that composes cache
|
|
28
|
+
// layers by hand; the wire, the Merkle encoders and the executor are
|
|
29
|
+
// internal (1.0 freezes what this file exports).
|
|
30
|
+
export { ReapiRemoteCache } from './cache.js'
|
|
31
|
+
export { type ReapiOptions } from './wire.js'
|
|
32
|
+
|
|
33
|
+
// The connection's wire form, not the plugin's: the plugin reads PEM FILES
|
|
34
|
+
// (`tlsCertificate`, …, checked as a pair) and warns through its own
|
|
35
|
+
// context, so `reapi({ onWarn })` was dropped without a word and
|
|
36
|
+
// `reapi({ tlsClientCertPem })` skipped the pair check. `ReapiRemoteCache`,
|
|
37
|
+
// composed by hand, takes them.
|
|
38
|
+
export interface ReapiPluginOptions extends Partial<
|
|
39
|
+
Omit<ReapiOptions, 'tlsCaPem' | 'tlsClientCertPem' | 'tlsClientKeyPem' | 'onWarn'>
|
|
40
|
+
> {
|
|
41
|
+
/**
|
|
42
|
+
* Client-side bound on one action, from the EXECUTING transition. See
|
|
43
|
+
* `ReapiExecutorOptions.executeTimeoutMs`; `exec.timeout` wins per task.
|
|
44
|
+
*/
|
|
45
|
+
executeTimeoutMs?: number
|
|
46
|
+
/**
|
|
47
|
+
* Bound on the time an action waits QUEUED before a worker starts it. See
|
|
48
|
+
* `ReapiExecutorOptions.queueTimeoutMs`; past it the task runs here.
|
|
49
|
+
*/
|
|
50
|
+
queueTimeoutMs?: number
|
|
51
|
+
/**
|
|
52
|
+
* Endpoint, or omit to read `VX_REAPI_ENDPOINT`. With neither the plugin
|
|
53
|
+
* DECLINES — a declared-but-unconfigured plugin costs nothing and must
|
|
54
|
+
* never fail a run.
|
|
55
|
+
*/
|
|
56
|
+
endpoint?: string
|
|
57
|
+
/**
|
|
58
|
+
* Contribute the `executor` capability too, so tasks RUN on the REAPI
|
|
59
|
+
* server rather than only caching there. Default false: remote execution
|
|
60
|
+
* changes where a user's build runs, which is not something a plugin should
|
|
61
|
+
* switch on merely by being configured for caching. `VX_REAPI_EXECUTE=1`
|
|
62
|
+
* also enables it.
|
|
63
|
+
*/
|
|
64
|
+
execute?: boolean
|
|
65
|
+
/** REAPI platform properties for remote execution (`container-image`, …). */
|
|
66
|
+
platform?: Record<string, string>
|
|
67
|
+
/** Concurrent remote tasks; becomes the scheduler's pool for this executor. */
|
|
68
|
+
capacity?: number
|
|
69
|
+
/**
|
|
70
|
+
* PEM file of the CA that signed the server's certificate, for a server
|
|
71
|
+
* behind a private CA. Falls back to `VX_REAPI_TLS_CERTIFICATE`. Bazel's
|
|
72
|
+
* `--tls_certificate`.
|
|
73
|
+
*/
|
|
74
|
+
tlsCertificate?: string
|
|
75
|
+
/**
|
|
76
|
+
* PEM files of a client certificate and its key, for a server that asks
|
|
77
|
+
* for mutual TLS (`VX_REAPI_TLS_CLIENT_CERTIFICATE` / `_KEY`). Bazel's
|
|
78
|
+
* `--tls_client_certificate` / `--tls_client_key`.
|
|
79
|
+
*/
|
|
80
|
+
tlsClientCertificate?: string
|
|
81
|
+
tlsClientKey?: string
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Declare in `vx.workspace.ts`; the local store is the floor beneath it and is
|
|
86
|
+
* read first, so the remote is asked only on a local miss:
|
|
87
|
+
*
|
|
88
|
+
* ```ts
|
|
89
|
+
* plugins: [reapi({ endpoint: 'grpcs://grpc.example.com:443' })]
|
|
90
|
+
* ```
|
|
91
|
+
*
|
|
92
|
+
* Declines when no endpoint is configured, so it is safe to leave declared.
|
|
93
|
+
*/
|
|
94
|
+
function connection(options: ReapiPluginOptions): ReapiOptions | undefined {
|
|
95
|
+
// `process.env`, not `Bun.env`, for core's reason (exec/sandbox-runtime.ts):
|
|
96
|
+
// an embedder that replaces the env object leaves `Bun.env` on the old one.
|
|
97
|
+
const from = options.endpoint !== undefined ? '`reapi({ endpoint })`' : 'VX_REAPI_ENDPOINT'
|
|
98
|
+
const endpoint = (options.endpoint ?? process.env['VX_REAPI_ENDPOINT'])?.trim()
|
|
99
|
+
if (endpoint === undefined || endpoint === '') return undefined
|
|
100
|
+
assertEndpoint(endpoint, from)
|
|
101
|
+
const instanceName = options.instanceName ?? process.env['VX_REAPI_INSTANCE']
|
|
102
|
+
// A server behind a private CA, or one that asks for a client
|
|
103
|
+
// certificate, was unreachable: TLS used the system roots and no client
|
|
104
|
+
// pair (F-41). Each is a PEM file, read here; one that cannot be read is
|
|
105
|
+
// a setting to fix, refused naming it.
|
|
106
|
+
const pem = (
|
|
107
|
+
option: string | undefined,
|
|
108
|
+
name: string,
|
|
109
|
+
fromEnv: string | undefined,
|
|
110
|
+
env: string,
|
|
111
|
+
): string | undefined => {
|
|
112
|
+
const file = option ?? fromEnv?.trim()
|
|
113
|
+
if (file === undefined || file === '') return undefined
|
|
114
|
+
try {
|
|
115
|
+
return readFileSync(file, 'utf8')
|
|
116
|
+
} catch (err) {
|
|
117
|
+
const from = option !== undefined ? `\`reapi({ ${name} })\`` : env
|
|
118
|
+
throw new UserError(
|
|
119
|
+
`vx/reapi: ${from} names ${file}, which cannot be read (${(err as NodeJS.ErrnoException).code ?? String(err)})`,
|
|
120
|
+
)
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
const tlsCaPem = pem(
|
|
124
|
+
options.tlsCertificate,
|
|
125
|
+
'tlsCertificate',
|
|
126
|
+
process.env['VX_REAPI_TLS_CERTIFICATE'],
|
|
127
|
+
'VX_REAPI_TLS_CERTIFICATE',
|
|
128
|
+
)
|
|
129
|
+
const tlsClientCertPem = pem(
|
|
130
|
+
options.tlsClientCertificate,
|
|
131
|
+
'tlsClientCertificate',
|
|
132
|
+
process.env['VX_REAPI_TLS_CLIENT_CERTIFICATE'],
|
|
133
|
+
'VX_REAPI_TLS_CLIENT_CERTIFICATE',
|
|
134
|
+
)
|
|
135
|
+
const tlsClientKeyPem = pem(
|
|
136
|
+
options.tlsClientKey,
|
|
137
|
+
'tlsClientKey',
|
|
138
|
+
process.env['VX_REAPI_TLS_CLIENT_KEY'],
|
|
139
|
+
'VX_REAPI_TLS_CLIENT_KEY',
|
|
140
|
+
)
|
|
141
|
+
// grpc-js refuses either one alone, with a message that names neither
|
|
142
|
+
// setting.
|
|
143
|
+
if ((tlsClientCertPem === undefined) !== (tlsClientKeyPem === undefined)) {
|
|
144
|
+
throw new UserError(
|
|
145
|
+
'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',
|
|
146
|
+
)
|
|
147
|
+
}
|
|
148
|
+
return {
|
|
149
|
+
...options,
|
|
150
|
+
endpoint,
|
|
151
|
+
...(instanceName === undefined ? {} : { instanceName }),
|
|
152
|
+
...(tlsCaPem === undefined ? {} : { tlsCaPem }),
|
|
153
|
+
...(tlsClientCertPem === undefined ? {} : { tlsClientCertPem }),
|
|
154
|
+
...(tlsClientKeyPem === undefined ? {} : { tlsClientKeyPem }),
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* A malformed endpoint failed each request on its own, far from the setting:
|
|
160
|
+
* `http://` failed the run with grpc's `Could not parse target name ""`, and
|
|
161
|
+
* `host:notaport` or a lone space degraded every request to a miss (F-14).
|
|
162
|
+
* Refused once, naming where it came from.
|
|
163
|
+
*/
|
|
164
|
+
function assertEndpoint(endpoint: string, from: string): void {
|
|
165
|
+
// A grpc-js resolver target (`unix:/run/cas.sock`, `dns:///host:443`) is
|
|
166
|
+
// grpc's to parse.
|
|
167
|
+
if (/^(unix|unix-abstract|dns|ipv4|ipv6):/.test(endpoint)) return
|
|
168
|
+
const target = endpoint.replace(/^(https?|grpcs?):\/\//, '')
|
|
169
|
+
const m = /^(\[[^\]]+\]|[^:/\s]+)(?::(\d+))?$/.exec(target)
|
|
170
|
+
const port = m?.[2] === undefined ? undefined : Number(m[2])
|
|
171
|
+
if (m === null || (port !== undefined && (port < 1 || port > 65_535))) {
|
|
172
|
+
throw new UserError(
|
|
173
|
+
`vx/reapi: ${from} is ${JSON.stringify(endpoint)}, which is not host[:port] (e.g. cache.example.com:443 or grpcs://cache.example.com)`,
|
|
174
|
+
)
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** Each option `ReapiPluginOptions` names, with its kind: derived from the type, so the two cannot drift. */
|
|
179
|
+
const REAPI_PLUGIN_KEYS: PluginOptionKinds<ReapiPluginOptions> = {
|
|
180
|
+
executeTimeoutMs: 'number',
|
|
181
|
+
queueTimeoutMs: 'number',
|
|
182
|
+
endpoint: 'string',
|
|
183
|
+
execute: 'boolean',
|
|
184
|
+
platform: 'object',
|
|
185
|
+
capacity: 'number',
|
|
186
|
+
tlsCertificate: 'string',
|
|
187
|
+
tlsClientCertificate: 'string',
|
|
188
|
+
tlsClientKey: 'string',
|
|
189
|
+
instanceName: 'string',
|
|
190
|
+
headers: 'object',
|
|
191
|
+
tls: 'boolean',
|
|
192
|
+
toolName: 'string',
|
|
193
|
+
toolVersion: 'string',
|
|
194
|
+
correlatedInvocationsId: 'string',
|
|
195
|
+
callTimeoutMs: 'number',
|
|
196
|
+
metaTimeoutMs: 'number',
|
|
197
|
+
chunkBytes: 'number',
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export function reapi(options: ReapiPluginOptions = {}): VxPlugin {
|
|
201
|
+
refuseUnknownOptions('reapi()', options, REAPI_PLUGIN_KEYS)
|
|
202
|
+
let executorClient: ReapiClient | undefined
|
|
203
|
+
let remoteCache: ReapiRemoteCache | undefined
|
|
204
|
+
return definePlugin(import.meta, {
|
|
205
|
+
async executor(ctx): Promise<TaskExecutor | undefined> {
|
|
206
|
+
const wanted = options.execute === true || process.env['VX_REAPI_EXECUTE'] === '1'
|
|
207
|
+
const conn = connection(options)
|
|
208
|
+
if (!wanted || conn === undefined) return undefined
|
|
209
|
+
executorClient = new ReapiClient({ ...conn, onWarn: (m) => ctx.warn(m) })
|
|
210
|
+
// Negotiate once: turns zstd transfer compression on when the server
|
|
211
|
+
// advertises it. The digest function stays SHA256 (see wire.negotiate).
|
|
212
|
+
// An unreachable or wrong endpoint surfaces here, and the raw gRPC
|
|
213
|
+
// string ("14 UNAVAILABLE … Resolution note:") names neither the
|
|
214
|
+
// plugin's setting nor anything to do about it. Core turns a throwing
|
|
215
|
+
// factory into a UserError and ABORTS — deliberately, an executor is
|
|
216
|
+
// load-bearing — so this is the message a user acts on.
|
|
217
|
+
try {
|
|
218
|
+
await executorClient.negotiate()
|
|
219
|
+
} catch (err) {
|
|
220
|
+
executorClient.close()
|
|
221
|
+
executorClient = undefined
|
|
222
|
+
const why = err instanceof Error ? err.message : String(err)
|
|
223
|
+
throw new Error(
|
|
224
|
+
`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`,
|
|
225
|
+
)
|
|
226
|
+
}
|
|
227
|
+
// A cache-only deployment (bazel-remote) advertises no execution
|
|
228
|
+
// capability. Offering it work would hang the run on a server that will
|
|
229
|
+
// never answer, so DECLINE loudly and let the local executor take over.
|
|
230
|
+
const caps = await executorClient.capabilities()
|
|
231
|
+
if (!caps.execEnabled) {
|
|
232
|
+
ctx.warn(
|
|
233
|
+
`vx/reapi: ${conn.endpoint} does not advertise remote execution (cache only) — tasks will run locally`,
|
|
234
|
+
)
|
|
235
|
+
executorClient.close()
|
|
236
|
+
executorClient = undefined
|
|
237
|
+
return undefined
|
|
238
|
+
}
|
|
239
|
+
return reapiExecutor(executorClient, {
|
|
240
|
+
...(options.platform === undefined ? {} : { platform: options.platform }),
|
|
241
|
+
...(options.capacity === undefined ? {} : { capacity: options.capacity }),
|
|
242
|
+
...(options.executeTimeoutMs === undefined
|
|
243
|
+
? {}
|
|
244
|
+
: { executeTimeoutMs: options.executeTimeoutMs }),
|
|
245
|
+
...(options.queueTimeoutMs === undefined ? {} : { queueTimeoutMs: options.queueTimeoutMs }),
|
|
246
|
+
warn: (m) => ctx.warn(m),
|
|
247
|
+
})
|
|
248
|
+
},
|
|
249
|
+
teardown(): void {
|
|
250
|
+
executorClient?.close()
|
|
251
|
+
executorClient = undefined
|
|
252
|
+
// The cache layer holds a client too, and `RemoteCacheLayer` has no
|
|
253
|
+
// close hook for core to call — `LayeredCache.close()` closes only the
|
|
254
|
+
// LOCAL handle. Closing it here is lifecycle hygiene rather than a
|
|
255
|
+
// measured fix: @grpc/grpc-js pools subchannels per target, so 20
|
|
256
|
+
// unclosed clients were MEASURED to share one connection. It matters
|
|
257
|
+
// for `vx watch`, which installs and tears down plugins once per
|
|
258
|
+
// re-run, and it keeps the resource owned by whoever created it.
|
|
259
|
+
remoteCache?.close()
|
|
260
|
+
remoteCache = undefined
|
|
261
|
+
},
|
|
262
|
+
cache(ctx): CacheLayer | undefined {
|
|
263
|
+
const conn = connection(options)
|
|
264
|
+
if (conn === undefined) return undefined
|
|
265
|
+
const remote = new ReapiRemoteCache({ ...conn, onWarn: (m) => ctx.warn(m) })
|
|
266
|
+
remoteCache = remote
|
|
267
|
+
// Compose over the local handle the host opened: reads try local, then
|
|
268
|
+
// remote (hydrating local on a remote hit); writes go local immediately
|
|
269
|
+
// and the remote upload drains in the background. All of that is core's
|
|
270
|
+
// LayeredCache — this plugin supplies only the wire.
|
|
271
|
+
return new LayeredCache(ctx.localCache, remote, {
|
|
272
|
+
policy: ctx.policy,
|
|
273
|
+
onRemoteError: (err) => ctx.warn(`vx/reapi: ${err.message}`),
|
|
274
|
+
})
|
|
275
|
+
},
|
|
276
|
+
})
|
|
277
|
+
}
|