@redpanda-data/docs-extensions-and-macros 4.9.1 → 4.10.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,814 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const { execSync, spawnSync } = require('child_process');
|
|
6
|
+
const yaml = require('yaml');
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Normalize a Git tag into a semantic version string.
|
|
10
|
+
*
|
|
11
|
+
* Trims surrounding whitespace, returns 'dev' unchanged, removes a leading 'v' if present,
|
|
12
|
+
* and validates that the result matches MAJOR.MINOR.PATCH with optional pre-release/build metadata.
|
|
13
|
+
* Throws if the input is not a non-empty string or does not conform to the expected version format.
|
|
14
|
+
*
|
|
15
|
+
* @param {string} tag - Git tag (e.g., 'v25.1.1', '25.1.1', or 'dev').
|
|
16
|
+
* @returns {string} Normalized version (e.g., '25.1.1' or 'dev').
|
|
17
|
+
* @throws {Error} If `tag` is not a non-empty string or does not match the semantic version pattern.
|
|
18
|
+
*/
|
|
19
|
+
function normalizeTag(tag) {
|
|
20
|
+
if (!tag || typeof tag !== 'string') {
|
|
21
|
+
throw new Error('Tag must be a non-empty string');
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Trim whitespace
|
|
25
|
+
tag = tag.trim();
|
|
26
|
+
|
|
27
|
+
if (!tag) {
|
|
28
|
+
throw new Error('Invalid version format: tag cannot be empty');
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Handle dev branch
|
|
32
|
+
if (tag === 'dev') {
|
|
33
|
+
return 'dev';
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Remove 'v' prefix if present
|
|
37
|
+
const normalized = tag.startsWith('v') ? tag.slice(1) : tag;
|
|
38
|
+
|
|
39
|
+
// Validate semantic version format
|
|
40
|
+
const semverPattern = /^\d+\.\d+\.\d+(-[\w\.-]+)?(\+[\w\.-]+)?$/;
|
|
41
|
+
if (!semverPattern.test(normalized) && normalized !== 'dev') {
|
|
42
|
+
throw new Error(`Invalid version format: ${tag}. Expected format like v25.1.1 or 25.1.1`);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return normalized;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Return the major.minor portion of a semantic version string.
|
|
50
|
+
*
|
|
51
|
+
* Accepts a semantic version like `25.1.1` and yields `25.1`. The special value
|
|
52
|
+
* `'dev'` is returned unchanged.
|
|
53
|
+
* @param {string} version - Semantic version (e.g., `'25.1.1'`) or `'dev'`.
|
|
54
|
+
* @returns {string} The `major.minor` string (e.g., `'25.1'`) or `'dev'`.
|
|
55
|
+
* @throws {Error} If `version` is not a non-empty string, lacks major/minor parts, or if major/minor are not numeric.
|
|
56
|
+
*/
|
|
57
|
+
function getMajorMinor(version) {
|
|
58
|
+
if (!version || typeof version !== 'string') {
|
|
59
|
+
throw new Error('Version must be a non-empty string');
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (version === 'dev') {
|
|
63
|
+
return 'dev';
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const parts = version.split('.');
|
|
67
|
+
if (parts.length < 2) {
|
|
68
|
+
throw new Error(`Invalid version format: ${version}. Expected X.Y.Z format`);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const major = parseInt(parts[0], 10);
|
|
72
|
+
const minor = parseInt(parts[1], 10);
|
|
73
|
+
|
|
74
|
+
if (isNaN(major) || isNaN(minor)) {
|
|
75
|
+
throw new Error(`Major and minor versions must be numbers: ${version}`);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return `${major}.${minor}`;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Produce a new value with object keys sorted recursively for deterministic output.
|
|
83
|
+
* Non-objects are returned unchanged; arrays are processed element-wise.
|
|
84
|
+
* @param {*} obj - Value to normalize; may be an object, array, or any primitive.
|
|
85
|
+
* @returns {*} A new value where any objects have their keys sorted lexicographically.
|
|
86
|
+
*/
|
|
87
|
+
function sortObjectKeys(obj) {
|
|
88
|
+
if (obj === null || typeof obj !== 'object') {
|
|
89
|
+
return obj;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (Array.isArray(obj)) {
|
|
93
|
+
return obj.map(sortObjectKeys);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const sortedObj = {};
|
|
97
|
+
Object.keys(obj)
|
|
98
|
+
.sort()
|
|
99
|
+
.forEach(key => {
|
|
100
|
+
sortedObj[key] = sortObjectKeys(obj[key]);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
return sortedObj;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Detect available OpenAPI bundler
|
|
108
|
+
* @param {boolean} quiet - Suppress output
|
|
109
|
+
* @returns {string} Available bundler command
|
|
110
|
+
*/
|
|
111
|
+
function detectBundler(quiet = false) {
|
|
112
|
+
const bundlers = ['swagger-cli', 'redocly'];
|
|
113
|
+
|
|
114
|
+
for (const bundler of bundlers) {
|
|
115
|
+
try {
|
|
116
|
+
execSync(`${bundler} --version`, {
|
|
117
|
+
stdio: 'ignore',
|
|
118
|
+
timeout: 10000
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
if (!quiet) {
|
|
122
|
+
console.log(`✅ Using ${bundler} for OpenAPI bundling`);
|
|
123
|
+
}
|
|
124
|
+
return bundler;
|
|
125
|
+
} catch (error) {
|
|
126
|
+
// Continue to next bundler
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Try npx @redocly/cli as fallback
|
|
131
|
+
try {
|
|
132
|
+
execSync('npx @redocly/cli --version', {
|
|
133
|
+
stdio: 'ignore',
|
|
134
|
+
timeout: 10000
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
if (!quiet) {
|
|
138
|
+
console.log('✅ Using npx @redocly/cli for OpenAPI bundling');
|
|
139
|
+
}
|
|
140
|
+
return 'npx @redocly/cli';
|
|
141
|
+
} catch (error) {
|
|
142
|
+
// Try legacy npx redocly
|
|
143
|
+
try {
|
|
144
|
+
execSync('npx redocly --version', {
|
|
145
|
+
stdio: 'ignore',
|
|
146
|
+
timeout: 10000
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
if (!quiet) {
|
|
150
|
+
console.log('✅ Using npx redocly for OpenAPI bundling');
|
|
151
|
+
}
|
|
152
|
+
return 'npx redocly';
|
|
153
|
+
} catch (error) {
|
|
154
|
+
// Final fallback failed
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
throw new Error(
|
|
159
|
+
'No OpenAPI bundler found. Please install one of:\n' +
|
|
160
|
+
' npm install -g swagger-cli\n' +
|
|
161
|
+
' npm install -g @redocly/cli\n' +
|
|
162
|
+
'For more information, see: https://github.com/APIDevTools/swagger-cli or https://redocly.com/docs/cli/'
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Collects file paths of OpenAPI fragment files for the specified API surface.
|
|
168
|
+
*
|
|
169
|
+
* @param {string} tempDir - Path to a temporary repository workspace that contains generated OpenAPI fragments (must exist).
|
|
170
|
+
* @param {'admin'|'connect'} apiSurface - API surface to scan; either `'admin'` or `'connect'`.
|
|
171
|
+
* @returns {string[]} Array of full paths to discovered fragment files (*.openapi.yaml / *.openapi.yml).
|
|
172
|
+
* @throws {Error} If tempDir is missing or does not exist.
|
|
173
|
+
* @throws {Error} If apiSurface is not 'admin' or 'connect'.
|
|
174
|
+
* @throws {Error} If no OpenAPI fragment files are found.
|
|
175
|
+
*/
|
|
176
|
+
function createEntrypoint(tempDir, apiSurface) {
|
|
177
|
+
// Validate input parameters
|
|
178
|
+
if (!tempDir || typeof tempDir !== 'string' || tempDir.trim() === '') {
|
|
179
|
+
throw new Error('Invalid temporary directory');
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// Check if directory exists
|
|
183
|
+
if (!fs.existsSync(tempDir)) {
|
|
184
|
+
throw new Error('Invalid temporary directory');
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
if (!apiSurface || typeof apiSurface !== 'string' || !['admin', 'connect'].includes(apiSurface)) {
|
|
188
|
+
throw new Error('Invalid API surface');
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
let quiet = false; // Default for logging
|
|
192
|
+
if (!quiet) {
|
|
193
|
+
console.log('🔍 Looking for fragments in:');
|
|
194
|
+
console.log(` Admin v2: ${path.join(tempDir, 'vbuild/openapi/proto/redpanda/core/admin/v2')}`);
|
|
195
|
+
console.log(` Common: ${path.join(tempDir, 'vbuild/openapi/proto/redpanda/core/common')}`);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const fragmentDirs = [];
|
|
199
|
+
let fragmentFiles = [];
|
|
200
|
+
|
|
201
|
+
try {
|
|
202
|
+
if (apiSurface === 'admin') {
|
|
203
|
+
const adminDir = path.join(tempDir, 'vbuild/openapi/proto/redpanda/core/admin/v2');
|
|
204
|
+
const commonDir = path.join(tempDir, 'vbuild/openapi/proto/redpanda/core/common');
|
|
205
|
+
|
|
206
|
+
fragmentDirs.push(adminDir, commonDir);
|
|
207
|
+
} else if (apiSurface === 'connect') {
|
|
208
|
+
const connectDir = path.join(tempDir, 'vbuild/openapi/proto/redpanda/connect');
|
|
209
|
+
fragmentDirs.push(connectDir);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// Log directory existence for debugging
|
|
213
|
+
if (!quiet && fs.existsSync(path.join(tempDir, 'vbuild'))) {
|
|
214
|
+
console.log('📂 vbuild directory contents:');
|
|
215
|
+
try {
|
|
216
|
+
const contents = fs.readdirSync(path.join(tempDir, 'vbuild'), { recursive: true });
|
|
217
|
+
contents.slice(0, 10).forEach(item => {
|
|
218
|
+
console.log(` ${item}`);
|
|
219
|
+
});
|
|
220
|
+
if (contents.length > 10) {
|
|
221
|
+
console.log(` ... and ${contents.length - 10} more items`);
|
|
222
|
+
}
|
|
223
|
+
} catch (dirErr) {
|
|
224
|
+
console.log(` ❌ Error reading directory: ${dirErr.message}`);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
fragmentDirs.forEach(dir => {
|
|
229
|
+
if (fs.existsSync(dir)) {
|
|
230
|
+
try {
|
|
231
|
+
const files = fs.readdirSync(dir)
|
|
232
|
+
.filter(file => file.endsWith('.openapi.yaml') || file.endsWith('.openapi.yml'))
|
|
233
|
+
.map(file => path.join(dir, file))
|
|
234
|
+
.filter(filePath => fs.statSync(filePath).isFile()); // Make sure it's actually a file
|
|
235
|
+
|
|
236
|
+
fragmentFiles.push(...files);
|
|
237
|
+
} catch (readErr) {
|
|
238
|
+
throw new Error(`Failed to read fragment directories: ${readErr.message}`);
|
|
239
|
+
}
|
|
240
|
+
} else {
|
|
241
|
+
if (!quiet) {
|
|
242
|
+
console.log(`📁 ${path.basename(dir) === 'v2' ? 'Admin v2' : path.basename(dir)} directory not found: ${dir}`);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
} catch (err) {
|
|
248
|
+
throw new Error(`Failed to scan for OpenAPI fragments: ${err.message}`);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
if (fragmentFiles.length === 0) {
|
|
252
|
+
throw new Error('No OpenAPI fragments found to bundle. Make sure \'buf generate\' has run successfully');
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// Most bundlers can handle multiple input files or merge operations.
|
|
256
|
+
return fragmentFiles;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Bundle one or more OpenAPI fragment files into a single bundled YAML using a selected external bundler.
|
|
261
|
+
*
|
|
262
|
+
* Merges multiple fragment files into a temporary single entrypoint when required, invokes the specified bundler
|
|
263
|
+
* executable (supported values: 'swagger-cli', 'redocly', 'npx redocly', 'npx @redocly/cli'), and writes the bundled
|
|
264
|
+
* output to the given outputPath. Ensures the output directory exists and verifies the produced file is non-empty.
|
|
265
|
+
*
|
|
266
|
+
* @param {string} bundler - The bundler to invoke: 'swagger-cli', 'redocly', 'npx redocly', or 'npx @redocly/cli'.
|
|
267
|
+
* @param {string[]|string} fragmentFiles - Array of fragment file paths to merge or a single entrypoint file path.
|
|
268
|
+
* @param {string} outputPath - Filesystem path where the bundled OpenAPI YAML will be written.
|
|
269
|
+
* @param {string} tempDir - Existing temporary directory used to create a merged entrypoint when multiple fragments are provided.
|
|
270
|
+
* @param {boolean} [quiet=false] - If true, suppresses console output from this function and child process stdio.
|
|
271
|
+
* @throws {Error} If input validation fails, the bundler process times out or exits with an error, or the output file is missing or empty.
|
|
272
|
+
*/
|
|
273
|
+
function runBundler(bundler, fragmentFiles, outputPath, tempDir, quiet = false) {
|
|
274
|
+
if (!bundler || typeof bundler !== 'string') {
|
|
275
|
+
throw new Error('Invalid bundler specified');
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
if (!fragmentFiles || (Array.isArray(fragmentFiles) && fragmentFiles.length === 0)) {
|
|
279
|
+
throw new Error('No fragment files provided for bundling');
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
if (!outputPath || typeof outputPath !== 'string') {
|
|
283
|
+
throw new Error('Invalid output path specified');
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
if (!tempDir || !fs.existsSync(tempDir)) {
|
|
287
|
+
throw new Error('Invalid temporary directory');
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
const stdio = quiet ? 'ignore' : 'inherit';
|
|
291
|
+
const timeout = 120000; // 2 minutes timeout
|
|
292
|
+
|
|
293
|
+
// If we have multiple fragments, we need to merge them first since bundlers
|
|
294
|
+
// typically expect a single entrypoint file
|
|
295
|
+
let entrypoint;
|
|
296
|
+
|
|
297
|
+
try {
|
|
298
|
+
if (Array.isArray(fragmentFiles) && fragmentFiles.length > 1) {
|
|
299
|
+
// Create a merged entrypoint file
|
|
300
|
+
entrypoint = path.join(tempDir, 'merged-entrypoint.yaml');
|
|
301
|
+
|
|
302
|
+
const mergedContent = {
|
|
303
|
+
openapi: '3.1.0',
|
|
304
|
+
info: {
|
|
305
|
+
title: 'Redpanda Admin API',
|
|
306
|
+
version: '2.0.0'
|
|
307
|
+
},
|
|
308
|
+
paths: {},
|
|
309
|
+
components: {
|
|
310
|
+
schemas: {}
|
|
311
|
+
}
|
|
312
|
+
};
|
|
313
|
+
|
|
314
|
+
// Manually merge all fragment files
|
|
315
|
+
for (const filePath of fragmentFiles) {
|
|
316
|
+
try {
|
|
317
|
+
if (!fs.existsSync(filePath)) {
|
|
318
|
+
console.warn(`⚠️ Fragment file not found: ${filePath}`);
|
|
319
|
+
continue;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
const fragmentContent = fs.readFileSync(filePath, 'utf8');
|
|
323
|
+
const fragmentData = yaml.parse(fragmentContent);
|
|
324
|
+
|
|
325
|
+
if (!fragmentData || typeof fragmentData !== 'object') {
|
|
326
|
+
console.warn(`⚠️ Invalid fragment data in: ${filePath}`);
|
|
327
|
+
continue;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// Merge paths
|
|
331
|
+
if (fragmentData.paths && typeof fragmentData.paths === 'object') {
|
|
332
|
+
Object.assign(mergedContent.paths, fragmentData.paths);
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// Merge components
|
|
336
|
+
if (fragmentData.components && typeof fragmentData.components === 'object') {
|
|
337
|
+
if (fragmentData.components.schemas) {
|
|
338
|
+
Object.assign(mergedContent.components.schemas, fragmentData.components.schemas);
|
|
339
|
+
}
|
|
340
|
+
// Merge other component types
|
|
341
|
+
const componentTypes = ['responses', 'parameters', 'examples', 'requestBodies', 'headers', 'securitySchemes', 'links', 'callbacks'];
|
|
342
|
+
for (const componentType of componentTypes) {
|
|
343
|
+
if (fragmentData.components[componentType]) {
|
|
344
|
+
if (!mergedContent.components[componentType]) {
|
|
345
|
+
mergedContent.components[componentType] = {};
|
|
346
|
+
}
|
|
347
|
+
Object.assign(mergedContent.components[componentType], fragmentData.components[componentType]);
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
} catch (error) {
|
|
352
|
+
console.warn(`⚠️ Failed to parse fragment ${filePath}: ${error.message}`);
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
// Validate merged content
|
|
357
|
+
if (Object.keys(mergedContent.paths).length === 0) {
|
|
358
|
+
throw new Error('No valid paths found in any fragments');
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
fs.writeFileSync(entrypoint, yaml.stringify(mergedContent), 'utf8');
|
|
362
|
+
|
|
363
|
+
if (!quiet) {
|
|
364
|
+
console.log(`📄 Created merged entrypoint with ${Object.keys(mergedContent.paths).length} paths`);
|
|
365
|
+
}
|
|
366
|
+
} else {
|
|
367
|
+
// Single file or string entrypoint
|
|
368
|
+
entrypoint = Array.isArray(fragmentFiles) ? fragmentFiles[0] : fragmentFiles;
|
|
369
|
+
|
|
370
|
+
if (!fs.existsSync(entrypoint)) {
|
|
371
|
+
throw new Error(`Entrypoint file not found: ${entrypoint}`);
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
// Ensure output directory exists
|
|
376
|
+
const outputDir = path.dirname(outputPath);
|
|
377
|
+
fs.mkdirSync(outputDir, { recursive: true });
|
|
378
|
+
|
|
379
|
+
let result;
|
|
380
|
+
if (bundler === 'swagger-cli') {
|
|
381
|
+
result = spawnSync('swagger-cli', ['bundle', entrypoint, '-o', outputPath, '-t', 'yaml'], {
|
|
382
|
+
stdio,
|
|
383
|
+
timeout
|
|
384
|
+
});
|
|
385
|
+
} else if (bundler === 'redocly') {
|
|
386
|
+
result = spawnSync('redocly', ['bundle', entrypoint, '--output', outputPath], {
|
|
387
|
+
stdio,
|
|
388
|
+
timeout
|
|
389
|
+
});
|
|
390
|
+
} else if (bundler === 'npx redocly') {
|
|
391
|
+
result = spawnSync('npx', ['redocly', 'bundle', entrypoint, '--output', outputPath], {
|
|
392
|
+
stdio,
|
|
393
|
+
timeout
|
|
394
|
+
});
|
|
395
|
+
} else if (bundler === 'npx @redocly/cli') {
|
|
396
|
+
result = spawnSync('npx', ['@redocly/cli', 'bundle', entrypoint, '--output', outputPath], {
|
|
397
|
+
stdio,
|
|
398
|
+
timeout
|
|
399
|
+
});
|
|
400
|
+
} else {
|
|
401
|
+
throw new Error(`Unknown bundler: ${bundler}`);
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
if (result.error) {
|
|
405
|
+
if (result.error.code === 'ETIMEDOUT') {
|
|
406
|
+
throw new Error(`Bundler timed out after ${timeout / 1000} seconds`);
|
|
407
|
+
}
|
|
408
|
+
throw new Error(`Bundler execution failed: ${result.error.message}`);
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
if (result.status !== 0) {
|
|
412
|
+
const errorMsg = result.stderr ? result.stderr.toString() : 'Unknown error';
|
|
413
|
+
throw new Error(`${bundler} bundle failed with exit code ${result.status}: ${errorMsg}`);
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
// Verify output file was created
|
|
417
|
+
if (!fs.existsSync(outputPath)) {
|
|
418
|
+
throw new Error(`Bundler completed but output file not found: ${outputPath}`);
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
const stats = fs.statSync(outputPath);
|
|
422
|
+
if (stats.size === 0) {
|
|
423
|
+
throw new Error(`Bundler created empty output file: ${outputPath}`);
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
if (!quiet) {
|
|
427
|
+
console.log(`✅ Bundle created: ${outputPath} (${Math.round(stats.size / 1024)}KB)`);
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
} catch (error) {
|
|
431
|
+
// Clean up temporary entrypoint file on error
|
|
432
|
+
if (entrypoint && entrypoint !== fragmentFiles && fs.existsSync(entrypoint)) {
|
|
433
|
+
try {
|
|
434
|
+
fs.unlinkSync(entrypoint);
|
|
435
|
+
} catch {
|
|
436
|
+
// Ignore cleanup errors
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
throw error;
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
/**
|
|
444
|
+
* Update bundle metadata, enforce a deterministic key order, and rewrite the bundled OpenAPI YAML.
|
|
445
|
+
*
|
|
446
|
+
* Reads the bundled YAML at `filePath`, validates and augments its `info` object (titles, descriptions,
|
|
447
|
+
* version fields and x- metadata) based on `options.surface` and provided version information, sorts
|
|
448
|
+
* object keys deterministically, and writes the updated YAML back to `filePath`.
|
|
449
|
+
*
|
|
450
|
+
* @param {string} filePath - Path to the bundled OpenAPI YAML file to process.
|
|
451
|
+
* @param {Object} options - Processing options.
|
|
452
|
+
* @param {'admin'|'connect'} options.surface - API surface to target; affects title and description.
|
|
453
|
+
* @param {string} [options.tag] - Git tag used for versioning (may be normalized internally).
|
|
454
|
+
* @param {string} [options.normalizedTag] - Pre-normalized version string to use instead of `tag`.
|
|
455
|
+
* @param {string} [options.majorMinor] - Major.minor version to set in `info.version`.
|
|
456
|
+
* @param {string} [options.adminMajor] - Admin API major version to set as `x-admin-api-major`.
|
|
457
|
+
* @param {boolean} [options.useAdminMajorVersion] - When true and surface is 'admin', prefer `adminMajor` for `info.version`.
|
|
458
|
+
* @param {boolean} [quiet=false] - Suppress console output when true.
|
|
459
|
+
* @returns {Object} The processed OpenAPI bundle object with keys sorted deterministically.
|
|
460
|
+
* @throws {Error} If inputs are missing/invalid, the file is absent or empty, YAML parsing fails, or processing cannot complete.
|
|
461
|
+
*/
|
|
462
|
+
function postProcessBundle(filePath, options, quiet = false) {
|
|
463
|
+
if (!filePath || typeof filePath !== 'string') {
|
|
464
|
+
throw new Error('Bundle file not found');
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
if (!fs.existsSync(filePath)) {
|
|
468
|
+
throw new Error('Bundle file not found');
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
if (!options || typeof options !== 'object') {
|
|
472
|
+
throw new Error('Missing required options');
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
const { surface, tag, majorMinor, adminMajor, normalizedTag, useAdminMajorVersion } = options;
|
|
476
|
+
|
|
477
|
+
if (!surface || !['admin', 'connect'].includes(surface)) {
|
|
478
|
+
throw new Error('Invalid API surface');
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
// Require at least one version identifier
|
|
482
|
+
if (!tag && !normalizedTag && !majorMinor) {
|
|
483
|
+
throw new Error('Missing required options');
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
try {
|
|
487
|
+
const content = fs.readFileSync(filePath, 'utf8');
|
|
488
|
+
if (!content.trim()) {
|
|
489
|
+
throw new Error('Bundle file is empty');
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
let bundle;
|
|
493
|
+
try {
|
|
494
|
+
bundle = yaml.parse(content);
|
|
495
|
+
} catch (parseError) {
|
|
496
|
+
throw new Error(`Invalid YAML in bundle file: ${parseError.message}`);
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
if (!bundle || typeof bundle !== 'object') {
|
|
500
|
+
throw new Error('Bundle file does not contain valid OpenAPI structure');
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
// Normalize the tag and extract version info
|
|
504
|
+
const normalizedVersion = normalizedTag || (tag ? normalizeTag(tag) : '1.0.0');
|
|
505
|
+
let versionMajorMinor;
|
|
506
|
+
|
|
507
|
+
if (useAdminMajorVersion && surface === 'admin' && adminMajor) {
|
|
508
|
+
// Use admin major version for info.version when flag is set
|
|
509
|
+
versionMajorMinor = adminMajor;
|
|
510
|
+
} else {
|
|
511
|
+
// Use normalized tag version (default behavior)
|
|
512
|
+
versionMajorMinor = majorMinor || (normalizedVersion !== '1.0.0' ? getMajorMinor(normalizedVersion) : '1.0');
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
// Update info section with proper metadata
|
|
516
|
+
if (!bundle.info) {
|
|
517
|
+
bundle.info = {};
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
bundle.info.version = versionMajorMinor;
|
|
521
|
+
|
|
522
|
+
if (surface === 'admin') {
|
|
523
|
+
bundle.info.title = 'Redpanda Admin API';
|
|
524
|
+
bundle.info.description = 'Redpanda Admin API specification';
|
|
525
|
+
if (adminMajor) {
|
|
526
|
+
bundle.info['x-admin-api-major'] = adminMajor;
|
|
527
|
+
}
|
|
528
|
+
} else if (surface === 'connect') {
|
|
529
|
+
bundle.info.title = 'Redpanda Connect RPCs';
|
|
530
|
+
bundle.info.description = 'Redpanda Connect API specification';
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
// Additional metadata expected by tests
|
|
534
|
+
if (tag || normalizedTag) {
|
|
535
|
+
bundle.info['x-redpanda-core-version'] = tag || normalizedTag || normalizedVersion;
|
|
536
|
+
}
|
|
537
|
+
bundle.info['x-generated-at'] = new Date().toISOString();
|
|
538
|
+
bundle.info['x-generator'] = 'redpanda-docs-openapi-bundler';
|
|
539
|
+
|
|
540
|
+
// Sort keys for deterministic output
|
|
541
|
+
const sortedBundle = sortObjectKeys(bundle);
|
|
542
|
+
|
|
543
|
+
// Write back to file
|
|
544
|
+
fs.writeFileSync(filePath, yaml.stringify(sortedBundle, {
|
|
545
|
+
lineWidth: 0,
|
|
546
|
+
minContentWidth: 0,
|
|
547
|
+
indent: 2
|
|
548
|
+
}), 'utf8');
|
|
549
|
+
|
|
550
|
+
if (!quiet) {
|
|
551
|
+
console.log(`📝 Updated bundle metadata: version=${normalizedVersion}`);
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
return sortedBundle;
|
|
555
|
+
|
|
556
|
+
} catch (error) {
|
|
557
|
+
throw new Error(`Post-processing failed: ${error.message}`);
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
/**
|
|
562
|
+
* Bundle OpenAPI fragments for the specified API surface(s) from a repository tag and write the resulting bundled YAML files to disk.
|
|
563
|
+
*
|
|
564
|
+
* @param {Object} options - Configuration options.
|
|
565
|
+
* @param {string} options.tag - Git tag to checkout (e.g., 'v25.1.1').
|
|
566
|
+
* @param {'admin'|'connect'|'both'} options.surface - API surface to process.
|
|
567
|
+
* @param {string} [options.output] - Standalone output file path; when provided, used for the single output file.
|
|
568
|
+
* @param {string} [options.outAdmin] - Output path for the admin API when integrating with doc-tools mode.
|
|
569
|
+
* @param {string} [options.outConnect] - Output path for the connect API when integrating with doc-tools mode.
|
|
570
|
+
* @param {string} [options.repo] - Repository URL to clone (defaults to https://github.com/redpanda-data/redpanda.git).
|
|
571
|
+
* @param {string} [options.adminMajor] - Admin API major version string used for metadata (e.g., 'v2.0.0').
|
|
572
|
+
* @param {boolean} [options.useAdminMajorVersion] - When true and processing the admin surface, use `adminMajor` for the bundle info.version.
|
|
573
|
+
* @param {boolean} [options.quiet=false] - Suppress logging to stdout/stderr when true.
|
|
574
|
+
* @returns {Object|Object[]} An object (for a single surface) or an array of objects (for both surfaces) with fields:
|
|
575
|
+
* - surface: processed surface name ('admin' or 'connect'),
|
|
576
|
+
* - outputPath: final written file path,
|
|
577
|
+
* - fragmentCount: number of OpenAPI fragment files processed,
|
|
578
|
+
* - bundler: name or command of the bundler used.
|
|
579
|
+
*/
|
|
580
|
+
async function bundleOpenAPI(options) {
|
|
581
|
+
const { tag, surface, output, outAdmin, outConnect, repo, adminMajor, useAdminMajorVersion, quiet = false } = options;
|
|
582
|
+
|
|
583
|
+
// Validate required parameters
|
|
584
|
+
if (!tag) {
|
|
585
|
+
throw new Error('Git tag is required');
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
if (!surface || !['admin', 'connect', 'both'].includes(surface)) {
|
|
589
|
+
throw new Error('API surface must be "admin", "connect", or "both"');
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
// Handle different surface options
|
|
593
|
+
const surfaces = surface === 'both' ? ['admin', 'connect'] : [surface];
|
|
594
|
+
const results = [];
|
|
595
|
+
|
|
596
|
+
const tempDir = fs.mkdtempSync(path.join(process.cwd(), 'openapi-bundle-'));
|
|
597
|
+
|
|
598
|
+
// Set up cleanup handlers
|
|
599
|
+
const cleanup = () => {
|
|
600
|
+
try {
|
|
601
|
+
if (fs.existsSync(tempDir)) {
|
|
602
|
+
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
603
|
+
}
|
|
604
|
+
} catch (error) {
|
|
605
|
+
console.error(`Warning: Failed to cleanup temporary directory: ${error.message}`);
|
|
606
|
+
}
|
|
607
|
+
};
|
|
608
|
+
|
|
609
|
+
// Create dedicated handlers that clean up and then terminate
|
|
610
|
+
const cleanupAndExit = (signal) => {
|
|
611
|
+
return () => {
|
|
612
|
+
cleanup();
|
|
613
|
+
process.exit(signal === 'SIGTERM' ? 0 : 1);
|
|
614
|
+
};
|
|
615
|
+
};
|
|
616
|
+
|
|
617
|
+
const cleanupAndCrash = (error) => {
|
|
618
|
+
cleanup();
|
|
619
|
+
console.error('Fatal error:', error);
|
|
620
|
+
process.exit(1);
|
|
621
|
+
};
|
|
622
|
+
|
|
623
|
+
// Handle graceful shutdown and crashes
|
|
624
|
+
const sigintHandler = cleanupAndExit('SIGINT');
|
|
625
|
+
const sigtermHandler = cleanupAndExit('SIGTERM');
|
|
626
|
+
|
|
627
|
+
process.on('SIGINT', sigintHandler);
|
|
628
|
+
process.on('SIGTERM', sigtermHandler);
|
|
629
|
+
process.on('uncaughtException', cleanupAndCrash);
|
|
630
|
+
|
|
631
|
+
try {
|
|
632
|
+
// Clone repository (only once for all surfaces)
|
|
633
|
+
if (!quiet) {
|
|
634
|
+
console.log('📥 Cloning redpanda repository...');
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
const repositoryUrl = repo || 'https://github.com/redpanda-data/redpanda.git';
|
|
638
|
+
|
|
639
|
+
try {
|
|
640
|
+
execSync(`git clone --depth 1 --branch ${tag} ${repositoryUrl} redpanda`, {
|
|
641
|
+
cwd: tempDir,
|
|
642
|
+
stdio: quiet ? 'ignore' : 'inherit',
|
|
643
|
+
timeout: 60000 // 1 minute timeout
|
|
644
|
+
});
|
|
645
|
+
} catch (cloneError) {
|
|
646
|
+
throw new Error(`Failed to clone repository: ${cloneError.message}`);
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
const repoDir = path.join(tempDir, 'redpanda');
|
|
650
|
+
|
|
651
|
+
// Verify repository was cloned
|
|
652
|
+
if (!fs.existsSync(repoDir)) {
|
|
653
|
+
throw new Error('Repository clone failed - directory not found');
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
// Run buf generate
|
|
657
|
+
if (!quiet) {
|
|
658
|
+
console.log('🔧 Running buf generate...');
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
try {
|
|
662
|
+
execSync('buf generate --template buf.gen.openapi.yaml', {
|
|
663
|
+
cwd: repoDir,
|
|
664
|
+
stdio: quiet ? 'ignore' : 'inherit',
|
|
665
|
+
timeout: 120000 // 2 minutes timeout
|
|
666
|
+
});
|
|
667
|
+
} catch (bufError) {
|
|
668
|
+
throw new Error(`buf generate failed: ${bufError.message}`);
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
// Process each surface
|
|
672
|
+
for (const currentSurface of surfaces) {
|
|
673
|
+
// Determine output path based on mode (standalone vs doc-tools integration)
|
|
674
|
+
let finalOutput;
|
|
675
|
+
if (output) {
|
|
676
|
+
// Standalone mode with explicit output
|
|
677
|
+
finalOutput = output;
|
|
678
|
+
} else if (currentSurface === 'admin' && outAdmin) {
|
|
679
|
+
// Doc-tools mode with admin output
|
|
680
|
+
finalOutput = outAdmin;
|
|
681
|
+
} else if (currentSurface === 'connect' && outConnect) {
|
|
682
|
+
// Doc-tools mode with connect output
|
|
683
|
+
finalOutput = outConnect;
|
|
684
|
+
} else {
|
|
685
|
+
// Default paths
|
|
686
|
+
finalOutput = currentSurface === 'admin'
|
|
687
|
+
? 'admin/redpanda-admin-api.yaml'
|
|
688
|
+
: 'connect/redpanda-connect-api.yaml';
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
if (!quiet) {
|
|
692
|
+
console.log(`🚀 Bundling OpenAPI for ${currentSurface} API (tag: ${tag})`);
|
|
693
|
+
console.log(`📁 Output: ${finalOutput}`);
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
// Find OpenAPI fragments
|
|
697
|
+
const fragmentFiles = createEntrypoint(repoDir, currentSurface);
|
|
698
|
+
|
|
699
|
+
if (!quiet) {
|
|
700
|
+
console.log(`📋 Found ${fragmentFiles.length} OpenAPI fragments`);
|
|
701
|
+
fragmentFiles.forEach(file => {
|
|
702
|
+
const relativePath = path.relative(repoDir, file);
|
|
703
|
+
console.log(` ${relativePath}`);
|
|
704
|
+
});
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
// Detect and use bundler
|
|
708
|
+
const bundler = detectBundler(quiet);
|
|
709
|
+
|
|
710
|
+
// Bundle the OpenAPI fragments
|
|
711
|
+
if (!quiet) {
|
|
712
|
+
console.log('🔄 Bundling OpenAPI fragments...');
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
const tempOutput = path.join(tempDir, `bundled-${currentSurface}.yaml`);
|
|
716
|
+
await runBundler(bundler, fragmentFiles, tempOutput, tempDir, quiet);
|
|
717
|
+
|
|
718
|
+
// Post-process the bundle
|
|
719
|
+
if (!quiet) {
|
|
720
|
+
console.log('📝 Post-processing bundle...');
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
const postProcessOptions = {
|
|
724
|
+
surface: currentSurface,
|
|
725
|
+
tag: tag,
|
|
726
|
+
majorMinor: getMajorMinor(normalizeTag(tag)),
|
|
727
|
+
adminMajor: adminMajor,
|
|
728
|
+
useAdminMajorVersion: useAdminMajorVersion
|
|
729
|
+
};
|
|
730
|
+
|
|
731
|
+
postProcessBundle(tempOutput, postProcessOptions, quiet);
|
|
732
|
+
|
|
733
|
+
// Move to final output location
|
|
734
|
+
const outputDir = path.dirname(finalOutput);
|
|
735
|
+
if (!fs.existsSync(outputDir)) {
|
|
736
|
+
fs.mkdirSync(outputDir, { recursive: true });
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
fs.copyFileSync(tempOutput, finalOutput);
|
|
740
|
+
|
|
741
|
+
if (!quiet) {
|
|
742
|
+
const stats = fs.statSync(finalOutput);
|
|
743
|
+
console.log(`✅ Bundle complete: ${finalOutput} (${Math.round(stats.size / 1024)}KB)`);
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
results.push({
|
|
747
|
+
surface: currentSurface,
|
|
748
|
+
outputPath: finalOutput,
|
|
749
|
+
fragmentCount: fragmentFiles.length,
|
|
750
|
+
bundler: bundler
|
|
751
|
+
});
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
return results.length === 1 ? results[0] : results;
|
|
755
|
+
|
|
756
|
+
} catch (error) {
|
|
757
|
+
if (!quiet) {
|
|
758
|
+
console.error(`❌ Bundling failed: ${error.message}`);
|
|
759
|
+
}
|
|
760
|
+
throw error;
|
|
761
|
+
} finally {
|
|
762
|
+
// Remove event handlers to restore default behavior
|
|
763
|
+
process.removeListener('SIGINT', sigintHandler);
|
|
764
|
+
process.removeListener('SIGTERM', sigtermHandler);
|
|
765
|
+
process.removeListener('uncaughtException', cleanupAndCrash);
|
|
766
|
+
|
|
767
|
+
cleanup();
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
// Export functions for testing
|
|
772
|
+
module.exports = {
|
|
773
|
+
bundleOpenAPI,
|
|
774
|
+
normalizeTag,
|
|
775
|
+
getMajorMinor,
|
|
776
|
+
sortObjectKeys,
|
|
777
|
+
detectBundler,
|
|
778
|
+
createEntrypoint,
|
|
779
|
+
postProcessBundle
|
|
780
|
+
};
|
|
781
|
+
|
|
782
|
+
// CLI interface if run directly
|
|
783
|
+
if (require.main === module) {
|
|
784
|
+
const { program } = require('commander');
|
|
785
|
+
|
|
786
|
+
program
|
|
787
|
+
.name('bundle-openapi')
|
|
788
|
+
.description('Bundle OpenAPI fragments from Redpanda repository')
|
|
789
|
+
.requiredOption('-t, --tag <tag>', 'Git tag to checkout (e.g., v25.1.1)')
|
|
790
|
+
.requiredOption('-s, --surface <surface>', 'API surface', (value) => {
|
|
791
|
+
if (!['admin', 'connect', 'both'].includes(value)) {
|
|
792
|
+
throw new Error('Invalid API surface. Must be "admin", "connect", or "both"');
|
|
793
|
+
}
|
|
794
|
+
return value;
|
|
795
|
+
})
|
|
796
|
+
.option('-o, --output <path>', 'Output file path (defaults: admin/redpanda-admin-api.yaml or connect/redpanda-connect-api.yaml)')
|
|
797
|
+
.option('--out-admin <path>', 'Output path for admin API', 'admin/redpanda-admin-api.yaml')
|
|
798
|
+
.option('--out-connect <path>', 'Output path for connect API', 'connect/redpanda-connect-api.yaml')
|
|
799
|
+
.option('--repo <url>', 'Repository URL', 'https://github.com/redpanda-data/redpanda.git')
|
|
800
|
+
.option('--admin-major <string>', 'Admin API major version', 'v2.0.0')
|
|
801
|
+
.option('--use-admin-major-version', 'Use admin major version for info.version instead of git tag', false)
|
|
802
|
+
.option('-q, --quiet', 'Suppress output', false)
|
|
803
|
+
.action(async (options) => {
|
|
804
|
+
try {
|
|
805
|
+
await bundleOpenAPI(options);
|
|
806
|
+
process.exit(0);
|
|
807
|
+
} catch (error) {
|
|
808
|
+
console.error(`Error: ${error.message}`);
|
|
809
|
+
process.exit(1);
|
|
810
|
+
}
|
|
811
|
+
});
|
|
812
|
+
|
|
813
|
+
program.parse();
|
|
814
|
+
}
|