@rulebricks/cli 1.9.0 → 2.0.1

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,466 @@
1
+ /**
2
+ * Benchmark utilities for running k6 load tests against Rulebricks deployments
3
+ */
4
+ import { execa } from "execa";
5
+ import { promises as fs } from "fs";
6
+ import path from "path";
7
+ import os from "os";
8
+ import { spawn } from "child_process";
9
+ import { BLOCKED_BENCHMARK_DOMAINS, } from "../types/index.js";
10
+ // Directory for benchmark scripts and results
11
+ const RULEBRICKS_DIR = path.join(os.homedir(), ".rulebricks");
12
+ const BENCHMARKS_DIR = path.join(RULEBRICKS_DIR, "benchmarks");
13
+ /**
14
+ * Extracts meaningful error message from execa error
15
+ */
16
+ function extractExecaError(error) {
17
+ const execaError = error;
18
+ const output = execaError.stderr || execaError.stdout || "";
19
+ if (output) {
20
+ const lines = output.split("\n").filter((l) => l.trim());
21
+ if (lines.length > 0)
22
+ return lines[0];
23
+ }
24
+ return execaError.shortMessage || execaError.message || "Unknown error";
25
+ }
26
+ /**
27
+ * Check if k6 is installed and available
28
+ */
29
+ export async function isK6Installed() {
30
+ try {
31
+ await execa("k6", ["version"]);
32
+ return true;
33
+ }
34
+ catch {
35
+ return false;
36
+ }
37
+ }
38
+ /**
39
+ * Get k6 version string
40
+ */
41
+ export async function getK6Version() {
42
+ try {
43
+ const { stdout } = await execa("k6", ["version"]);
44
+ return stdout.trim();
45
+ }
46
+ catch {
47
+ return null;
48
+ }
49
+ }
50
+ /**
51
+ * Get k6 installation instructions based on OS
52
+ */
53
+ export function getK6InstallInstructions() {
54
+ const platform = process.platform;
55
+ switch (platform) {
56
+ case "darwin":
57
+ return "Install k6 with: brew install k6";
58
+ case "linux":
59
+ return "Install k6: https://k6.io/docs/get-started/installation/#linux";
60
+ case "win32":
61
+ return "Install k6 with: choco install k6 or winget install k6";
62
+ default:
63
+ return "Install k6: https://k6.io/docs/get-started/installation/";
64
+ }
65
+ }
66
+ /**
67
+ * Validate that a URL is a valid benchmark target (not a cloud URL)
68
+ */
69
+ export function isValidBenchmarkTarget(url) {
70
+ try {
71
+ const parsed = new URL(url);
72
+ const hostname = parsed.hostname.toLowerCase();
73
+ // Check against blocked domains
74
+ for (const blocked of BLOCKED_BENCHMARK_DOMAINS) {
75
+ if (hostname === blocked || hostname.endsWith(`.${blocked}`)) {
76
+ return {
77
+ valid: false,
78
+ reason: `Cannot benchmark against Rulebricks Cloud (${blocked}). Only private deployments managed by this CLI are allowed.`,
79
+ };
80
+ }
81
+ }
82
+ // Must be HTTPS for production deployments
83
+ if (parsed.protocol !== "https:" && parsed.protocol !== "http:") {
84
+ return {
85
+ valid: false,
86
+ reason: "URL must use http:// or https:// protocol",
87
+ };
88
+ }
89
+ return { valid: true };
90
+ }
91
+ catch {
92
+ return {
93
+ valid: false,
94
+ reason: "Invalid URL format",
95
+ };
96
+ }
97
+ }
98
+ /**
99
+ * Check if a deployment is healthy by calling the /api/health endpoint
100
+ * Returns true if the deployment responds with {"status":"OK"}
101
+ */
102
+ export async function checkDeploymentHealth(deploymentUrl) {
103
+ try {
104
+ // Ensure URL has protocol
105
+ const baseUrl = deploymentUrl.startsWith("http")
106
+ ? deploymentUrl
107
+ : `https://${deploymentUrl}`;
108
+ const cleanUrl = baseUrl.replace(/\/$/, "");
109
+ const healthUrl = `${cleanUrl}/api/health`;
110
+ // Use native fetch with a timeout
111
+ const controller = new AbortController();
112
+ const timeoutId = setTimeout(() => controller.abort(), 10000); // 10 second timeout
113
+ try {
114
+ const response = await fetch(healthUrl, {
115
+ method: "GET",
116
+ signal: controller.signal,
117
+ headers: {
118
+ Accept: "application/json",
119
+ },
120
+ });
121
+ clearTimeout(timeoutId);
122
+ if (!response.ok) {
123
+ return false;
124
+ }
125
+ const data = (await response.json());
126
+ // Check for the expected health response
127
+ return data.status === "OK";
128
+ }
129
+ catch {
130
+ clearTimeout(timeoutId);
131
+ return false;
132
+ }
133
+ }
134
+ catch {
135
+ return false;
136
+ }
137
+ }
138
+ /**
139
+ * Build the full API URL from deployment domain and flow slug
140
+ */
141
+ export function buildApiUrl(domain, flowSlug) {
142
+ // Ensure domain has protocol
143
+ const baseUrl = domain.startsWith("http") ? domain : `https://${domain}`;
144
+ // Remove trailing slash if present
145
+ const cleanUrl = baseUrl.replace(/\/$/, "");
146
+ // Build the flows API endpoint
147
+ return `${cleanUrl}/api/v1/flows/${flowSlug}`;
148
+ }
149
+ /**
150
+ * Ensure benchmark scripts directory exists and contains the test scripts
151
+ */
152
+ export async function ensureBenchmarkScripts() {
153
+ await fs.mkdir(BENCHMARKS_DIR, { recursive: true });
154
+ // Check if scripts exist, if not write them
155
+ const qpsScript = path.join(BENCHMARKS_DIR, "qps-test.js");
156
+ const throughputScript = path.join(BENCHMARKS_DIR, "throughput-test.js");
157
+ const libDir = path.join(BENCHMARKS_DIR, "lib");
158
+ const payloadScript = path.join(libDir, "payload.js");
159
+ const reportScript = path.join(libDir, "report.js");
160
+ // Create lib directory
161
+ await fs.mkdir(libDir, { recursive: true });
162
+ // Try to copy from package first (during development or installed package)
163
+ const packageBenchmarksDir = await findPackageBenchmarksDir();
164
+ if (packageBenchmarksDir) {
165
+ // Copy from package
166
+ await copyFile(path.join(packageBenchmarksDir, "qps-test.js"), qpsScript);
167
+ await copyFile(path.join(packageBenchmarksDir, "throughput-test.js"), throughputScript);
168
+ await copyFile(path.join(packageBenchmarksDir, "lib", "payload.js"), payloadScript);
169
+ await copyFile(path.join(packageBenchmarksDir, "lib", "report.js"), reportScript);
170
+ }
171
+ else {
172
+ // Scripts not found - this shouldn't happen in a properly installed package
173
+ throw new Error("Benchmark scripts not found. Please reinstall the CLI package.");
174
+ }
175
+ return BENCHMARKS_DIR;
176
+ }
177
+ /**
178
+ * Find the benchmarks directory in the package
179
+ */
180
+ async function findPackageBenchmarksDir() {
181
+ // Try relative to the current file (development)
182
+ const possiblePaths = [
183
+ // Development: src/lib/benchmark.ts -> benchmarks/
184
+ path.resolve(import.meta.dirname, "..", "..", "benchmarks"),
185
+ // Installed: dist/lib/benchmark.js -> benchmarks/
186
+ path.resolve(import.meta.dirname, "..", "..", "..", "benchmarks"),
187
+ ];
188
+ for (const p of possiblePaths) {
189
+ try {
190
+ await fs.access(path.join(p, "qps-test.js"));
191
+ return p;
192
+ }
193
+ catch {
194
+ // Continue to next path
195
+ }
196
+ }
197
+ return null;
198
+ }
199
+ /**
200
+ * Copy a file, creating destination directory if needed
201
+ */
202
+ async function copyFile(src, dest) {
203
+ try {
204
+ const content = await fs.readFile(src, "utf-8");
205
+ await fs.writeFile(dest, content, "utf-8");
206
+ }
207
+ catch (error) {
208
+ throw new Error(`Failed to copy ${src} to ${dest}: ${error}`);
209
+ }
210
+ }
211
+ /**
212
+ * Create the output directory for benchmark results
213
+ */
214
+ export async function createOutputDirectory(deploymentName) {
215
+ const timestamp = new Date()
216
+ .toISOString()
217
+ .replace(/[:.]/g, "-")
218
+ .replace("T", "_")
219
+ .slice(0, 19);
220
+ const dirName = `rulebricks-${deploymentName}-${timestamp}`;
221
+ const outputDir = path.join(process.cwd(), dirName);
222
+ await fs.mkdir(outputDir, { recursive: true });
223
+ return outputDir;
224
+ }
225
+ /**
226
+ * Save benchmark configuration to output directory
227
+ */
228
+ export async function saveBenchmarkConfig(outputDir, config) {
229
+ const configPath = path.join(outputDir, "config.json");
230
+ // Redact API key for security
231
+ const safeConfig = {
232
+ ...config,
233
+ apiKey: config.apiKey.slice(0, 8) + "..." + config.apiKey.slice(-4),
234
+ };
235
+ await fs.writeFile(configPath, JSON.stringify(safeConfig, null, 2), "utf-8");
236
+ }
237
+ /**
238
+ * Run a benchmark test
239
+ */
240
+ export async function runBenchmark(config, options = {}) {
241
+ // Ensure k6 is installed
242
+ const k6Installed = await isK6Installed();
243
+ if (!k6Installed) {
244
+ return {
245
+ success: false,
246
+ outputDir: "",
247
+ reportPath: "",
248
+ resultsPath: "",
249
+ error: `k6 is not installed. ${getK6InstallInstructions()}`,
250
+ };
251
+ }
252
+ // Validate target URL
253
+ const urlValidation = isValidBenchmarkTarget(config.apiUrl);
254
+ if (!urlValidation.valid) {
255
+ return {
256
+ success: false,
257
+ outputDir: "",
258
+ reportPath: "",
259
+ resultsPath: "",
260
+ error: urlValidation.reason,
261
+ };
262
+ }
263
+ // Ensure benchmark scripts exist
264
+ let scriptsDir;
265
+ try {
266
+ scriptsDir = await ensureBenchmarkScripts();
267
+ }
268
+ catch (error) {
269
+ return {
270
+ success: false,
271
+ outputDir: "",
272
+ reportPath: "",
273
+ resultsPath: "",
274
+ error: `Failed to set up benchmark scripts: ${error}`,
275
+ };
276
+ }
277
+ // Create output directory
278
+ const outputDir = await createOutputDirectory(config.deploymentName);
279
+ // Save config for reference
280
+ await saveBenchmarkConfig(outputDir, config);
281
+ // Determine which test script to run
282
+ const testScript = config.testMode === "qps"
283
+ ? path.join(scriptsDir, "qps-test.js")
284
+ : path.join(scriptsDir, "throughput-test.js");
285
+ const reportName = config.testMode === "qps" ? "qps-report.html" : "throughput-report.html";
286
+ const resultsName = config.testMode === "qps" ? "qps-results.json" : "throughput-results.json";
287
+ // Build environment variables for k6
288
+ const env = {
289
+ ...process.env,
290
+ API_URL: config.apiUrl,
291
+ API_KEY: config.apiKey,
292
+ TEST_DURATION: config.testDuration,
293
+ TARGET_RPS: config.targetRps.toString(),
294
+ };
295
+ if (config.testMode === "throughput" && config.bulkSize) {
296
+ env.BULK_SIZE = config.bulkSize.toString();
297
+ }
298
+ // Run k6
299
+ try {
300
+ const k6Process = spawn("k6", ["run", testScript], {
301
+ cwd: outputDir,
302
+ env,
303
+ stdio: ["ignore", "pipe", "pipe"],
304
+ });
305
+ let stdout = "";
306
+ let stderr = "";
307
+ k6Process.stdout?.on("data", (data) => {
308
+ const text = data.toString();
309
+ stdout += text;
310
+ if (options.onOutput) {
311
+ text.split("\n").forEach((line) => {
312
+ if (line.trim())
313
+ options.onOutput(line);
314
+ });
315
+ }
316
+ });
317
+ k6Process.stderr?.on("data", (data) => {
318
+ const text = data.toString();
319
+ stderr += text;
320
+ if (options.onOutput) {
321
+ text.split("\n").forEach((line) => {
322
+ if (line.trim())
323
+ options.onOutput(line);
324
+ });
325
+ }
326
+ });
327
+ // Wait for process to complete
328
+ const exitCode = await new Promise((resolve) => {
329
+ k6Process.on("close", (code) => {
330
+ resolve(code ?? 1);
331
+ });
332
+ });
333
+ const reportPath = path.join(outputDir, reportName);
334
+ const resultsPath = path.join(outputDir, resultsName);
335
+ // Check if report was generated
336
+ let reportExists = false;
337
+ try {
338
+ await fs.access(reportPath);
339
+ reportExists = true;
340
+ }
341
+ catch {
342
+ reportExists = false;
343
+ }
344
+ // Parse metrics from results JSON if available
345
+ let metrics;
346
+ try {
347
+ const resultsContent = await fs.readFile(resultsPath, "utf-8");
348
+ metrics = parseK6Results(resultsContent, config);
349
+ }
350
+ catch {
351
+ // Results file might not exist or be parseable
352
+ }
353
+ // k6 returns non-zero if thresholds fail, but we still consider it a success
354
+ // if the report was generated
355
+ if (reportExists) {
356
+ return {
357
+ success: true,
358
+ outputDir,
359
+ reportPath,
360
+ resultsPath,
361
+ metrics,
362
+ };
363
+ }
364
+ else {
365
+ return {
366
+ success: false,
367
+ outputDir,
368
+ reportPath,
369
+ resultsPath,
370
+ error: `k6 test failed (exit code ${exitCode}). ${stderr || stdout}`.trim(),
371
+ };
372
+ }
373
+ }
374
+ catch (error) {
375
+ return {
376
+ success: false,
377
+ outputDir,
378
+ reportPath: path.join(outputDir, reportName),
379
+ resultsPath: path.join(outputDir, resultsName),
380
+ error: extractExecaError(error),
381
+ };
382
+ }
383
+ }
384
+ /**
385
+ * Parse k6 JSON results into BenchmarkMetrics
386
+ */
387
+ function parseK6Results(jsonContent, config) {
388
+ const data = JSON.parse(jsonContent);
389
+ const metrics = data.metrics || {};
390
+ const totalRequests = metrics.http_reqs?.values?.count || 0;
391
+ const testDuration = (data.state?.testRunDurationMs || 0) / 1000;
392
+ const actualRps = testDuration > 0 ? totalRequests / testDuration : 0;
393
+ const result = {
394
+ actualRps,
395
+ successRate: (metrics.successes?.values?.rate || 0) * 100,
396
+ p50Latency: metrics.http_req_duration?.values?.med || 0,
397
+ p90Latency: metrics.http_req_duration?.values?.["p(90)"] || 0,
398
+ p95Latency: metrics.http_req_duration?.values?.["p(95)"] || 0,
399
+ p99Latency: metrics.http_req_duration?.values?.["p(99)"] || 0,
400
+ minLatency: metrics.http_req_duration?.values?.min || 0,
401
+ maxLatency: metrics.http_req_duration?.values?.max || 0,
402
+ avgLatency: metrics.http_req_duration?.values?.avg || 0,
403
+ totalRequests,
404
+ failedRequests: metrics.dropped_requests?.values?.count || 0,
405
+ testDuration,
406
+ dataSent: metrics.data_sent?.values?.count || 0,
407
+ dataReceived: metrics.data_received?.values?.count || 0,
408
+ maxVUs: metrics.vus_max?.values?.max || metrics.vus?.values?.max || 0,
409
+ };
410
+ // Add throughput-specific metrics
411
+ if (config.testMode === "throughput" && config.bulkSize) {
412
+ result.actualThroughput = actualRps * config.bulkSize;
413
+ result.totalPayloads =
414
+ metrics.total_payloads?.values?.count || totalRequests * config.bulkSize;
415
+ }
416
+ return result;
417
+ }
418
+ /**
419
+ * Open a file in the default browser/application
420
+ */
421
+ export async function openInBrowser(filePath) {
422
+ const platform = process.platform;
423
+ try {
424
+ switch (platform) {
425
+ case "darwin":
426
+ await execa("open", [filePath]);
427
+ break;
428
+ case "win32":
429
+ await execa("cmd", ["/c", "start", '""', filePath]);
430
+ break;
431
+ case "linux":
432
+ default:
433
+ await execa("xdg-open", [filePath]);
434
+ break;
435
+ }
436
+ }
437
+ catch {
438
+ // Silently fail if browser can't be opened
439
+ }
440
+ }
441
+ /**
442
+ * Format duration string (e.g., "4m") to human readable
443
+ */
444
+ export function formatDuration(duration) {
445
+ const match = duration.match(/^(\d+)(m|s|h)$/);
446
+ if (!match)
447
+ return duration;
448
+ const [, value, unit] = match;
449
+ const num = parseInt(value, 10);
450
+ switch (unit) {
451
+ case "s":
452
+ return `${num} second${num !== 1 ? "s" : ""}`;
453
+ case "m":
454
+ return `${num} minute${num !== 1 ? "s" : ""}`;
455
+ case "h":
456
+ return `${num} hour${num !== 1 ? "s" : ""}`;
457
+ default:
458
+ return duration;
459
+ }
460
+ }
461
+ /**
462
+ * Calculate expected throughput for display
463
+ */
464
+ export function calculateExpectedThroughput(targetRps, bulkSize) {
465
+ return bulkSize ? targetRps * bulkSize : targetRps;
466
+ }
package/dist/lib/dns.d.ts CHANGED
@@ -1,6 +1,8 @@
1
1
  import { DNSRecord } from "../types/index.js";
2
2
  /**
3
- * Checks if a DNS record resolves
3
+ * Checks if a DNS record resolves using multiple DNS resolvers for reliability.
4
+ * Tries system DNS first, then Google (8.8.8.8), then Cloudflare (1.1.1.1).
5
+ * Returns success if ANY resolver confirms the record matches the target.
4
6
  */
5
7
  export declare function checkDNSRecord(hostname: string, expectedTarget?: string): Promise<{
6
8
  resolved: boolean;