@redpanda-data/docs-extensions-and-macros 5.30.0 → 5.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.
@@ -0,0 +1,447 @@
1
+ 'use strict'
2
+
3
+ const { spawnSync } = require('child_process')
4
+ const fs = require('fs')
5
+ const os = require('os')
6
+ const path = require('path')
7
+ const { getGitHubToken } = require('../../cli-utils/github-token')
8
+
9
+ /**
10
+ * rp_util's config schema JSON, for any streaming-enterprise ref (tag,
11
+ * branch, or SHA), built from source every time.
12
+ *
13
+ * This is the foundation, not a fallback: doc generation for work-in-progress
14
+ * branches depends on this working on its own, with no dependency on any
15
+ * pre-built artifact ever having been published for that ref. A pre-built
16
+ * cache in front of this (for tags) is a separate, optional speed layer --
17
+ * see the publish-rp-util-schema workflow -- and always falls through to
18
+ * this module when the cache misses.
19
+ *
20
+ * rp_util covers cluster scope (config::configuration) and everything that
21
+ * makes up broker scope: config::node_config plus the three standalone
22
+ * config_store classes that live alongside it (Pandaproxy, its Kafka
23
+ * client, and Schema Registry). Topic properties stay on the existing
24
+ * C++-source-parsing path (property_extractor.py) -- rp_util has no
25
+ * equivalent for them.
26
+ */
27
+
28
+ const STREAMING_ENTERPRISE_REPO = 'https://github.com/redpanda-data/streaming-enterprise.git'
29
+ const RP_UTIL_TARGET = '//src/v/rp_util:rp_util'
30
+ const RP_UTIL_BIN_RELPATH = path.join('bazel-bin', 'src', 'v', 'rp_util', 'rp_util')
31
+ // 24.04, not 22.04: Bazel's rules_foreign_cc bundles a prebuilt ninja binary
32
+ // requiring glibc >= 2.38, which Ubuntu 22.04 (glibc 2.35) doesn't have --
33
+ // confirmed via CMake's own "GLIBC_2.38 not found" failure trying to run it.
34
+ const DOCKER_LINUX_IMAGE = 'ubuntu:24.04'
35
+ // Named (not anonymous) volumes so Bazel's output/repository cache survives
36
+ // across separate `docker run --rm` invocations -- without this, every local
37
+ // macOS run pays a full cold Bazel build (boost, seastar, openssl, ...) under
38
+ // Rosetta emulation, which can take hours instead of minutes.
39
+ const DOCKER_BAZEL_CACHE_VOLUME = 'rp-util-bazel-cache'
40
+ const DOCKER_BAZELISK_CACHE_VOLUME = 'rp-util-bazelisk-cache'
41
+
42
+ // Every schema rp_util can dump, and the key each shows up under in
43
+ // getRpUtilSchema()'s return value. One flag per config_store class --
44
+ // see rp_util/main.cc.
45
+ const SCHEMA_FLAGS = [
46
+ { key: 'clusterSchema', flag: '--config_schema_json' },
47
+ { key: 'nodeSchema', flag: '--node_config_schema_json' },
48
+ { key: 'pandaproxySchema', flag: '--pandaproxy_config_schema_json' },
49
+ { key: 'kafkaClientSchema', flag: '--kafka_client_config_schema_json' },
50
+ { key: 'schemaRegistrySchema', flag: '--schema_registry_config_schema_json' }
51
+ ]
52
+
53
+ /**
54
+ * Shallow-clone streaming-enterprise at a ref into destDir. Tries a
55
+ * branch/tag-name clone first (fast, matches almost every real call); a ref
56
+ * that isn't a branch or tag name at HEAD (a bare SHA, for example) falls
57
+ * back to a full clone plus checkout.
58
+ * @param {string} ref - Branch, tag, or commit SHA
59
+ * @param {string} destDir - Destination directory (must not exist yet)
60
+ */
61
+ /**
62
+ * Strip a credential out of text before it's surfaced anywhere that gets
63
+ * logged -- an Error message ends up in plain CI logs. Covers a credential
64
+ * embedded in a Git remote URL's userinfo (e.g. https://<token>@github.com/...),
65
+ * which Git echoes back verbatim in common failures (a private repo it can't
66
+ * access, a bad ref, ...), and a Basic-auth header in case Git ever echoes
67
+ * failing config back -- defense in depth: this module keeps the token out
68
+ * of argv and URLs entirely (see gitAuthEnv).
69
+ */
70
+ function redactCredentials(text) {
71
+ return String(text || '')
72
+ .replace(/\/\/[^/@\s]+@/g, '//***@')
73
+ .replace(/(authorization:\s*basic\s+)\S+/gi, '$1***')
74
+ }
75
+
76
+ /**
77
+ * Build the environment that authenticates git's github.com requests via a
78
+ * per-invocation credential helper, injected through GIT_CONFIG_* env vars.
79
+ * No token byte ever appears in argv (readable by any local process via
80
+ * `ps`/`/proc/<pid>/cmdline` for the full multi-minute clone) or in a URL
81
+ * (echoed verbatim by git on common failures), and nothing is written into
82
+ * the resulting clone's .git/config, where it would otherwise sit readable
83
+ * on disk for the entire downstream Bazel/Docker build. Same env-only
84
+ * pattern the property-docs Makefile migration uses.
85
+ * @param {string} token
86
+ * @returns {object} env for spawnSync
87
+ */
88
+ function gitAuthEnv(token) {
89
+ return {
90
+ ...process.env,
91
+ RP_UTIL_FETCH_GIT_TOKEN: token,
92
+ GIT_CONFIG_COUNT: '2',
93
+ // Clear any inherited helpers first so a system credential manager
94
+ // can't intercept (or prompt) before ours answers.
95
+ GIT_CONFIG_KEY_0: 'credential.helper',
96
+ GIT_CONFIG_VALUE_0: '',
97
+ GIT_CONFIG_KEY_1: 'credential.https://github.com.helper',
98
+ // The helper reads the token from its own environment at callback time;
99
+ // argv carries only this static, secret-free string.
100
+ GIT_CONFIG_VALUE_1: '!f() { echo "username=x-access-token"; echo "password=$RP_UTIL_FETCH_GIT_TOKEN"; }; f'
101
+ }
102
+ }
103
+
104
+ function cloneStreamingEnterprise(ref, destDir) {
105
+ const token = getGitHubToken()
106
+ if (!token) {
107
+ throw new Error(
108
+ 'No GitHub token available to clone redpanda-data/streaming-enterprise (it is private).\n' +
109
+ 'Set GITHUB_TOKEN, REDPANDA_GITHUB_TOKEN, ACTIONS_BOT_TOKEN, or GIT_CREDENTIALS.'
110
+ )
111
+ }
112
+ // Plain (unauthenticated) URL -- auth travels only via the per-invocation
113
+ // credential-helper env below, so it's never in argv and never written to
114
+ // destDir/.git/config.
115
+ const repoUrl = STREAMING_ENTERPRISE_REPO
116
+ const authEnv = gitAuthEnv(token)
117
+
118
+ console.log(`Cloning streaming-enterprise@${ref} into ${destDir}...`)
119
+ const shallow = spawnSync('git', [
120
+ 'clone', '-q', '--depth', '1', '--branch', ref, repoUrl, destDir
121
+ ], { encoding: 'utf8', timeout: 300000, env: authEnv })
122
+
123
+ if (shallow.status === 0) return
124
+
125
+ // Not a branch/tag name at HEAD -- full clone, then checkout the ref directly.
126
+ fs.rmSync(destDir, { recursive: true, force: true })
127
+ console.log(`'${ref}' is not a branch/tag at HEAD; cloning full history to resolve it...`)
128
+ const full = spawnSync('git', ['clone', '-q', repoUrl, destDir], {
129
+ encoding: 'utf8', timeout: 600000, env: authEnv
130
+ })
131
+ if (full.status !== 0) {
132
+ throw new Error(`Failed to clone streaming-enterprise: ${redactCredentials(full.stderr)}`)
133
+ }
134
+ const checkout = spawnSync('git', ['checkout', '-q', ref], {
135
+ cwd: destDir, encoding: 'utf8', timeout: 60000
136
+ })
137
+ if (checkout.status !== 0) {
138
+ throw new Error(`Failed to checkout ref '${ref}' in streaming-enterprise: ${redactCredentials(checkout.stderr)}`)
139
+ }
140
+ }
141
+
142
+ function checkDockerAvailable() {
143
+ const check = spawnSync('docker', ['--version'], { encoding: 'utf8', timeout: 5000 })
144
+ if (check.status !== 0) {
145
+ throw new Error(
146
+ 'Docker is required to build rp_util (a Linux binary) on ' +
147
+ `${os.platform()}, but was not found.\n` +
148
+ 'Start Docker Desktop, or run this on a Linux host/CI runner instead ' +
149
+ '(no Docker needed there -- see publish-rp-util-schema.yaml).'
150
+ )
151
+ }
152
+ }
153
+
154
+ /**
155
+ * Build //src/v/rp_util:rp_util natively via Bazel. Linux only -- the
156
+ * hermetic LLVM toolchain this repo's .bazelrc configures targets Linux, and
157
+ * Seastar-based binaries don't build natively on macOS/arm64.
158
+ * @param {string} sourceDir - streaming-enterprise checkout root
159
+ */
160
+ function buildNative(sourceDir) {
161
+ const bazelCheck = spawnSync('bazel', ['--version'], { encoding: 'utf8', timeout: 15000 })
162
+ if (bazelCheck.status !== 0) {
163
+ throw new Error(
164
+ 'bazel (or bazelisk on PATH as `bazel`) is required but was not found.\n' +
165
+ 'In CI, set this up with bazel-contrib/setup-bazel before calling doc-tools; ' +
166
+ 'locally, install bazelisk: https://github.com/bazelbuild/bazelisk'
167
+ )
168
+ }
169
+
170
+ console.log('Building rp_util natively via Bazel...')
171
+ const build = spawnSync('bazel', ['build', '--lockfile_mode=off', RP_UTIL_TARGET], {
172
+ cwd: sourceDir, encoding: 'utf8', timeout: 1800000, maxBuffer: 50 * 1024 * 1024
173
+ })
174
+ if (build.status !== 0) {
175
+ throw new Error(`Failed to build rp_util: ${build.stderr}`)
176
+ }
177
+ return path.join(sourceDir, RP_UTIL_BIN_RELPATH)
178
+ }
179
+
180
+ /**
181
+ * Build //src/v/rp_util:rp_util inside a Linux Docker container, and run
182
+ * every schema dump inside that same container -- the binary itself is a
183
+ * Linux ELF that can't be exec'd directly on the host once the container
184
+ * exits, so unlike buildNative() this returns the parsed JSON directly
185
+ * rather than a binary path.
186
+ * @param {string} sourceDir - streaming-enterprise checkout root
187
+ * @returns {object} keyed by SCHEMA_FLAGS' `key`s
188
+ */
189
+ function buildAndRunInDocker(sourceDir) {
190
+ checkDockerAvailable()
191
+
192
+ // Match the container's CPU architecture to the host's. Forcing
193
+ // linux/amd64 on an Apple Silicon host makes Docker emulate x86_64 via
194
+ // Rosetta for every single instruction in a from-source C++ build (boost,
195
+ // seastar, openssl, ...) -- observed to turn a ~10-20min native build into
196
+ // a multi-hour one. CI itself builds natively on arm64 hardware; there's
197
+ // no reason a local arm64 Mac shouldn't too.
198
+ const dockerArch = os.arch() === 'arm64' ? 'arm64' : 'amd64'
199
+ const dockerPlatform = `linux/${dockerArch}`
200
+ const bazeliskAsset = `bazelisk-linux-${dockerArch}`
201
+
202
+ console.log(`Building and running rp_util in a ${dockerPlatform} ${DOCKER_LINUX_IMAGE} container...`)
203
+
204
+ const binPath = RP_UTIL_BIN_RELPATH.split(path.sep).join('/')
205
+ const dumpCommands = SCHEMA_FLAGS
206
+ .map(({ key, flag }) => `&& ./${binPath} ${flag} > /tmp/${key}.json`)
207
+ .join(' ')
208
+ const copyCommand = `&& cp ${SCHEMA_FLAGS.map(({ key }) => `/tmp/${key}.json`).join(' ')} /work/`
209
+
210
+ const command = [
211
+ // libatomic1: the hermetic LLVM toolchain's clang binary is linked
212
+ // against libatomic.so.1, which a bare ubuntu:22.04 image doesn't ship.
213
+ // build-essential + the autotools set below: several rules_foreign_cc
214
+ // genrules (liburing, krb5, ...) shell out straight to configure+make
215
+ // on PATH rather than through Bazel's own toolchain resolution, so they
216
+ // need a real host toolchain regardless of the hermetic LLVM one also
217
+ // being present. This list is deliberately generous -- CI builds in a
218
+ // purpose-built image (vtools, not checked out here) that already has
219
+ // all of this, so there's no local Dockerfile to read the exact list
220
+ // off of; better to over-install once than hit another 20-30min
221
+ // round-trip per missing tool.
222
+ 'apt-get update -qq && apt-get install -qq -y libatomic1 build-essential curl ca-certificates ' +
223
+ 'perl bison flex python3 pkg-config m4 autoconf automake libtool gawk texinfo',
224
+ '&& curl -fsSL -o /usr/local/bin/bazel',
225
+ `https://github.com/bazelbuild/bazelisk/releases/latest/download/${bazeliskAsset}`,
226
+ '&& chmod +x /usr/local/bin/bazel',
227
+ `&& bazel --output_user_root=/bazel-cache build --lockfile_mode=off ${RP_UTIL_TARGET}`,
228
+ dumpCommands,
229
+ copyCommand
230
+ ].join(' ')
231
+
232
+ const result = spawnSync('docker', [
233
+ 'run', '--rm', '--platform', dockerPlatform,
234
+ '-v', `${sourceDir}:/work`,
235
+ '-v', `${DOCKER_BAZEL_CACHE_VOLUME}:/bazel-cache`,
236
+ '-v', `${DOCKER_BAZELISK_CACHE_VOLUME}:/root/.cache/bazelisk`,
237
+ '-w', '/work',
238
+ DOCKER_LINUX_IMAGE,
239
+ 'bash', '-c', command
240
+ ], { encoding: 'utf8', timeout: 10800000, maxBuffer: 50 * 1024 * 1024 })
241
+
242
+ if (result.status !== 0) {
243
+ throw new Error(`Failed to build/run rp_util in Docker: ${result.stderr}`)
244
+ }
245
+
246
+ const schemas = {}
247
+ for (const { key } of SCHEMA_FLAGS) {
248
+ schemas[key] = JSON.parse(fs.readFileSync(path.join(sourceDir, `${key}.json`), 'utf8'))
249
+ }
250
+ return schemas
251
+ }
252
+
253
+ function runSchemaFlag(binaryPath, flag) {
254
+ const result = spawnSync(binaryPath, [flag], {
255
+ encoding: 'utf8', timeout: 60000, maxBuffer: 50 * 1024 * 1024
256
+ })
257
+ if (result.status !== 0) {
258
+ throw new Error(`rp_util ${flag} failed: ${result.stderr}`)
259
+ }
260
+ try {
261
+ return JSON.parse(result.stdout)
262
+ } catch (err) {
263
+ throw new Error(`rp_util ${flag} did not print valid JSON: ${err.message}`)
264
+ }
265
+ }
266
+
267
+ // Where publish-rp-util-schema.yaml uploads its release assets, and the
268
+ // mapping from each asset's filename back to the schema key rp_util_merge.py
269
+ // and this module's own callers key everything by.
270
+ const RP_UTIL_SCHEMA_RELEASE_REPO = 'redpanda-data/docs-extensions-and-macros'
271
+ const RELEASE_ASSET_TO_SCHEMA_KEY = {
272
+ 'cluster-config-schema.json': 'clusterSchema',
273
+ 'node-config-schema.json': 'nodeSchema',
274
+ 'pandaproxy-config-schema.json': 'pandaproxySchema',
275
+ 'kafka-client-config-schema.json': 'kafkaClientSchema',
276
+ 'schema-registry-config-schema.json': 'schemaRegistrySchema'
277
+ }
278
+
279
+ /**
280
+ * Fetch rp_util's schema from a previously published GitHub release
281
+ * (publish-rp-util-schema.yaml's rp-util-schema-<tag> release in this repo)
282
+ * instead of building rp_util from source. A real doc-generation run can't
283
+ * afford a from-source Bazel build (15-45 minutes observed) every time --
284
+ * this is the fast path that exists so it doesn't have to.
285
+ * @param {string} tag - The exact tag the release was published for (e.g. "v26.2.2")
286
+ * @returns {Promise<object|null>} Schemas keyed like SCHEMA_FLAGS, or null if
287
+ * no release exists for this tag (caller should fall back to building)
288
+ */
289
+ async function fetchPublishedSchema(tag) {
290
+ const token = getGitHubToken()
291
+ const headers = token ? { Authorization: `Bearer ${token}` } : {}
292
+
293
+ const releaseResp = await fetch(
294
+ `https://api.github.com/repos/${RP_UTIL_SCHEMA_RELEASE_REPO}/releases/tags/rp-util-schema-${tag}`,
295
+ { headers }
296
+ )
297
+ if (releaseResp.status === 404) return null
298
+ if (!releaseResp.ok) {
299
+ throw new Error(
300
+ `Failed to look up published rp_util schema release for ${tag}: ` +
301
+ `${releaseResp.status} ${releaseResp.statusText}`
302
+ )
303
+ }
304
+ const release = await releaseResp.json()
305
+
306
+ // A published release must carry all five assets, each a real schema
307
+ // payload, or it is not usable. Accepting whatever subset happens to be
308
+ // there looks like success while every missing scope silently keeps its
309
+ // Tree-sitter data -- the exact class of quiet wrong answer this whole
310
+ // rp_util path exists to remove. The publisher itself cannot produce a
311
+ // partial published release (`gh release create` uploads to a draft and
312
+ // publishes only once every asset is up, and the /releases/tags endpoint
313
+ // used above never returns drafts), but a hand-made backfill release, a
314
+ // manually deleted asset, or a future change to the publisher all can, so
315
+ // validate here rather than trusting the producer.
316
+ const assetsByName = new Map()
317
+ for (const asset of release.assets || []) {
318
+ assetsByName.set(asset.name, asset)
319
+ }
320
+ const missing = Object.keys(RELEASE_ASSET_TO_SCHEMA_KEY).filter(name => !assetsByName.has(name))
321
+ if (missing.length) {
322
+ console.warn(
323
+ `Published rp_util schema release rp-util-schema-${tag} is incomplete ` +
324
+ `(missing: ${missing.join(', ')}); ignoring it.`
325
+ )
326
+ return null
327
+ }
328
+
329
+ const schemas = {}
330
+ for (const [name, key] of Object.entries(RELEASE_ASSET_TO_SCHEMA_KEY)) {
331
+ const asset = assetsByName.get(name)
332
+ const assetResp = await fetch(asset.url, {
333
+ headers: { ...headers, Accept: 'application/octet-stream' }
334
+ })
335
+ if (!assetResp.ok) {
336
+ throw new Error(
337
+ `Failed to download published rp_util schema asset ${asset.name}: ${assetResp.status}`
338
+ )
339
+ }
340
+ let payload
341
+ try {
342
+ payload = await assetResp.json()
343
+ } catch (err) {
344
+ console.warn(
345
+ `Published rp_util schema asset ${name} in rp-util-schema-${tag} is not valid JSON ` +
346
+ `(${err.message}); ignoring the release.`
347
+ )
348
+ return null
349
+ }
350
+ // Every rp_util schema flag dumps a {"properties": {...}} object; an
351
+ // asset without one is a truncated or wrong-shaped upload, not a schema
352
+ // the merge can map.
353
+ if (!payload || typeof payload.properties !== 'object' || payload.properties === null) {
354
+ console.warn(
355
+ `Published rp_util schema asset ${name} in rp-util-schema-${tag} has no properties ` +
356
+ `object; ignoring the release.`
357
+ )
358
+ return null
359
+ }
360
+ schemas[key] = payload
361
+ }
362
+ return schemas
363
+ }
364
+
365
+ /**
366
+ * Get every schema rp_util can dump (cluster, plus everything that makes up
367
+ * broker scope) for a streaming-enterprise ref.
368
+ *
369
+ * Tries a previously published release first (fast: a few small downloads)
370
+ * and only falls back to building rp_util from source (slow: a from-scratch
371
+ * Bazel build) when no published release exists for this exact ref, or when
372
+ * the caller explicitly asks to skip that check.
373
+ * @param {string} ref - Branch, tag, or commit SHA in redpanda-data/streaming-enterprise
374
+ * @param {object} [options]
375
+ * @param {boolean} [options.preferPublished] - Try fetchPublishedSchema(ref) first.
376
+ * Default true. Set false to always build from source (e.g. `ref` isn't a
377
+ * released tag, or the caller explicitly wants a from-source build).
378
+ * @param {string} [options.sourcePath] - Use an existing local streaming-enterprise
379
+ * checkout instead of cloning (caller is responsible for it being at `ref`).
380
+ * Implies building from source -- skips the published-release check.
381
+ * @param {boolean} [options.keepSource] - Don't delete a clone this function made
382
+ * @returns {Promise<{clusterSchema: object, nodeSchema: object, pandaproxySchema: object,
383
+ * kafkaClientSchema: object, schemaRegistrySchema: object, sourcePath: string|null}>}
384
+ */
385
+ async function getRpUtilSchema(ref, options = {}) {
386
+ const { sourcePath, keepSource = false, preferPublished = true } = options
387
+
388
+ // streaming-enterprise release/RC tags are v-prefixed, and schema releases
389
+ // are named rp-util-schema-<v-prefixed tag>. A v-less release-shaped ref
390
+ // ("26.2.2") can neither be cloned nor have a schema release under its
391
+ // verbatim name, so normalize it here -- otherwise every layer downstream
392
+ // (release lookup, clone, the publish workflow an error message points at)
393
+ // fails or no-ops on a name that can never exist.
394
+ if (/^\d+\.\d+\.\d+(-rc\d+)?$/.test(ref)) {
395
+ ref = `v${ref}`
396
+ }
397
+
398
+ if (!sourcePath && preferPublished) {
399
+ const published = await fetchPublishedSchema(ref)
400
+ if (published) {
401
+ return { ...published, sourcePath: null }
402
+ }
403
+ console.log(`No published rp_util schema release for ${ref}; building from source...`)
404
+ }
405
+
406
+ let sourceDir = sourcePath
407
+ let ownsClone = false
408
+ if (!sourceDir) {
409
+ sourceDir = fs.mkdtempSync(path.join(os.tmpdir(), 'rp-util-source-'))
410
+ ownsClone = true
411
+ try {
412
+ cloneStreamingEnterprise(ref, sourceDir)
413
+ } catch (err) {
414
+ fs.rmSync(sourceDir, { recursive: true, force: true })
415
+ throw err
416
+ }
417
+ }
418
+
419
+ try {
420
+ let schemas
421
+ if (os.platform() === 'linux') {
422
+ const binaryPath = buildNative(sourceDir)
423
+ schemas = {}
424
+ for (const { key, flag } of SCHEMA_FLAGS) {
425
+ schemas[key] = runSchemaFlag(binaryPath, flag)
426
+ }
427
+ } else {
428
+ schemas = buildAndRunInDocker(sourceDir)
429
+ }
430
+ return { ...schemas, sourcePath: sourceDir }
431
+ } finally {
432
+ if (ownsClone && !keepSource) {
433
+ fs.rmSync(sourceDir, { recursive: true, force: true })
434
+ }
435
+ }
436
+ }
437
+
438
+ module.exports = {
439
+ getRpUtilSchema,
440
+ SCHEMA_FLAGS,
441
+ // exported for testing
442
+ cloneStreamingEnterprise,
443
+ buildNative,
444
+ buildAndRunInDocker,
445
+ runSchemaFlag,
446
+ fetchPublishedSchema
447
+ }