Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
515 changes: 493 additions & 22 deletions apps/load-tests/src/cli/infra.ts

Large diffs are not rendered by default.

19 changes: 16 additions & 3 deletions apps/load-tests/src/cli/results.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
#!/usr/bin/env node
import chalk from "chalk";
import { Command } from "commander";
import {
aggregateResults,
printAggregatedResults,
} from "../results/aggregate.js";

const program = new Command();

Expand All @@ -12,9 +16,18 @@ program
program
.command("aggregate")
.description("Aggregate results from multiple load test runs")
.action(() => {
console.log(chalk.yellow("[results aggregate] Not implemented yet - coming in next PR"));
.requiredOption("--input <dir>", "Directory containing result JSON files")
.action((options: { input: string }) => {
try {
console.log(chalk.cyan(`[results] Aggregating results from ${options.input}...`));
console.log("");

const aggregated = aggregateResults(options.input);
printAggregatedResults(aggregated);
} catch (error) {
console.error(chalk.red(`[results] Error: ${(error as Error).message}`));
process.exit(1);
}
});

program.parse();

115 changes: 115 additions & 0 deletions apps/load-tests/src/infra/collect.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import * as fs from "node:fs";
import * as path from "node:path";
import chalk from "chalk";
import { downloadFile } from "./ssh.js";
import type { DropletInfo } from "./types.js";

/**
* Result of collecting a file from a droplet.
*/
export interface CollectResult {
dropletName: string;
success: boolean;
localPath?: string;
fileSize?: number;
error?: string;
}

/**
* Collect files from multiple droplets.
*/
export async function collectFromDroplets(
droplets: DropletInfo[],
remotePath: string,
outputDir: string,
privateKeyPath: string,
onProgress?: (droplet: DropletInfo, status: "downloading" | "done" | "failed") => void,
): Promise<CollectResult[]> {
// Create output directory
if (!fs.existsSync(outputDir)) {
fs.mkdirSync(outputDir, { recursive: true });
}

const results: CollectResult[] = [];

const collectPromises = droplets.map(async (droplet) => {
if (!droplet.ip) {
const result: CollectResult = {
dropletName: droplet.name,
success: false,
error: "No IP address",
};
results.push(result);
onProgress?.(droplet, "failed");
return result;
}

onProgress?.(droplet, "downloading");

const localPath = path.join(outputDir, `${droplet.name}.json`);

try {
await downloadFile(droplet.ip, remotePath, localPath, privateKeyPath);

// Get file size
const stats = fs.statSync(localPath);
const result: CollectResult = {
dropletName: droplet.name,
success: true,
localPath,
fileSize: stats.size,
};
results.push(result);
onProgress?.(droplet, "done");
return result;
} catch (error) {
const result: CollectResult = {
dropletName: droplet.name,
success: false,
error: (error as Error).message,
};
results.push(result);
onProgress?.(droplet, "failed");
return result;
}
});

await Promise.all(collectPromises);
return results;
}

/**
* Format file size in a human-readable way.
*/
export function formatFileSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
const kb = bytes / 1024;
if (kb < 1024) return `${kb.toFixed(1)} KB`;
const mb = kb / 1024;
return `${mb.toFixed(1)} MB`;
}

/**
* Print collection results summary.
*/
export function printCollectResults(results: CollectResult[]): void {
const successful = results.filter((r) => r.success);
const failed = results.filter((r) => !r.success);

console.log("");
if (failed.length === 0) {
console.log(chalk.green(`[infra collect] Complete! ${successful.length}/${results.length} files downloaded.`));
} else {
console.log(chalk.yellow(`[infra collect] Done. ${successful.length}/${results.length} downloaded, ${failed.length} failed.`));
}
console.log("");

for (const result of results) {
if (result.success) {
console.log(` ${result.dropletName.padEnd(14)} ${chalk.green("✓")} downloaded (${formatFileSize(result.fileSize ?? 0)})`);
} else {
console.log(` ${result.dropletName.padEnd(14)} ${chalk.red("✗")} ${result.error}`);
}
}
}

73 changes: 73 additions & 0 deletions apps/load-tests/src/infra/config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import { config as loadDotenv } from "dotenv";
import type { InfraConfig, SshConfig } from "./types.js";

// Load .env file from the load-tests directory
const envPath = path.resolve(import.meta.dirname, "../../.env");
loadDotenv({ path: envPath });

/**
* Load and validate infrastructure configuration from environment variables.
* Throws an error if required variables are missing.
*/
export function loadInfraConfig(): InfraConfig {
const digitalOceanToken = process.env.DIGITALOCEAN_TOKEN;
const sshKeyFingerprint = process.env.SSH_KEY_FINGERPRINT;
const sshPrivateKeyPath = process.env.SSH_PRIVATE_KEY_PATH ?? "~/.ssh/id_rsa";

if (!digitalOceanToken) {
throw new Error(
"DIGITALOCEAN_TOKEN is required.\n" +
"Set it in apps/load-tests/.env or as an environment variable.\n" +
"Get a token from: https://cloud.digitalocean.com/account/api/tokens"
);
}

if (!sshKeyFingerprint) {
throw new Error(
"SSH_KEY_FINGERPRINT is required.\n" +
"Set it in apps/load-tests/.env or as an environment variable.\n" +
"Find your SSH key fingerprint at: https://cloud.digitalocean.com/account/security"
);
}

// Expand ~ to home directory
const expandedKeyPath = sshPrivateKeyPath.replace(/^~/, os.homedir());

return {
digitalOceanToken,
sshKeyFingerprint,
sshPrivateKeyPath: expandedKeyPath,
};
}

/**
* Get SSH configuration for connecting to droplets.
*/
export function getSshConfig(config: InfraConfig): SshConfig {
return {
privateKeyPath: config.sshPrivateKeyPath,
username: "root",
port: 22,
};
}

/**
* Read the SSH private key from disk.
* Throws an error if the file doesn't exist.
*/
export function readSshPrivateKey(config: InfraConfig): string {
const keyPath = config.sshPrivateKeyPath;

if (!fs.existsSync(keyPath)) {
throw new Error(
`SSH private key not found at: ${keyPath}\n` +
"Set SSH_PRIVATE_KEY_PATH in apps/load-tests/.env to the correct path."
);
}

return fs.readFileSync(keyPath, "utf-8");
}

Loading