UNPKG

@redpanda-data/docs-extensions-and-macros

Version:

Antora extensions and macros developed for Redpanda documentation.

567 lines (511 loc) 20 kB
'use strict' /** * Static (source-based) detection of Linux-only rpk commands. * * Works on any platform (including Linux CI runners, where the dynamic * "build on Linux and Darwin and diff the trees" approach is impossible * because a cross-compiled darwin rpk cannot be executed). * * How it works * ------------ * rpk gates platform-specific commands with Go build constraints: * * 1. Whole-package gating: every file in a command's package carries a * Linux constraint (explicit `//go:build linux` tag or a `_linux.go` * filename suffix) and no darwin-buildable counterpart exists. * Example: pkg/cli/iotune, pkg/cli/redpanda/tune. * * 2. Dual registration: a package provides two variants of the same * command constructor, one per platform, registering different * subcommand sets. Example: pkg/cli/redpanda/redpanda.go * (`//go:build linux`, registers start/stop/check/mode/config/tune) * vs pkg/cli/redpanda/redpanda_darwin.go (`//go:build darwin`, * registers only the hidden admin command). Same pattern at the root: * pkg/cli/root_linux.go vs pkg/cli/root_darwin.go. * * This module detects both patterns: * * - For every scanned package, it computes which files build on linux * and which build on darwin (from `//go:build` / `// +build` tags AND * filename-implied constraints like `_linux.go`). * - Packages whose files all fail to build on darwin (but build on * linux) and that define a cobra command constructor are Linux-only. * - Packages containing platform-differential files get a registration * diff: subcommand constructors referenced from linux-buildable files * but not from darwin-buildable files are Linux-only. Deprecated and * hidden constructors are ignored because `rpk --print-tree` (the * basis of the generated docs) excludes hidden commands. * * Scope: built-in commands only. Plugin commands (rpk connect, rpk ai, ...) * are not part of the rpk source tree, so their platform availability * cannot be derived here. Plugins keep whatever platform behavior the * caller's other detection paths provide (for example, dynamic detection * marks plugin-only commands when plugins are only installed in the Linux * build container). */ const fs = require('fs') const path = require('path') /** GOOS values recognized in filename suffixes and build tags */ const KNOWN_GOOS = new Set([ 'aix', 'android', 'darwin', 'dragonfly', 'freebsd', 'hurd', 'illumos', 'ios', 'js', 'linux', 'nacl', 'netbsd', 'openbsd', 'plan9', 'solaris', 'wasip1', 'windows', 'zos' ]) /** GOOS values that satisfy the `unix` build tag */ const UNIX_GOOS = new Set([ 'aix', 'android', 'darwin', 'dragonfly', 'freebsd', 'hurd', 'illumos', 'ios', 'linux', 'netbsd', 'openbsd', 'solaris' ]) /** GOARCH values recognized in filename suffixes */ const KNOWN_GOARCH = new Set([ '386', 'amd64', 'amd64p32', 'arm', 'arm64', 'arm64be', 'armbe', 'loong64', 'mips', 'mips64', 'mips64le', 'mips64p32', 'mips64p32le', 'mipsle', 'ppc', 'ppc64', 'ppc64le', 'riscv', 'riscv64', 's390', 's390x', 'sparc', 'sparc64', 'wasm' ]) /** * Directories (relative to the rpk source root) scanned for command * implementations. Modern rpk keeps commands in pkg/cli/<command>/; * the other roots cover older source layouts. */ const SCAN_ROOTS = ['pkg/cli', 'pkg/cli/cmd', 'cmd/rpk'] /** Directory names that never correspond to a command path segment */ const NON_COMMAND_SEGMENTS = new Set(['internal', 'common', 'testdata']) /** * Determine the GOOS constraint implied by a Go filename. * Follows Go's rules: *_GOOS.go, *_GOOS_GOARCH.go constrain the OS; * *_GOARCH.go alone does not. * @param {string} fileName - e.g. 'redpanda_darwin.go', 'bundle_k8s_linux.go' * @returns {string|null} GOOS name or null when unconstrained */ function goosFromFileName(fileName) { const base = fileName.replace(/\.go$/, '') const parts = base.split('_') if (parts.length < 2) return null const last = parts[parts.length - 1] if (KNOWN_GOOS.has(last)) return last if (KNOWN_GOARCH.has(last) && parts.length >= 3) { const secondLast = parts[parts.length - 2] if (KNOWN_GOOS.has(secondLast)) return secondLast } return null } /** * Evaluate a `//go:build` constraint expression for a target GOOS. * Supports identifiers, !, &&, || and parentheses. * Unknown identifiers (custom build tags) evaluate to false, matching a * default `go build` with no -tags flag. GOARCH identifiers evaluate to * true because we only care about OS-level availability. * @param {string} expr - Expression after `//go:build` * @param {string} goos - Target GOOS ('linux' or 'darwin') * @returns {boolean} */ function evaluateBuildExpr(expr, goos) { const tokens = expr.match(/[A-Za-z0-9_.]+|&&|\|\||!|\(|\)/g) || [] let pos = 0 const peek = () => tokens[pos] const next = () => tokens[pos++] function evalIdent(ident) { if (KNOWN_GOOS.has(ident)) return ident === goos if (ident === 'unix') return UNIX_GOOS.has(goos) if (KNOWN_GOARCH.has(ident)) return true if (ident === 'cgo') return true // Custom build tags (e.g. withasan, integration) are unset by default return false } function parsePrimary() { const tok = next() if (tok === '(') { const val = parseOr() if (peek() === ')') next() return val } if (tok === '!') return !parsePrimary() if (tok === undefined) return false return evalIdent(tok) } function parseAnd() { let val = parsePrimary() while (peek() === '&&') { next() val = parsePrimary() && val } return val } function parseOr() { let val = parseAnd() while (peek() === '||') { next() val = parseAnd() || val } return val } return parseOr() } /** * Evaluate legacy `// +build` lines for a target GOOS. * Multiple lines AND together; within a line, space-separated options OR * and comma-separated terms AND. * @param {string[]} lines - Contents after `// +build` (one per line) * @param {string} goos - Target GOOS * @returns {boolean} */ function evaluatePlusBuildLines(lines, goos) { const evalTerm = (term) => { let negate = false while (term.startsWith('!')) { negate = !negate term = term.slice(1) } let val if (KNOWN_GOOS.has(term)) val = term === goos else if (term === 'unix') val = UNIX_GOOS.has(goos) else if (KNOWN_GOARCH.has(term) || term === 'cgo') val = true else val = false return negate ? !val : val } return lines.every(line => { const options = line.trim().split(/\s+/).filter(Boolean) if (options.length === 0) return true return options.some(opt => opt.split(',').every(evalTerm)) }) } /** * Extract the build constraint (if any) from a Go file's header. * Only lines before the `package` declaration are considered. * @param {string} content - Go file content * @returns {{goBuildExpr: string|null, plusBuildLines: string[]}} */ function extractBuildConstraint(content) { const header = content.split(/^package\s/m)[0] const goBuildMatch = header.match(/^\/\/go:build\s+(.+)$/m) const plusBuildLines = [] const plusBuildRe = /^\/\/\s*\+build\s+(.+)$/gm let m while ((m = plusBuildRe.exec(header)) !== null) { plusBuildLines.push(m[1]) } return { goBuildExpr: goBuildMatch ? goBuildMatch[1].trim() : null, plusBuildLines } } /** * Determine whether a Go file builds for a target GOOS, taking both the * filename-implied constraint and explicit build tags into account. * @param {string} fileName - Base name of the file * @param {string} content - File content * @param {string} goos - Target GOOS ('linux' or 'darwin') * @returns {boolean} */ function fileBuildsOn(fileName, content, goos) { const fileGoos = goosFromFileName(fileName) if (fileGoos && fileGoos !== goos) return false const { goBuildExpr, plusBuildLines } = extractBuildConstraint(content) if (goBuildExpr) return evaluateBuildExpr(goBuildExpr, goos) if (plusBuildLines.length > 0) return evaluatePlusBuildLines(plusBuildLines, goos) return true } /** * Derive a command name from a cobra constructor function name. * NewStartCommand -> start, newTuneCommand -> tune, NewCommand -> null. * @param {string} funcName * @returns {string|null} */ function commandNameFromConstructor(funcName) { const m = funcName.match(/^(?:New|new)(\w+?)(?:Command|Cmd)$/) if (m && m[1]) return m[1].toLowerCase() return null } /** * Parse cobra command constructors defined in a Go file. * A constructor is any `func XxxYyy(...) *cobra.Command`. For each one we * record the cobra `Use:` name (first token) and whether the command is * deprecated or hidden (such commands never appear in `rpk --print-tree`). * @param {string} content - Go file content * @returns {Object<string, {useName: string|null, excluded: boolean}>} */ function parseConstructors(content) { const constructors = {} const funcRe = /^func\s+(\w+)\s*\([^)]*\)\s*\*cobra\.Command\s*\{/gm const matches = [] let m while ((m = funcRe.exec(content)) !== null) { matches.push({ name: m[1], start: m.index }) } for (let i = 0; i < matches.length; i++) { const bodyEnd = i + 1 < matches.length ? matches[i + 1].start : content.length const body = content.slice(matches[i].start, bodyEnd) const useMatch = body.match(/\bUse:\s*"([^"]+)"/) const useName = useMatch ? useMatch[1].trim().split(/\s+/)[0] : null // Deprecated or hidden commands are excluded from `rpk --print-tree` const excluded = /\bHidden:\s*true\b/.test(body) || /\bDeprecated:\s*"/.test(body) || /\bDeprecateCmd\s*\(/.test(body) constructors[matches[i].name] = { useName, excluded } } return constructors } /** * Extract constructor references from all AddCommand(...) calls in a file. * Handles both same-package (`NewStartCommand(...)`) and cross-package * (`tune.NewCommand(...)`) references. Only functions starting with * New/new are considered, which skips wrappers like cobraext.DeprecateCmd. * @param {string} content - Go file content * @returns {Array<{qualifier: string|null, funcName: string}>} */ function parseAddCommandRefs(content) { const refs = [] const addRe = /\bAddCommand\s*\(/g let m while ((m = addRe.exec(content)) !== null) { // Find the span of this AddCommand(...) call by balancing parentheses let depth = 1 let i = m.index + m[0].length while (i < content.length && depth > 0) { if (content[i] === '(') depth++ else if (content[i] === ')') depth-- i++ } const span = content.slice(m.index + m[0].length, i - 1) const callRe = /(?:(\w+)\.)?((?:New|new)\w*)\s*\(/g let c while ((c = callRe.exec(span)) !== null) { refs.push({ qualifier: c[1] || null, funcName: c[2] }) } } return refs } /** * Recursively collect non-test Go files under a directory. * @param {string} dir - Absolute directory path * @param {Array} out - Accumulator of {absPath, dir, name} */ function collectGoFiles(dir, out) { let entries try { entries = fs.readdirSync(dir, { withFileTypes: true }) } catch (err) { return } for (const entry of entries) { const fullPath = path.join(dir, entry.name) if (entry.isDirectory()) { if (entry.name === 'testdata' || entry.name === 'vendor') continue collectGoFiles(fullPath, out) } else if (entry.name.endsWith('.go') && !entry.name.endsWith('_test.go')) { out.push({ absPath: fullPath, dir, name: entry.name }) } } } /** * Build the command path prefix for a package directory. * pkg/cli -> 'rpk'; pkg/cli/redpanda -> 'rpk redpanda'; * pkg/cli/cmd/redpanda (legacy) -> 'rpk redpanda'. * @param {string} scanRoot - Absolute path of the scan root * @param {string} dir - Absolute directory path * @returns {string|null} Command path prefix, or null for non-command dirs */ function commandPrefixForDir(scanRoot, dir) { const rel = path.relative(scanRoot, dir) let segments = rel === '' ? [] : rel.split(path.sep) // Legacy layouts nested commands under an extra cmd/ level if (segments[0] === 'cmd') segments = segments.slice(1) if (segments.some(s => NON_COMMAND_SEGMENTS.has(s))) return null return ['rpk', ...segments].join(' ') } /** * Analyze one scan root and add detected Linux-only command paths. * @param {string} scanRoot - Absolute path of the scan root * @param {Set<string>} linuxOnlyCommands - Accumulator */ function scanRootForLinuxOnly(scanRoot, linuxOnlyCommands) { const files = [] collectGoFiles(scanRoot, files) if (files.length === 0) return // Group files (with parsed metadata) by directory const dirs = new Map() for (const file of files) { let content try { content = fs.readFileSync(file.absPath, 'utf8') } catch (err) { continue } const info = { name: file.name, content, buildsOnLinux: fileBuildsOn(file.name, content, 'linux'), buildsOnDarwin: fileBuildsOn(file.name, content, 'darwin') } if (!dirs.has(file.dir)) dirs.set(file.dir, []) dirs.get(file.dir).push(info) } // Per-directory constructor index: dir -> {funcName: {useName, excluded}} const constructorIndex = new Map() for (const [dir, dirFiles] of dirs) { const index = {} for (const f of dirFiles) { Object.assign(index, parseConstructors(f.content)) } constructorIndex.set(dir, index) } /** * Resolve an AddCommand constructor reference to a visible command name. * Returns null for deprecated/hidden/unresolvable commands. */ const resolveRef = (dir, ref) => { let target = null let fallback = commandNameFromConstructor(ref.funcName) if (ref.qualifier) { const subDir = path.join(dir, ref.qualifier) target = constructorIndex.get(subDir) || null if (!fallback) fallback = ref.qualifier } else { target = constructorIndex.get(dir) || null } if (target && target[ref.funcName]) { const ctor = target[ref.funcName] if (ctor.excluded) return null return ctor.useName || fallback } // Unresolvable reference: only trust it if the function name follows // the cobra constructor convention (New[Xxx]Command). This skips // helper arguments captured inside AddCommand spans, such as the // rp.NewLauncher() argument in redpanda.NewCommand(fs, p, rp.NewLauncher()). if (!/^(?:New|new)(?:\w*(?:Command|Cmd))?$/.test(ref.funcName)) return null return fallback } for (const [dir, dirFiles] of dirs) { const prefix = commandPrefixForDir(scanRoot, dir) if (!prefix) continue const hasDifferentialFile = dirFiles.some(f => f.buildsOnLinux !== f.buildsOnDarwin) // Pattern 1: whole package is Linux-gated (and defines a command) if (prefix !== 'rpk') { const anyLinux = dirFiles.some(f => f.buildsOnLinux) const anyDarwin = dirFiles.some(f => f.buildsOnDarwin) const definesCommand = Object.keys(constructorIndex.get(dir)).length > 0 if (anyLinux && !anyDarwin && definesCommand) { linuxOnlyCommands.add(prefix) continue } } // Pattern 2: dual registration - diff subcommands registered from // linux-buildable files vs darwin-buildable files if (!hasDifferentialFile) continue const refsFor = (goos) => { const names = new Set() for (const f of dirFiles) { const buildable = goos === 'linux' ? f.buildsOnLinux : f.buildsOnDarwin if (!buildable) continue for (const ref of parseAddCommandRefs(f.content)) { const name = resolveRef(dir, ref) if (name) names.add(name) } } return names } const linuxRefs = refsFor('linux') const darwinRefs = refsFor('darwin') for (const name of linuxRefs) { if (!darwinRefs.has(name)) { linuxOnlyCommands.add(`${prefix} ${name}`) } } } } /** * Detect Linux-only rpk commands by statically analyzing Go source. * Works on any platform, including Linux CI runners. * @param {string} sourcePath - Path to the rpk Go source root (src/go/rpk) * @returns {Set<string>} Linux-only command paths (e.g. 'rpk iotune'). * Descendants of a returned path are implicitly Linux-only too. */ function detectLinuxOnlyFromSource(sourcePath) { const linuxOnlyCommands = new Set() const scannedRoots = new Set() for (const root of SCAN_ROOTS) { const absRoot = path.join(sourcePath, root) if (!fs.existsSync(absRoot)) continue // pkg/cli/cmd is nested inside pkg/cli; avoid scanning it twice if ([...scannedRoots].some(r => absRoot.startsWith(r + path.sep))) continue scannedRoots.add(absRoot) scanRootForLinuxOnly(absRoot, linuxOnlyCommands) } // Drop any detected path that is a descendant of another detected path; // platform markers already propagate to descendants. for (const cmd of [...linuxOnlyCommands]) { if ([...linuxOnlyCommands].some(other => other !== cmd && cmd.startsWith(other + ' '))) { linuxOnlyCommands.delete(cmd) } } return linuxOnlyCommands } /** * List Go files under the scanned roots that carry a Linux-only build * constraint (explicit tag or filename suffix). Used as a tripwire: if * detection returns an empty set while this list is non-empty, the scan * is almost certainly broken (wrong directory layout, changed tag style). * @param {string} sourcePath - Path to the rpk Go source root * @returns {string[]} Relative paths of Linux-constrained files */ function findLinuxConstrainedFiles(sourcePath) { const results = [] for (const root of SCAN_ROOTS) { const absRoot = path.join(sourcePath, root) if (!fs.existsSync(absRoot)) continue const files = [] collectGoFiles(absRoot, files) for (const file of files) { let content try { content = fs.readFileSync(file.absPath, 'utf8') } catch (err) { continue } if (fileBuildsOn(file.name, content, 'linux') && !fileBuildsOn(file.name, content, 'darwin')) { results.push(path.relative(sourcePath, file.absPath)) } } } return [...new Set(results)] } /** * Emit a loud warning when platform detection came back empty even though * the source demonstrably contains Linux-gated files. Never let a scan * failure silently mark every command as cross-platform. * @param {string} sourcePath - Path to the rpk Go source root * @param {Set<string>} linuxOnlyCommands - Combined detection result * @returns {boolean} True when the tripwire fired */ function warnIfDetectionLooksBroken(sourcePath, linuxOnlyCommands) { if (linuxOnlyCommands.size > 0) return false const constrained = findLinuxConstrainedFiles(sourcePath) if (constrained.length === 0) return false console.warn('\n' + '!'.repeat(70)) console.warn('⚠ PLATFORM DETECTION TRIPWIRE: no Linux-only commands were detected,') console.warn(`⚠ but the rpk source contains ${constrained.length} Linux-constrained file(s), e.g.:`) for (const file of constrained.slice(0, 5)) { console.warn(`⚠ - ${file}`) } console.warn('⚠ This usually means the static source scan failed (unexpected') console.warn('⚠ directory layout or build-tag style) and every command is about') console.warn('⚠ to be marked as available on both Linux and macOS, which is wrong.') console.warn('⚠ Fix tools/rpk-docs/detect-platform-commands.js before publishing') console.warn('⚠ these docs. See redpanda-data/docs#1831 for the impact of this.') console.warn('!'.repeat(70) + '\n') return true } module.exports = { detectLinuxOnlyFromSource, findLinuxConstrainedFiles, warnIfDetectionLooksBroken, // Exported for unit tests fileBuildsOn, goosFromFileName, evaluateBuildExpr, evaluatePlusBuildLines, parseConstructors, parseAddCommandRefs, commandNameFromConstructor }