@redpanda-data/docs-extensions-and-macros 5.38.10 → 5.41.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/bin/doc-tools.js +9 -0
- package/cli-utils/install-test-dependencies.sh +39 -140
- package/cli-utils/rpk-cdn.js +549 -0
- package/package.json +1 -1
- package/tools/rpk-docs/rpk-docs-handler.js +26 -137
package/bin/doc-tools.js
CHANGED
|
@@ -76,6 +76,11 @@ programCli
|
|
|
76
76
|
* - Node.js and npm
|
|
77
77
|
* - Python 3.9 or higher
|
|
78
78
|
* - Docker (for some dependencies)
|
|
79
|
+
* - Network access to https://rpk.redpanda.com (rpk is downloaded from the
|
|
80
|
+
* rpk distribution CDN and checksum-verified; no GitHub token needed).
|
|
81
|
+
* Optional: RPK_VERSION=vX.Y.Z pins the rpk version. Without it, the
|
|
82
|
+
* newest GA is resolved from streaming-enterprise tags when a GitHub token
|
|
83
|
+
* is available, otherwise from latest/ on the CDN.
|
|
79
84
|
*/
|
|
80
85
|
programCli
|
|
81
86
|
.command('install-test-dependencies')
|
|
@@ -1358,6 +1363,10 @@ automation
|
|
|
1358
1363
|
* - A GitHub token (resolved from GIT_CREDENTIALS, REDPANDA_GITHUB_TOKEN, ACTIONS_BOT_TOKEN, GITHUB_TOKEN, VBOT_GITHUB_API_TOKEN, or GH_TOKEN, in that priority order) with
|
|
1359
1364
|
* access to redpanda-data/streaming-enterprise, which is private (not
|
|
1360
1365
|
* needed when --from-source points at an existing local checkout)
|
|
1366
|
+
* - --plugin refreshes need none of the above when the snapshot's rpk_version
|
|
1367
|
+
* is a published GA or RC tag: the rpk binary is downloaded from
|
|
1368
|
+
* https://rpk.redpanda.com and checksum-verified. Go, Git and the token are
|
|
1369
|
+
* only used as a fallback when the CDN has no build for that tag.
|
|
1361
1370
|
*/
|
|
1362
1371
|
automation
|
|
1363
1372
|
.command('rpk-docs')
|
|
@@ -414,166 +414,65 @@ ensure_dependencies_installed() {
|
|
|
414
414
|
log_info "Installing/checking dependencies for doc-tools CLI commands..."
|
|
415
415
|
ensure_dependencies_installed
|
|
416
416
|
|
|
417
|
-
# install_rpk installs Redpanda's rpk CLI into ~/.local/bin
|
|
417
|
+
# install_rpk installs Redpanda's rpk CLI into ~/.local/bin from the rpk
|
|
418
|
+
# distribution CDN (https://rpk.redpanda.com), adds it to PATH for the current
|
|
419
|
+
# and future sessions, and verifies the installation; returns 0 on success and
|
|
420
|
+
# non-zero on failure. Set RPK_VERSION=vX.Y.Z to pin a version. The URL layout,
|
|
421
|
+
# version resolution and checksum verification live in cli-utils/rpk-cdn.js so
|
|
422
|
+
# this script and the rpk docs generator share one implementation.
|
|
418
423
|
install_rpk() {
|
|
419
424
|
if command_exists rpk; then
|
|
420
425
|
log_info "rpk is already installed. Version information:"
|
|
421
426
|
rpk --version
|
|
422
427
|
return 0
|
|
423
428
|
fi
|
|
424
|
-
|
|
425
|
-
log_info "Installing rpk..."
|
|
426
|
-
|
|
427
|
-
# Detect OS and architecture
|
|
428
|
-
local os_name=$(uname -s)
|
|
429
|
-
local arch_name=$(uname -m)
|
|
430
|
-
|
|
431
|
-
# Map OS name to rpk release format
|
|
432
|
-
local rpk_os=""
|
|
433
|
-
case "$os_name" in
|
|
434
|
-
"Darwin")
|
|
435
|
-
rpk_os="darwin"
|
|
436
|
-
;;
|
|
437
|
-
"Linux")
|
|
438
|
-
rpk_os="linux"
|
|
439
|
-
;;
|
|
440
|
-
*)
|
|
441
|
-
log_warn "Unsupported operating system: $os_name"
|
|
442
|
-
log_warn "Please install rpk manually:"
|
|
443
|
-
log_warn "https://docs.redpanda.com/current/get-started/rpk-install/"
|
|
444
|
-
return 1
|
|
445
|
-
;;
|
|
446
|
-
esac
|
|
447
|
-
|
|
448
|
-
# Map architecture to rpk release format
|
|
449
|
-
local rpk_arch=""
|
|
450
|
-
case "$arch_name" in
|
|
451
|
-
"x86_64" | "amd64")
|
|
452
|
-
rpk_arch="amd64"
|
|
453
|
-
;;
|
|
454
|
-
"arm64" | "aarch64")
|
|
455
|
-
rpk_arch="arm64"
|
|
456
|
-
;;
|
|
457
|
-
*)
|
|
458
|
-
log_warn "Unsupported architecture: $arch_name"
|
|
459
|
-
log_warn "Please install rpk manually:"
|
|
460
|
-
log_warn "https://docs.redpanda.com/current/get-started/rpk-install/"
|
|
461
|
-
return 1
|
|
462
|
-
;;
|
|
463
|
-
esac
|
|
464
|
-
|
|
465
|
-
# rpk releases now publish to the private redpanda-data/streaming-enterprise,
|
|
466
|
-
# not the public redpanda-data/redpanda, so a token is required (same
|
|
467
|
-
# priority order as cli-utils/github-token.js's getGitHubToken(), minus
|
|
468
|
-
# GIT_CREDENTIALS, which is Antora's own credential store and doesn't
|
|
469
|
-
# apply to this standalone installer script).
|
|
470
|
-
local gh_token="${REDPANDA_GITHUB_TOKEN:-${ACTIONS_BOT_TOKEN:-${GITHUB_TOKEN:-${VBOT_GITHUB_API_TOKEN:-${GH_TOKEN:-}}}}}"
|
|
471
|
-
if [ -z "$gh_token" ]; then
|
|
472
|
-
log_warn "No GitHub token found (checked REDPANDA_GITHUB_TOKEN, ACTIONS_BOT_TOKEN, GITHUB_TOKEN, VBOT_GITHUB_API_TOKEN, GH_TOKEN)."
|
|
473
|
-
log_warn "rpk releases are now published to the private redpanda-data/streaming-enterprise repo and require authentication."
|
|
474
|
-
log_warn "Please install rpk manually:"
|
|
475
|
-
log_warn "https://docs.redpanda.com/current/get-started/rpk-install/"
|
|
476
|
-
return 1
|
|
477
|
-
fi
|
|
478
429
|
|
|
479
|
-
|
|
480
|
-
local releases_api="https://api.github.com/repos/redpanda-data/streaming-enterprise/releases/latest"
|
|
430
|
+
log_info "Installing rpk..."
|
|
481
431
|
|
|
482
|
-
|
|
432
|
+
local script_dir
|
|
433
|
+
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" && pwd)"
|
|
434
|
+
local tmp_dir
|
|
435
|
+
tmp_dir="$(mktemp -d)"
|
|
436
|
+
local rpk_bin
|
|
483
437
|
|
|
484
|
-
#
|
|
485
|
-
#
|
|
486
|
-
#
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
-H "Accept: application/vnd.github+json" \
|
|
492
|
-
"$releases_api" <<EOF
|
|
493
|
-
header = "Authorization: token ${gh_token}"
|
|
494
|
-
EOF
|
|
495
|
-
); then
|
|
496
|
-
log_warn "Failed to query the latest release from streaming-enterprise"
|
|
497
|
-
log_warn "Please install rpk manually:"
|
|
438
|
+
# node is always present: this script is launched by bin/doc-tools.js.
|
|
439
|
+
# Without RPK_VERSION the module resolves the latest release itself
|
|
440
|
+
# (GitHub tags when a token is available, otherwise latest/ on the CDN).
|
|
441
|
+
if ! rpk_bin=$(node "${script_dir}/rpk-cdn.js" install --dest "${tmp_dir}" ${RPK_VERSION:+--version "${RPK_VERSION}"}); then
|
|
442
|
+
rm -rf "${tmp_dir}"
|
|
443
|
+
log_warn "Could not install rpk from https://rpk.redpanda.com (see the message above)."
|
|
444
|
+
log_warn "Set RPK_VERSION=vX.Y.Z to pin a version, or install rpk manually:"
|
|
498
445
|
log_warn "https://docs.redpanda.com/current/get-started/rpk-install/"
|
|
499
446
|
return 1
|
|
500
447
|
fi
|
|
501
448
|
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
if [ -z "$rpk_url" ] || [ "$rpk_url" = "null" ]; then
|
|
507
|
-
log_warn "Latest streaming-enterprise release has no asset named ${rpk_filename}"
|
|
449
|
+
mkdir -p ~/.local/bin
|
|
450
|
+
if ! mv "${rpk_bin}" ~/.local/bin/rpk; then
|
|
451
|
+
rm -rf "${tmp_dir}"
|
|
452
|
+
log_warn "Failed to move rpk binary to ~/.local/bin/"
|
|
508
453
|
log_warn "Please install rpk manually:"
|
|
509
454
|
log_warn "https://docs.redpanda.com/current/get-started/rpk-install/"
|
|
510
455
|
return 1
|
|
511
456
|
fi
|
|
457
|
+
rm -rf "${tmp_dir}"
|
|
512
458
|
|
|
513
|
-
|
|
459
|
+
# Add to PATH for current session
|
|
460
|
+
export PATH=$HOME/.local/bin:$PATH
|
|
514
461
|
|
|
515
|
-
#
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
# backoff (1s, 2s, 4s, ...) to ride out transient GitHub/CDN and network
|
|
519
|
-
# errors, including rate limits that need time to clear. --retry-max-time
|
|
520
|
-
# caps the whole retry window so CI fails fast on persistent errors.
|
|
521
|
-
# Accept: application/octet-stream is required on the API asset url to
|
|
522
|
-
# receive the binary itself instead of its JSON metadata.
|
|
523
|
-
# Same as the release lookup above: the token travels on stdin, not argv.
|
|
524
|
-
if curl -fL --retry 5 --retry-all-errors \
|
|
525
|
-
--connect-timeout 30 --max-time 300 --retry-max-time 600 \
|
|
526
|
-
--config - \
|
|
527
|
-
-H "Accept: application/octet-stream" \
|
|
528
|
-
-o "$rpk_filename" "$rpk_url" <<EOF
|
|
529
|
-
header = "Authorization: token ${gh_token}"
|
|
530
|
-
EOF
|
|
531
|
-
then
|
|
532
|
-
if unzip "$rpk_filename" 2>/dev/null; then
|
|
533
|
-
mkdir -p ~/.local/bin
|
|
534
|
-
if mv rpk ~/.local/bin/ 2>/dev/null; then
|
|
535
|
-
rm "$rpk_filename"
|
|
536
|
-
|
|
537
|
-
# Add to PATH for current session
|
|
538
|
-
export PATH=$HOME/.local/bin:$PATH
|
|
539
|
-
|
|
540
|
-
# Add the target directory to PATH for future sessions
|
|
541
|
-
if ! grep -q 'export PATH=$HOME/.local/bin:$PATH' ~/.bashrc 2>/dev/null; then
|
|
542
|
-
echo 'export PATH=$HOME/.local/bin:$PATH' >> ~/.bashrc
|
|
543
|
-
fi
|
|
544
|
-
|
|
545
|
-
# Verify installation
|
|
546
|
-
if command_exists rpk; then
|
|
547
|
-
log_info "rpk has been installed successfully. Version information:"
|
|
548
|
-
rpk --version
|
|
549
|
-
return 0
|
|
550
|
-
else
|
|
551
|
-
log_warn "rpk installation may have failed. Please install manually:"
|
|
552
|
-
log_warn "https://docs.redpanda.com/current/get-started/rpk-install/"
|
|
553
|
-
return 1
|
|
554
|
-
fi
|
|
555
|
-
else
|
|
556
|
-
log_warn "Failed to move rpk binary to ~/.local/bin/"
|
|
557
|
-
rm -f "$rpk_filename" rpk 2>/dev/null
|
|
558
|
-
log_warn "Please install rpk manually:"
|
|
559
|
-
log_warn "https://docs.redpanda.com/current/get-started/rpk-install/"
|
|
560
|
-
return 1
|
|
561
|
-
fi
|
|
562
|
-
else
|
|
563
|
-
log_warn "Failed to unzip $rpk_filename (may not exist for ${rpk_os}-${rpk_arch})"
|
|
564
|
-
rm -f "$rpk_filename" 2>/dev/null
|
|
565
|
-
log_warn "Please install rpk manually:"
|
|
566
|
-
log_warn "https://docs.redpanda.com/current/get-started/rpk-install/"
|
|
567
|
-
return 1
|
|
568
|
-
fi
|
|
569
|
-
else
|
|
570
|
-
log_warn "Failed to download $rpk_url"
|
|
571
|
-
# A mid-transfer failure can leave a partial file behind even with -f.
|
|
572
|
-
rm -f "$rpk_filename" 2>/dev/null
|
|
573
|
-
log_warn "Please install rpk manually:"
|
|
574
|
-
log_warn "https://docs.redpanda.com/current/get-started/rpk-install/"
|
|
575
|
-
return 1
|
|
462
|
+
# Add the target directory to PATH for future sessions
|
|
463
|
+
if ! grep -q 'export PATH=$HOME/.local/bin:$PATH' ~/.bashrc 2>/dev/null; then
|
|
464
|
+
echo 'export PATH=$HOME/.local/bin:$PATH' >> ~/.bashrc
|
|
576
465
|
fi
|
|
466
|
+
|
|
467
|
+
# Verify installation
|
|
468
|
+
if command_exists rpk; then
|
|
469
|
+
log_info "rpk has been installed successfully. Version information:"
|
|
470
|
+
rpk --version
|
|
471
|
+
return 0
|
|
472
|
+
fi
|
|
473
|
+
log_warn "rpk installation may have failed. Please install manually:"
|
|
474
|
+
log_warn "https://docs.redpanda.com/current/get-started/rpk-install/"
|
|
475
|
+
return 1
|
|
577
476
|
}
|
|
578
477
|
|
|
579
478
|
# Install rpk for rpcn-connector-docs command
|
|
@@ -0,0 +1,549 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* rpk binary distribution on https://rpk.redpanda.com (DEVPROD-4723).
|
|
5
|
+
*
|
|
6
|
+
* rpk release binaries stopped shipping as GitHub Release assets in
|
|
7
|
+
* September 2026 and are served from an S3-backed CDN instead. This module
|
|
8
|
+
* is the single definition of that layout for every consumer in this
|
|
9
|
+
* package (the rpk docs generator and install-test-dependencies.sh):
|
|
10
|
+
*
|
|
11
|
+
* https://rpk.redpanda.com/v<tag>/rpk-<os>-<arch>.zip
|
|
12
|
+
* https://rpk.redpanda.com/v<tag>/rpk_<version>_checksums.txt (sha256sum format)
|
|
13
|
+
* https://rpk.redpanda.com/latest/... (newest GA, copied at promote)
|
|
14
|
+
*
|
|
15
|
+
* Both GA (vX.Y.Z) and RC (vX.Y.Z-rcN) tags are published under v<tag>/.
|
|
16
|
+
* No authentication is needed. The bucket has no listing, and a missing key
|
|
17
|
+
* answers HTTP 403 (AccessDenied), not 404, so "is this version published"
|
|
18
|
+
* is a request for its checksums file and both codes mean "not published".
|
|
19
|
+
*
|
|
20
|
+
* Runs as a CLI too (see usage() below) so the bash installer shares this
|
|
21
|
+
* code instead of carrying its own copy of the URL layout.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
const fs = require('fs')
|
|
25
|
+
const os = require('os')
|
|
26
|
+
const path = require('path')
|
|
27
|
+
const crypto = require('crypto')
|
|
28
|
+
const { spawnSync } = require('child_process')
|
|
29
|
+
|
|
30
|
+
const RPK_CDN_BASE = 'https://rpk.redpanda.com'
|
|
31
|
+
const RPK_CDN_LATEST_PREFIX = 'latest'
|
|
32
|
+
// GA and RC tags both have a build under v<tag>/
|
|
33
|
+
const RPK_RELEASE_TAG_RE = /^v\d+\.\d+\.\d+(-rc\d+)?$/
|
|
34
|
+
// Only GA tags are ever promoted to latest/
|
|
35
|
+
const RPK_GA_TAG_RE = /^v\d+\.\d+\.\d+$/
|
|
36
|
+
const RPK_OS_BY_PLATFORM = { darwin: 'darwin', linux: 'linux', win32: 'windows' }
|
|
37
|
+
const RPK_ARCH_BY_NODE_ARCH = { arm64: 'arm64', x64: 'amd64' }
|
|
38
|
+
// --platform/--arch are Node's own names (process.platform/process.arch), but
|
|
39
|
+
// the CLI asset spelling ("windows", "amd64") leaks into error messages, so
|
|
40
|
+
// callers reasonably try that spelling too. Accept both.
|
|
41
|
+
const PLATFORM_ALIASES = { windows: 'win32' }
|
|
42
|
+
const ARCH_ALIASES = { amd64: 'x64', x86_64: 'x64', aarch64: 'arm64' }
|
|
43
|
+
// S3 without ListBucket answers 403 for a missing key; a 404 would mean the
|
|
44
|
+
// same thing if the distribution ever changes.
|
|
45
|
+
const NOT_PUBLISHED_HTTP_CODES = new Set([403, 404])
|
|
46
|
+
|
|
47
|
+
const TAGS_OWNER = 'redpanda-data'
|
|
48
|
+
const TAGS_REPO = 'streaming-enterprise'
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Normalise a version to the tag shape the CDN publishes under.
|
|
52
|
+
* @param {string} version - "26.2.2", "v26.2.2" or "v26.3.0-rc1"
|
|
53
|
+
* @returns {string|null} "v"-prefixed tag, or null when the shape is not a release tag
|
|
54
|
+
*/
|
|
55
|
+
function normalizeRpkTag(version) {
|
|
56
|
+
if (typeof version !== 'string') return null
|
|
57
|
+
const trimmed = version.trim()
|
|
58
|
+
if (!trimmed) return null
|
|
59
|
+
const tag = trimmed.startsWith('v') ? trimmed : `v${trimmed}`
|
|
60
|
+
return RPK_RELEASE_TAG_RE.test(tag) ? tag : null
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Release asset name for a platform.
|
|
65
|
+
* @param {Object} [opts]
|
|
66
|
+
* @param {string} [opts.platform=process.platform]
|
|
67
|
+
* @param {string} [opts.arch=process.arch]
|
|
68
|
+
* @returns {string|null} e.g. "rpk-linux-amd64.zip", or null when unsupported
|
|
69
|
+
*/
|
|
70
|
+
function rpkAssetName({ platform = process.platform, arch = process.arch } = {}) {
|
|
71
|
+
const osName = RPK_OS_BY_PLATFORM[PLATFORM_ALIASES[platform] || platform]
|
|
72
|
+
const archName = RPK_ARCH_BY_NODE_ARCH[ARCH_ALIASES[arch] || arch]
|
|
73
|
+
if (!osName || !archName) return null
|
|
74
|
+
return `rpk-${osName}-${archName}.zip`
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* goreleaser checksums file name for a tag.
|
|
79
|
+
* @param {string} tag - e.g. "v26.2.2"
|
|
80
|
+
* @returns {string} e.g. "rpk_26.2.2_checksums.txt"
|
|
81
|
+
*/
|
|
82
|
+
function rpkChecksumsName(tag) {
|
|
83
|
+
return `rpk_${tag.replace(/^v/, '')}_checksums.txt`
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Build a CDN URL.
|
|
88
|
+
* @param {string} prefix - "v26.2.2" or "latest"
|
|
89
|
+
* @param {string} fileName
|
|
90
|
+
*/
|
|
91
|
+
function rpkCdnUrl(prefix, fileName) {
|
|
92
|
+
return `${RPK_CDN_BASE}/${prefix}/${fileName}`
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* All URLs for a tag on the current (or given) platform.
|
|
97
|
+
* @param {string} tag - normalised tag
|
|
98
|
+
* @param {Object} [opts] - platform/arch overrides (see rpkAssetName)
|
|
99
|
+
* @returns {{ zip: string, checksums: string, assetName: string, checksumsName: string }|null}
|
|
100
|
+
*/
|
|
101
|
+
function rpkCdnUrls(tag, opts = {}) {
|
|
102
|
+
const assetName = rpkAssetName(opts)
|
|
103
|
+
if (!assetName) return null
|
|
104
|
+
const checksumsName = rpkChecksumsName(tag)
|
|
105
|
+
return {
|
|
106
|
+
assetName,
|
|
107
|
+
checksumsName,
|
|
108
|
+
zip: rpkCdnUrl(tag, assetName),
|
|
109
|
+
checksums: rpkCdnUrl(tag, checksumsName)
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function isNotPublished(httpCode) {
|
|
114
|
+
return NOT_PUBLISHED_HTTP_CODES.has(httpCode)
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function parseHttpCode(stdout) {
|
|
118
|
+
const trimmed = String(stdout || '').trim()
|
|
119
|
+
return parseInt(trimmed.slice(-3), 10) || 0
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Download a URL to a file with curl.
|
|
124
|
+
*
|
|
125
|
+
* The HTTP status travels through -w '%{http_code}' rather than curl's exit
|
|
126
|
+
* code: with -f a 403 exits 22 on most builds but 56 on others, so callers
|
|
127
|
+
* gate on the status. --retry alone only retries transient errors (timeouts,
|
|
128
|
+
* 5xx, 429); --retry-all-errors is deliberately absent so a deterministic
|
|
129
|
+
* 403 for an unpublished version is not retried for half a minute.
|
|
130
|
+
*
|
|
131
|
+
* @param {string} url
|
|
132
|
+
* @param {string} dest - Output path; removed again on failure
|
|
133
|
+
* @param {Object} [opts]
|
|
134
|
+
* @param {number} [opts.maxTime=300] - curl --max-time in seconds
|
|
135
|
+
* @param {number} [opts.retries=3]
|
|
136
|
+
* @returns {{ ok: boolean, httpCode: number, status: number|null, stderr: string }}
|
|
137
|
+
*/
|
|
138
|
+
function curlToFile(url, dest, { maxTime = 300, retries = 3 } = {}) {
|
|
139
|
+
const result = spawnSync('curl', [
|
|
140
|
+
'-sSfL', '--retry', String(retries), '--retry-max-time', String(maxTime),
|
|
141
|
+
'--connect-timeout', '30', '--max-time', String(maxTime),
|
|
142
|
+
'-o', dest, '-w', '%{http_code}', url
|
|
143
|
+
], { encoding: 'utf8', timeout: (maxTime + 60) * 1000 })
|
|
144
|
+
|
|
145
|
+
const httpCode = parseHttpCode(result.stdout)
|
|
146
|
+
const ok = result.status === 0 && httpCode === 200
|
|
147
|
+
if (!ok) {
|
|
148
|
+
// A mid-transfer failure can leave a partial file behind even with -f
|
|
149
|
+
fs.rmSync(dest, { force: true })
|
|
150
|
+
}
|
|
151
|
+
return { ok, httpCode, status: result.status, stderr: (result.stderr || '').trim() }
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* HEAD a URL and return its HTTP status (0 when curl itself failed).
|
|
156
|
+
* @param {string} url
|
|
157
|
+
* @returns {number}
|
|
158
|
+
*/
|
|
159
|
+
function curlHead(url) {
|
|
160
|
+
const result = spawnSync('curl', [
|
|
161
|
+
'-sfIL', '--retry', '3', '--retry-max-time', '30',
|
|
162
|
+
'--connect-timeout', '15', '--max-time', '30',
|
|
163
|
+
'-o', os.devNull, '-w', '%{http_code}', url
|
|
164
|
+
], { encoding: 'utf8', timeout: 90000 })
|
|
165
|
+
return parseHttpCode(result.stdout)
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Find the expected sha256 for an asset in a sha256sum-format checksums file.
|
|
170
|
+
* @param {string} text - checksums file content
|
|
171
|
+
* @param {string} assetName
|
|
172
|
+
* @returns {string|null} lowercase hex digest, or null when the asset is not listed
|
|
173
|
+
*/
|
|
174
|
+
function parseChecksums(text, assetName) {
|
|
175
|
+
const line = String(text || '')
|
|
176
|
+
.split(/\r?\n/)
|
|
177
|
+
.map(l => l.trim())
|
|
178
|
+
.find(l => l.endsWith(assetName))
|
|
179
|
+
if (!line) return null
|
|
180
|
+
const [digest] = line.split(/\s+/)
|
|
181
|
+
return digest ? digest.toLowerCase() : null
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function sha256File(filePath) {
|
|
185
|
+
return crypto.createHash('sha256').update(fs.readFileSync(filePath)).digest('hex')
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Download and verify the rpk release binary for a tag.
|
|
190
|
+
*
|
|
191
|
+
* Returns null whenever the CDN has no complete build for the tag (HTTP
|
|
192
|
+
* 403/404 on the checksums file or the zip, network failure) so callers can
|
|
193
|
+
* fall back to a source build. Throws only for a published build that is
|
|
194
|
+
* wrong: a checksums file without this asset, a digest mismatch, or an
|
|
195
|
+
* archive that does not extract to an rpk binary.
|
|
196
|
+
*
|
|
197
|
+
* @param {string} version - Release tag or bare version (GA or RC)
|
|
198
|
+
* @param {string} destDir - Directory to download and extract into
|
|
199
|
+
* @param {Object} [opts]
|
|
200
|
+
* @param {string} [opts.platform=process.platform]
|
|
201
|
+
* @param {string} [opts.arch=process.arch]
|
|
202
|
+
* @param {{ log: Function, warn: Function }} [opts.log=console]
|
|
203
|
+
* @returns {string|null} Path to the extracted binary, or null when not published
|
|
204
|
+
*/
|
|
205
|
+
function downloadRpkBinary(version, destDir, { platform = process.platform, arch = process.arch, log = console } = {}) {
|
|
206
|
+
const tag = normalizeRpkTag(version)
|
|
207
|
+
if (!tag) {
|
|
208
|
+
log.warn(`'${version}' is not a release tag (expected vX.Y.Z or vX.Y.Z-rcN); no rpk build to download`)
|
|
209
|
+
return null
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const urls = rpkCdnUrls(tag, { platform, arch })
|
|
213
|
+
if (!urls) {
|
|
214
|
+
log.warn(`No rpk release asset for platform ${platform}/${arch}`)
|
|
215
|
+
return null
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// The checksums file first: it is small, it is uploaded together with the
|
|
219
|
+
// zips, and its absence is the cheapest "not published" signal.
|
|
220
|
+
const checksumsPath = path.join(destDir, urls.checksumsName)
|
|
221
|
+
const checksumsResult = curlToFile(urls.checksums, checksumsPath, { maxTime: 60 })
|
|
222
|
+
if (!checksumsResult.ok) {
|
|
223
|
+
if (isNotPublished(checksumsResult.httpCode)) {
|
|
224
|
+
log.warn(`rpk ${tag} is not published on ${RPK_CDN_BASE} (HTTP ${checksumsResult.httpCode} for ${urls.checksums})`)
|
|
225
|
+
return null
|
|
226
|
+
}
|
|
227
|
+
// Anything other than 403/404 is the CDN or the network failing, not a
|
|
228
|
+
// tag with no build. Returning null here would send the caller into a
|
|
229
|
+
// private source build over a registry blip, and the installer would exit
|
|
230
|
+
// 0 having installed something other than what was asked for.
|
|
231
|
+
throw new Error(`Could not fetch ${urls.checksums} (HTTP ${checksumsResult.httpCode}, curl exit ${checksumsResult.status}${checksumsResult.stderr ? `: ${checksumsResult.stderr}` : ''})`)
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
log.log(`Downloading ${urls.assetName} for ${tag} from ${RPK_CDN_BASE}...`)
|
|
235
|
+
const zipPath = path.join(destDir, urls.assetName)
|
|
236
|
+
const zipResult = curlToFile(urls.zip, zipPath, { maxTime: 300 })
|
|
237
|
+
if (!zipResult.ok) {
|
|
238
|
+
if (isNotPublished(zipResult.httpCode)) {
|
|
239
|
+
// Checksums present but zip missing: an upload in progress or a
|
|
240
|
+
// platform the release skipped. Fall back rather than fail.
|
|
241
|
+
log.warn(`rpk ${tag} has a checksums file but no ${urls.assetName} on ${RPK_CDN_BASE} (HTTP ${zipResult.httpCode} for ${urls.zip})`)
|
|
242
|
+
return null
|
|
243
|
+
}
|
|
244
|
+
throw new Error(`Could not download ${urls.zip} (HTTP ${zipResult.httpCode}, curl exit ${zipResult.status}${zipResult.stderr ? `: ${zipResult.stderr}` : ''})`)
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
const expected = parseChecksums(fs.readFileSync(checksumsPath, 'utf8'), urls.assetName)
|
|
248
|
+
if (!expected) {
|
|
249
|
+
throw new Error(`${urls.checksumsName} for ${tag} lists no entry for ${urls.assetName}; refusing to install an unverified binary`)
|
|
250
|
+
}
|
|
251
|
+
const actual = sha256File(zipPath)
|
|
252
|
+
if (expected !== actual) {
|
|
253
|
+
throw new Error(
|
|
254
|
+
`Checksum mismatch for ${urls.assetName} (${tag}):\n` +
|
|
255
|
+
` expected ${expected}\n actual ${actual}`
|
|
256
|
+
)
|
|
257
|
+
}
|
|
258
|
+
log.log('Checksum verified')
|
|
259
|
+
|
|
260
|
+
const unzipResult = spawnSync('unzip', ['-o', zipPath, '-d', destDir], {
|
|
261
|
+
encoding: 'utf8',
|
|
262
|
+
timeout: 60000
|
|
263
|
+
})
|
|
264
|
+
if (unzipResult.error) {
|
|
265
|
+
// spawnSync could not start unzip at all (typically ENOENT). That is a
|
|
266
|
+
// gap in this environment, not a bad archive, so leave the caller its
|
|
267
|
+
// source-build fallback and say why.
|
|
268
|
+
log.warn(`Could not run unzip to extract ${urls.assetName}: ${unzipResult.error.message}`)
|
|
269
|
+
return null
|
|
270
|
+
}
|
|
271
|
+
if (unzipResult.status !== 0) {
|
|
272
|
+
throw new Error(`Failed to extract ${urls.assetName}: ${unzipResult.stderr}`)
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
const binPath = path.join(destDir, 'rpk')
|
|
276
|
+
if (!fs.existsSync(binPath)) {
|
|
277
|
+
throw new Error(`Extracted archive did not contain an rpk binary: ${zipPath}`)
|
|
278
|
+
}
|
|
279
|
+
fs.chmodSync(binPath, 0o755)
|
|
280
|
+
return binPath
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Newest GA tag on streaming-enterprise that has a build on the CDN.
|
|
285
|
+
* Tags, not releases: RC tags never had a GitHub Release, and GA releases
|
|
286
|
+
* stopped being published with the S3 move.
|
|
287
|
+
*/
|
|
288
|
+
async function newestPublishedGaTagFromGitHub(octokit, probe, { platform, arch, log }) {
|
|
289
|
+
const semver = require('semver')
|
|
290
|
+
const refs = await octokit.paginate(octokit.rest.git.listMatchingRefs, {
|
|
291
|
+
owner: TAGS_OWNER,
|
|
292
|
+
repo: TAGS_REPO,
|
|
293
|
+
ref: 'tags/v',
|
|
294
|
+
per_page: 100
|
|
295
|
+
})
|
|
296
|
+
const gaTags = refs
|
|
297
|
+
.map(r => r.ref.replace(/^refs\/tags\//, ''))
|
|
298
|
+
.filter(t => RPK_GA_TAG_RE.test(t))
|
|
299
|
+
const versions = semver.rsort(gaTags.map(t => t.slice(1)))
|
|
300
|
+
// A tag can exist a few minutes before its build lands, so walk down a
|
|
301
|
+
// handful of candidates instead of trusting the newest tag blindly.
|
|
302
|
+
for (const v of versions.slice(0, 5)) {
|
|
303
|
+
const tag = `v${v}`
|
|
304
|
+
const urls = rpkCdnUrls(tag, { platform, arch })
|
|
305
|
+
const code = probe(urls.checksums)
|
|
306
|
+
if (code === 200) return tag
|
|
307
|
+
log.warn(`Newest tag ${tag} has no build on ${RPK_CDN_BASE} yet (HTTP ${code}); trying the previous GA`)
|
|
308
|
+
}
|
|
309
|
+
return null
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* Identify the version behind latest/ by downloading it and asking the
|
|
314
|
+
* binary. The CDN has no listing and no version marker, so this is the only
|
|
315
|
+
* tokenless way to learn what latest/ holds. Once the reported version names
|
|
316
|
+
* a tag, the already-downloaded zip is hashed against that tag's own
|
|
317
|
+
* checksums file: latest/ is supposed to be a byte copy of v<tag>/, so a
|
|
318
|
+
* mismatch means the reported version does not describe these bytes, and the
|
|
319
|
+
* tag is not trusted. The caller still re-downloads and re-verifies through
|
|
320
|
+
* downloadRpkBinary before installing anything, so this only gates whether
|
|
321
|
+
* the resolved tag is trustworthy, not the final install.
|
|
322
|
+
*/
|
|
323
|
+
function versionBehindLatest(probe, { platform, arch, log }) {
|
|
324
|
+
const assetName = rpkAssetName({ platform, arch })
|
|
325
|
+
if (!assetName) return null
|
|
326
|
+
const latestZipUrl = rpkCdnUrl(RPK_CDN_LATEST_PREFIX, assetName)
|
|
327
|
+
const code = probe(latestZipUrl)
|
|
328
|
+
if (code !== 200) {
|
|
329
|
+
log.warn(`${RPK_CDN_LATEST_PREFIX}/ is not populated on ${RPK_CDN_BASE} (HTTP ${code} for ${latestZipUrl})`)
|
|
330
|
+
return null
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'rpk-latest-'))
|
|
334
|
+
try {
|
|
335
|
+
const zipPath = path.join(tmpDir, assetName)
|
|
336
|
+
const dl = curlToFile(latestZipUrl, zipPath, { maxTime: 300 })
|
|
337
|
+
if (!dl.ok) {
|
|
338
|
+
log.warn(`Could not download ${latestZipUrl} (HTTP ${dl.httpCode})`)
|
|
339
|
+
return null
|
|
340
|
+
}
|
|
341
|
+
const unzip = spawnSync('unzip', ['-o', zipPath, '-d', tmpDir], { encoding: 'utf8', timeout: 60000 })
|
|
342
|
+
const binPath = path.join(tmpDir, 'rpk')
|
|
343
|
+
if (unzip.status !== 0 || !fs.existsSync(binPath)) {
|
|
344
|
+
log.warn(`Could not extract ${assetName} from ${RPK_CDN_LATEST_PREFIX}/`)
|
|
345
|
+
return null
|
|
346
|
+
}
|
|
347
|
+
fs.chmodSync(binPath, 0o755)
|
|
348
|
+
const ver = spawnSync(binPath, ['--version'], { encoding: 'utf8', timeout: 30000 })
|
|
349
|
+
const match = `${ver.stdout || ''}\n${ver.stderr || ''}`.match(/v\d+\.\d+\.\d+(-rc\d+)?/)
|
|
350
|
+
if (!match) {
|
|
351
|
+
log.warn(`Could not read a version from the ${RPK_CDN_LATEST_PREFIX}/ rpk binary`)
|
|
352
|
+
return null
|
|
353
|
+
}
|
|
354
|
+
const tag = match[0]
|
|
355
|
+
|
|
356
|
+
const checksumsName = rpkChecksumsName(tag)
|
|
357
|
+
const checksumsPath = path.join(tmpDir, checksumsName)
|
|
358
|
+
const checksumsResult = curlToFile(rpkCdnUrl(tag, checksumsName), checksumsPath, { maxTime: 60 })
|
|
359
|
+
if (!checksumsResult.ok) {
|
|
360
|
+
log.warn(`Could not verify ${RPK_CDN_LATEST_PREFIX}/ against ${tag}'s checksums (HTTP ${checksumsResult.httpCode}); not trusting it`)
|
|
361
|
+
return null
|
|
362
|
+
}
|
|
363
|
+
const expected = parseChecksums(fs.readFileSync(checksumsPath, 'utf8'), assetName)
|
|
364
|
+
const actual = sha256File(zipPath)
|
|
365
|
+
if (!expected || expected !== actual) {
|
|
366
|
+
log.warn(`${RPK_CDN_LATEST_PREFIX}/ does not match ${tag}'s published checksum for ${assetName}; not trusting it`)
|
|
367
|
+
return null
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
return tag
|
|
371
|
+
} finally {
|
|
372
|
+
fs.rmSync(tmpDir, { recursive: true, force: true })
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/**
|
|
377
|
+
* Resolve the tag to install when the caller wants "the latest rpk".
|
|
378
|
+
*
|
|
379
|
+
* Order:
|
|
380
|
+
* 1. RPK_VERSION in the environment (pin; invalid shapes throw)
|
|
381
|
+
* 2. With a GitHub token: newest GA tag on streaming-enterprise whose
|
|
382
|
+
* build exists on the CDN (verified before anything runs)
|
|
383
|
+
* 3. latest/ on the CDN, identified by running the binary once
|
|
384
|
+
* 4. throw with a message that names the RPK_VERSION escape hatch
|
|
385
|
+
*
|
|
386
|
+
* @param {Object} [opts]
|
|
387
|
+
* @param {Object} [opts.env=process.env]
|
|
388
|
+
* @param {Object} [opts.octokit] - Octokit instance; defaults to the shared client when a token exists
|
|
389
|
+
* @param {Function} [opts.probe=curlHead] - url -> http status, injectable for tests
|
|
390
|
+
* @param {string} [opts.platform], [opts.arch]
|
|
391
|
+
* @param {{ log: Function, warn: Function }} [opts.log=console]
|
|
392
|
+
* @returns {Promise<string>} normalised tag
|
|
393
|
+
*/
|
|
394
|
+
async function resolveLatestRpkTag({
|
|
395
|
+
env = process.env,
|
|
396
|
+
octokit,
|
|
397
|
+
probe = curlHead,
|
|
398
|
+
platform = process.platform,
|
|
399
|
+
arch = process.arch,
|
|
400
|
+
log = console
|
|
401
|
+
} = {}) {
|
|
402
|
+
if (env.RPK_VERSION) {
|
|
403
|
+
const pinned = normalizeRpkTag(env.RPK_VERSION)
|
|
404
|
+
if (!pinned) {
|
|
405
|
+
throw new Error(`RPK_VERSION='${env.RPK_VERSION}' is not a release tag; expected vX.Y.Z or vX.Y.Z-rcN`)
|
|
406
|
+
}
|
|
407
|
+
return pinned
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
const { getGitHubApiToken } = require('./github-token')
|
|
411
|
+
if (getGitHubApiToken()) {
|
|
412
|
+
const client = octokit || require('./octokit-client')
|
|
413
|
+
try {
|
|
414
|
+
const tag = await newestPublishedGaTagFromGitHub(client, probe, { platform, arch, log })
|
|
415
|
+
if (tag) return tag
|
|
416
|
+
} catch (err) {
|
|
417
|
+
log.warn(`Could not list ${TAGS_OWNER}/${TAGS_REPO} tags: ${err.message}`)
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
const fromLatest = versionBehindLatest(probe, { platform, arch, log })
|
|
422
|
+
if (fromLatest) return fromLatest
|
|
423
|
+
|
|
424
|
+
throw new Error(
|
|
425
|
+
`Could not resolve the latest rpk release: ${RPK_CDN_LATEST_PREFIX}/ is empty on ${RPK_CDN_BASE} ` +
|
|
426
|
+
`and no GitHub token is available to list ${TAGS_OWNER}/${TAGS_REPO} tags. ` +
|
|
427
|
+
'Set RPK_VERSION=vX.Y.Z or provide a token (GH_TOKEN, GITHUB_TOKEN, or REDPANDA_GITHUB_TOKEN).'
|
|
428
|
+
)
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
// --- CLI ----------------------------------------------------------------------
|
|
432
|
+
// stdout carries only the result so shell callers can capture it; everything
|
|
433
|
+
// else goes to stderr. Exit 0 ok, 2 not published / unresolvable, 1 unexpected.
|
|
434
|
+
|
|
435
|
+
const EXIT_NOT_PUBLISHED = 2
|
|
436
|
+
|
|
437
|
+
function usage() {
|
|
438
|
+
return [
|
|
439
|
+
'Usage:',
|
|
440
|
+
' node rpk-cdn.js install --dest <dir> [--version <tag>] [--platform <p>] [--arch <a>]',
|
|
441
|
+
' Download and verify rpk; prints the binary path. Without --version, resolves the',
|
|
442
|
+
' latest release (RPK_VERSION, then GitHub tags with a token, then latest/).',
|
|
443
|
+
' node rpk-cdn.js resolve-latest [--platform <p>] [--arch <a>]',
|
|
444
|
+
' Prints the tag "install" would use.',
|
|
445
|
+
' node rpk-cdn.js url --tag <tag> [--platform <p>] [--arch <a>]',
|
|
446
|
+
' Prints the zip and checksums URLs for a tag.',
|
|
447
|
+
'',
|
|
448
|
+
'--platform accepts win32/darwin/linux (or windows). --arch accepts x64/arm64 (or amd64, x86_64, aarch64).'
|
|
449
|
+
].join('\n')
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
function parseArgs(argv) {
|
|
453
|
+
const [command, ...rest] = argv
|
|
454
|
+
const opts = {}
|
|
455
|
+
for (let i = 0; i < rest.length; i++) {
|
|
456
|
+
const arg = rest[i]
|
|
457
|
+
if (!arg.startsWith('--')) throw new Error(`Unexpected argument '${arg}'\n${usage()}`)
|
|
458
|
+
const key = arg.slice(2)
|
|
459
|
+
const value = rest[i + 1]
|
|
460
|
+
if (value === undefined || value.startsWith('--')) throw new Error(`--${key} needs a value\n${usage()}`)
|
|
461
|
+
opts[key] = value
|
|
462
|
+
i++
|
|
463
|
+
}
|
|
464
|
+
return { command, opts }
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
async function main(argv) {
|
|
468
|
+
const stderrLog = { log: (...a) => console.error(...a), warn: (...a) => console.error(...a) }
|
|
469
|
+
const { command, opts } = parseArgs(argv)
|
|
470
|
+
const platformOpts = {
|
|
471
|
+
platform: opts.platform || process.platform,
|
|
472
|
+
arch: opts.arch || process.arch,
|
|
473
|
+
log: stderrLog
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
switch (command) {
|
|
477
|
+
case 'url': {
|
|
478
|
+
const tag = normalizeRpkTag(opts.tag || '')
|
|
479
|
+
if (!tag) {
|
|
480
|
+
console.error(`'${opts.tag}' is not a release tag; expected vX.Y.Z or vX.Y.Z-rcN`)
|
|
481
|
+
return EXIT_NOT_PUBLISHED
|
|
482
|
+
}
|
|
483
|
+
const urls = rpkCdnUrls(tag, platformOpts)
|
|
484
|
+
if (!urls) {
|
|
485
|
+
console.error(`No rpk release asset for platform ${platformOpts.platform}/${platformOpts.arch}`)
|
|
486
|
+
return EXIT_NOT_PUBLISHED
|
|
487
|
+
}
|
|
488
|
+
process.stdout.write(`${urls.zip}\n${urls.checksums}\n`)
|
|
489
|
+
return 0
|
|
490
|
+
}
|
|
491
|
+
case 'resolve-latest': {
|
|
492
|
+
const tag = await resolveLatestRpkTag(platformOpts)
|
|
493
|
+
process.stdout.write(`${tag}\n`)
|
|
494
|
+
return 0
|
|
495
|
+
}
|
|
496
|
+
case 'install': {
|
|
497
|
+
if (!opts.dest) throw new Error(`install requires --dest <dir>\n${usage()}`)
|
|
498
|
+
const tag = opts.version
|
|
499
|
+
? normalizeRpkTag(opts.version)
|
|
500
|
+
: await resolveLatestRpkTag(platformOpts)
|
|
501
|
+
if (!tag) {
|
|
502
|
+
console.error(`'${opts.version}' is not a release tag; expected vX.Y.Z or vX.Y.Z-rcN`)
|
|
503
|
+
return EXIT_NOT_PUBLISHED
|
|
504
|
+
}
|
|
505
|
+
fs.mkdirSync(opts.dest, { recursive: true })
|
|
506
|
+
const binPath = downloadRpkBinary(tag, opts.dest, platformOpts)
|
|
507
|
+
if (!binPath) return EXIT_NOT_PUBLISHED
|
|
508
|
+
process.stdout.write(`${binPath}\n`)
|
|
509
|
+
return 0
|
|
510
|
+
}
|
|
511
|
+
default:
|
|
512
|
+
throw new Error(`Unknown command '${command || ''}'\n${usage()}`)
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
if (require.main === module) {
|
|
517
|
+
// process.exitCode rather than process.exit(): install-test-dependencies.sh
|
|
518
|
+
// captures the binary path from stdout through a pipe, and exit() can end
|
|
519
|
+
// the process before that write drains, handing the shell a truncated path.
|
|
520
|
+
main(process.argv.slice(2)).then(
|
|
521
|
+
code => { process.exitCode = code },
|
|
522
|
+
err => {
|
|
523
|
+
console.error(err.message)
|
|
524
|
+
// Unresolvable "latest" is a not-published condition, not a bug
|
|
525
|
+
process.exitCode = /Could not resolve the latest rpk release/.test(err.message) ? EXIT_NOT_PUBLISHED : 1
|
|
526
|
+
}
|
|
527
|
+
)
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
module.exports = {
|
|
531
|
+
RPK_CDN_BASE,
|
|
532
|
+
RPK_CDN_LATEST_PREFIX,
|
|
533
|
+
RPK_RELEASE_TAG_RE,
|
|
534
|
+
RPK_GA_TAG_RE,
|
|
535
|
+
RPK_OS_BY_PLATFORM,
|
|
536
|
+
RPK_ARCH_BY_NODE_ARCH,
|
|
537
|
+
normalizeRpkTag,
|
|
538
|
+
rpkAssetName,
|
|
539
|
+
rpkChecksumsName,
|
|
540
|
+
rpkCdnUrl,
|
|
541
|
+
rpkCdnUrls,
|
|
542
|
+
isNotPublished,
|
|
543
|
+
curlToFile,
|
|
544
|
+
curlHead,
|
|
545
|
+
parseChecksums,
|
|
546
|
+
sha256File,
|
|
547
|
+
downloadRpkBinary,
|
|
548
|
+
resolveLatestRpkTag
|
|
549
|
+
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
'use strict'
|
|
2
2
|
|
|
3
3
|
const { spawnSync } = require('child_process')
|
|
4
|
-
const crypto = require('crypto')
|
|
5
4
|
const path = require('path')
|
|
6
5
|
const fs = require('fs')
|
|
7
6
|
const os = require('os')
|
|
@@ -9,6 +8,7 @@ const semver = require('semver')
|
|
|
9
8
|
const { findRepoRoot } = require('../../cli-utils/doc-tools-utils')
|
|
10
9
|
const { generateRpkDocs, applyOverridesToTree, resolveReferences, shouldExcludeCommand, shouldUsePartialDir, derivePartialsDir } = require('./generate-rpk-docs')
|
|
11
10
|
const { detectLinuxOnlyFromSource, warnIfDetectionLooksBroken } = require('./detect-platform-commands')
|
|
11
|
+
const { downloadRpkBinary, RPK_RELEASE_TAG_RE, RPK_CDN_BASE } = require('../../cli-utils/rpk-cdn')
|
|
12
12
|
const { generateRpkDiff, printDiffReport, generateWhatsNewSection, flattenToMap } = require('./report-delta')
|
|
13
13
|
const { loadAndValidateOverrides, ValidationResult } = require('./validate-overrides')
|
|
14
14
|
const { validateDirectory, formatResults } = require('./validate-output')
|
|
@@ -1306,138 +1306,26 @@ function buildRpkBinary(sourcePath, outPath) {
|
|
|
1306
1306
|
}
|
|
1307
1307
|
|
|
1308
1308
|
/**
|
|
1309
|
-
* Download
|
|
1310
|
-
*
|
|
1309
|
+
* Download the official rpk release binary for the current platform from the
|
|
1310
|
+
* rpk distribution CDN (https://rpk.redpanda.com). GA and RC tags are both
|
|
1311
|
+
* published there; no token is needed. See cli-utils/rpk-cdn.js for the
|
|
1312
|
+
* layout and the verification rules.
|
|
1313
|
+
* @param {string} tag - Release tag (e.g., v26.1.12, v26.2.1-rc2)
|
|
1311
1314
|
* @param {string} destDir - Directory to download and extract into
|
|
1312
|
-
* @returns {string|null} Path to the extracted binary, or null if the
|
|
1313
|
-
*
|
|
1315
|
+
* @returns {string|null} Path to the extracted binary, or null if the CDN has
|
|
1316
|
+
* no build for the tag (HTTP 403/404; caller falls back to a source build).
|
|
1317
|
+
* Throws on a checksum mismatch or an archive with no rpk binary.
|
|
1314
1318
|
*/
|
|
1315
1319
|
function downloadRpkRelease(tag, destDir) {
|
|
1316
|
-
|
|
1317
|
-
const archName = { arm64: 'arm64', x64: 'amd64' }[process.arch]
|
|
1318
|
-
if (!osName || !archName) {
|
|
1319
|
-
console.warn(`No rpk release asset for platform ${process.platform}/${process.arch}`)
|
|
1320
|
-
return null
|
|
1321
|
-
}
|
|
1322
|
-
|
|
1323
|
-
// Releases now publish to streaming-enterprise (private), not the public
|
|
1324
|
-
// redpanda-data/redpanda. This function already has a fallback (the
|
|
1325
|
-
// caller builds from source instead), so a missing token warns and
|
|
1326
|
-
// returns null here rather than throwing the way the clone-only paths do.
|
|
1327
|
-
const { getGitHubApiToken } = require('../../cli-utils/github-token')
|
|
1328
|
-
const token = getGitHubApiToken()
|
|
1329
|
-
if (!token) {
|
|
1330
|
-
console.warn('No GitHub token available for the private streaming-enterprise release download; falling back to a source build.')
|
|
1331
|
-
return null
|
|
1332
|
-
}
|
|
1333
|
-
|
|
1334
|
-
const assetName = `rpk-${osName}-${archName}.zip`
|
|
1335
|
-
const checksumAsset = `rpk_${tag.replace(/^v/, '')}_checksums.txt`
|
|
1336
|
-
const zipPath = path.join(destDir, assetName)
|
|
1337
|
-
// The Authorization header reaches curl through a config file on stdin
|
|
1338
|
-
// (--config -), never as an -H argument: an argv header is readable by
|
|
1339
|
-
// any process on the host (ps, /proc) for as long as the transfer runs.
|
|
1340
|
-
const curlAuthConfig = `header = "Authorization: token ${token}"`
|
|
1341
|
-
|
|
1342
|
-
// streaming-enterprise is private, so the release's browser download URL
|
|
1343
|
-
// (releases/download/<tag>/<asset>) 404s even with a valid token: it
|
|
1344
|
-
// redirects to a signed storage URL that requires an authenticated
|
|
1345
|
-
// browser session, not a bearer token. Only the API's per-asset url
|
|
1346
|
-
// (asset.url, not asset.browser_download_url) accepts this Authorization
|
|
1347
|
-
// header, so resolve assets through the release-by-tag API first.
|
|
1348
|
-
const releaseResult = spawnSync('curl', [
|
|
1349
|
-
'-fsSL', '--retry', '5', '--retry-all-errors',
|
|
1350
|
-
'--connect-timeout', '30', '--max-time', '60',
|
|
1351
|
-
'--config', '-',
|
|
1352
|
-
'-H', 'Accept: application/vnd.github+json',
|
|
1353
|
-
`https://api.github.com/repos/redpanda-data/streaming-enterprise/releases/tags/${tag}`
|
|
1354
|
-
], { encoding: 'utf8', input: curlAuthConfig, timeout: 90000 })
|
|
1355
|
-
|
|
1356
|
-
if (releaseResult.status !== 0) {
|
|
1357
|
-
console.warn(`Could not look up release ${tag} from streaming-enterprise (draft or missing release)`)
|
|
1358
|
-
return null
|
|
1359
|
-
}
|
|
1360
|
-
|
|
1361
|
-
let release
|
|
1362
|
-
try {
|
|
1363
|
-
release = JSON.parse(releaseResult.stdout)
|
|
1364
|
-
} catch {
|
|
1365
|
-
console.warn(`Could not parse release metadata for ${tag}`)
|
|
1366
|
-
return null
|
|
1367
|
-
}
|
|
1368
|
-
|
|
1369
|
-
const assetUrl = (release.assets || []).find(a => a.name === assetName)?.url
|
|
1370
|
-
if (!assetUrl) {
|
|
1371
|
-
console.warn(`Release ${tag} has no asset named ${assetName} (draft or missing release asset)`)
|
|
1372
|
-
return null
|
|
1373
|
-
}
|
|
1374
|
-
const checksumUrl = (release.assets || []).find(a => a.name === checksumAsset)?.url
|
|
1375
|
-
|
|
1376
|
-
console.log(`Downloading ${assetName} for ${tag}...`)
|
|
1377
|
-
// Accept: application/octet-stream is required on the API asset url to
|
|
1378
|
-
// receive the binary itself instead of its JSON metadata.
|
|
1379
|
-
const curlResult = spawnSync('curl', [
|
|
1380
|
-
'-fL', '--retry', '5', '--retry-all-errors',
|
|
1381
|
-
'--connect-timeout', '30', '--max-time', '300',
|
|
1382
|
-
'--config', '-',
|
|
1383
|
-
'-H', 'Accept: application/octet-stream',
|
|
1384
|
-
'-o', zipPath, assetUrl
|
|
1385
|
-
], { encoding: 'utf8', input: curlAuthConfig, timeout: 360000 })
|
|
1386
|
-
|
|
1387
|
-
if (curlResult.status !== 0) {
|
|
1388
|
-
console.warn(`Could not download rpk release for ${tag} (draft or missing release asset)`)
|
|
1389
|
-
return null
|
|
1390
|
-
}
|
|
1391
|
-
|
|
1392
|
-
// Verify against the release checksum file when it exists
|
|
1393
|
-
const checksumPath = path.join(destDir, checksumAsset)
|
|
1394
|
-
const checksumResult = checksumUrl ? spawnSync('curl', [
|
|
1395
|
-
'-fsSL', '--retry', '3', '--connect-timeout', '30', '--max-time', '60',
|
|
1396
|
-
'--config', '-',
|
|
1397
|
-
'-H', 'Accept: application/octet-stream',
|
|
1398
|
-
'-o', checksumPath, checksumUrl
|
|
1399
|
-
], { encoding: 'utf8', input: curlAuthConfig, timeout: 90000 }) : { status: 1 }
|
|
1400
|
-
|
|
1401
|
-
if (checksumResult.status === 0) {
|
|
1402
|
-
const expectedLine = fs.readFileSync(checksumPath, 'utf8')
|
|
1403
|
-
.split('\n')
|
|
1404
|
-
.find(line => line.trim().endsWith(assetName))
|
|
1405
|
-
if (expectedLine) {
|
|
1406
|
-
const expected = expectedLine.trim().split(/\s+/)[0]
|
|
1407
|
-
const actual = crypto.createHash('sha256').update(fs.readFileSync(zipPath)).digest('hex')
|
|
1408
|
-
if (expected !== actual) {
|
|
1409
|
-
throw new Error(
|
|
1410
|
-
`Checksum mismatch for ${assetName} (${tag}):\n` +
|
|
1411
|
-
` expected ${expected}\n actual ${actual}`
|
|
1412
|
-
)
|
|
1413
|
-
}
|
|
1414
|
-
console.log('Checksum verified')
|
|
1415
|
-
}
|
|
1416
|
-
} else {
|
|
1417
|
-
console.warn('No checksum file published for this release; skipping verification')
|
|
1418
|
-
}
|
|
1419
|
-
|
|
1420
|
-
const unzipResult = spawnSync('unzip', ['-o', zipPath, '-d', destDir], {
|
|
1421
|
-
encoding: 'utf8',
|
|
1422
|
-
timeout: 60000
|
|
1423
|
-
})
|
|
1424
|
-
if (unzipResult.status !== 0) {
|
|
1425
|
-
throw new Error(`Failed to extract ${assetName}: ${unzipResult.stderr}`)
|
|
1426
|
-
}
|
|
1427
|
-
|
|
1428
|
-
const binPath = path.join(destDir, 'rpk')
|
|
1429
|
-
if (!fs.existsSync(binPath)) {
|
|
1430
|
-
throw new Error(`Extracted archive did not contain an rpk binary: ${zipPath}`)
|
|
1431
|
-
}
|
|
1432
|
-
fs.chmodSync(binPath, 0o755)
|
|
1433
|
-
return binPath
|
|
1320
|
+
return downloadRpkBinary(tag, destDir)
|
|
1434
1321
|
}
|
|
1435
1322
|
|
|
1436
1323
|
/**
|
|
1437
1324
|
* Get an rpk binary matching the given version.
|
|
1438
|
-
* Prefers the official release download
|
|
1439
|
-
*
|
|
1440
|
-
*
|
|
1325
|
+
* Prefers the official release download: GA and RC tags are published to
|
|
1326
|
+
* https://rpk.redpanda.com/v<tag>/ with no token needed. Falls back to
|
|
1327
|
+
* building from source at the tag (private clone, token required) when the
|
|
1328
|
+
* CDN has no build; vdev always builds from source.
|
|
1441
1329
|
* @param {string} rpkVersion - Version tag from the snapshot (e.g., v26.1.12, v26.2.1-rc2)
|
|
1442
1330
|
* @param {Object} [options]
|
|
1443
1331
|
* @param {string} [options.rpkBin] - Existing binary to use, skipping download/build
|
|
@@ -1456,25 +1344,26 @@ function acquireRpkBinary(rpkVersion, options = {}) {
|
|
|
1456
1344
|
}
|
|
1457
1345
|
|
|
1458
1346
|
const tag = rpkVersion.startsWith('v') ? rpkVersion : `v${rpkVersion}`
|
|
1347
|
+
|
|
1348
|
+
if (!RPK_RELEASE_TAG_RE.test(tag) && tag !== 'vdev') {
|
|
1349
|
+
throw new Error(
|
|
1350
|
+
`Cannot acquire an rpk binary for version '${rpkVersion}'.\n` +
|
|
1351
|
+
'The snapshot\'s rpk_version is not a release tag. ' +
|
|
1352
|
+
'Pass --rpk-bin <path> to use a local rpk binary instead.'
|
|
1353
|
+
)
|
|
1354
|
+
}
|
|
1355
|
+
|
|
1459
1356
|
const workDir = fs.mkdtempSync(path.join(os.tmpdir(), 'rpk-bin-'))
|
|
1460
1357
|
|
|
1461
|
-
//
|
|
1462
|
-
if (
|
|
1358
|
+
// GA and RC tags both have a build on the CDN; only branches do not
|
|
1359
|
+
if (RPK_RELEASE_TAG_RE.test(tag)) {
|
|
1463
1360
|
const downloaded = downloadRpkRelease(tag, workDir)
|
|
1464
1361
|
if (downloaded) {
|
|
1465
1362
|
return downloaded
|
|
1466
1363
|
}
|
|
1467
1364
|
console.log('Falling back to building rpk from source...')
|
|
1468
1365
|
} else {
|
|
1469
|
-
console.log(
|
|
1470
|
-
}
|
|
1471
|
-
|
|
1472
|
-
if (!/^v\d+\.\d+\.\d+(-rc\d+)?$/.test(tag) && tag !== 'vdev') {
|
|
1473
|
-
throw new Error(
|
|
1474
|
-
`Cannot acquire an rpk binary for version '${rpkVersion}'.\n` +
|
|
1475
|
-
'The snapshot\'s rpk_version is not a release tag. ' +
|
|
1476
|
-
'Pass --rpk-bin <path> to use a local rpk binary instead.'
|
|
1477
|
-
)
|
|
1366
|
+
console.log(`${tag} has no build on ${RPK_CDN_BASE} (branches build from source)...`)
|
|
1478
1367
|
}
|
|
1479
1368
|
|
|
1480
1369
|
const sourceRef = tag === 'vdev' ? 'dev' : tag
|