UNPKG

@mesh-tech/mesh-cli

Version:

CLI for Mesh platform development utilities

263 lines (262 loc) 11 kB
import { spawn } from "child_process"; import * as net from "net"; import * as fs from "fs"; import * as path from "path"; import { SecretsManagerClient, GetSecretValueCommand, } from "@aws-sdk/client-secrets-manager"; import { detectContext, getPlatformBastionInfo, getDatabaseUrl, rewriteDatabaseUrl, logInfo, logSuccess, logError, logWarn, } from "../../utils/index.js"; const DEFAULT_LOCAL_PORT = 5432; const MAX_RETRIES = 12; const RETRY_DELAY_MS = 5000; function findSstOutputs() { let dir = process.cwd(); const root = path.parse(dir).root; while (dir !== root) { const outputsPath = path.join(dir, ".sst", "outputs.json"); if (fs.existsSync(outputsPath)) { try { const content = fs.readFileSync(outputsPath, "utf-8"); const outputs = JSON.parse(content); if (outputs.databaseUrl) { const secretArnMatch = outputs.databaseUrl.match(/secretArn=([^&]+)/); const secretArn = secretArnMatch ? decodeURIComponent(secretArnMatch[1]) : undefined; return { databaseUrl: outputs.databaseUrl, databaseName: outputs.databaseName, secretArn, rootDir: dir, }; } } catch { } } dir = path.dirname(dir); } return null; } function checkPort(port) { return new Promise((resolve) => { const socket = new net.Socket(); socket.setTimeout(500); socket.on("connect", () => { socket.destroy(); resolve(true); }); socket.on("timeout", () => { socket.destroy(); resolve(false); }); socket.on("error", () => { socket.destroy(); resolve(false); }); socket.connect(port, "localhost"); }); } function sleep(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } async function startTunnelBackground(instanceId, rdsEndpoint, rdsPort, localPort) { logInfo("Starting database tunnel..."); const tunnel = spawn("aws", [ "ssm", "start-session", "--target", instanceId, "--document-name", "AWS-StartPortForwardingSessionToRemoteHost", "--parameters", JSON.stringify({ host: [rdsEndpoint], portNumber: [String(rdsPort)], localPortNumber: [String(localPort)], }), ], { stdio: ["ignore", "ignore", "ignore"] }); const maxAttempts = 15; for (let attempt = 0; attempt < maxAttempts; attempt++) { if (tunnel.exitCode !== null) { throw new Error("Tunnel process died"); } if (await checkPort(localPort)) { logSuccess(`Tunnel started (PID: ${tunnel.pid})`); return tunnel; } await sleep(1000); } tunnel.kill(); throw new Error(`Tunnel failed to start after ${maxAttempts} seconds`); } async function fetchSecretWithRetry(client, secretId) { let lastError; for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) { try { const response = await client.send(new GetSecretValueCommand({ SecretId: secretId })); if (!response.SecretString) { throw new Error(`Secret ${secretId} has no string value`); } return JSON.parse(response.SecretString); } catch (error) { lastError = error; const isNotFound = error.name === "ResourceNotFoundException"; if (isNotFound && attempt < MAX_RETRIES) { logWarn(`Secret not found (attempt ${attempt}/${MAX_RETRIES}), waiting...`); await sleep(RETRY_DELAY_MS); continue; } throw error; } } throw lastError; } export async function execCommand(commandArgs, options) { if (commandArgs.length === 0) { logError("No command specified"); logInfo("Usage: mesh db exec [options] -- <command> [args...]"); logInfo(""); logInfo("Options:"); logInfo(" -t, --tenant <tenant> Platform tenant (e.g., mesh)"); logInfo(" -e, --env <env> Platform environment (e.g., dev)"); logInfo(" --secret <arn> Use secret ARN directly (skips SSM discovery)"); logInfo(" --db-host <host> Database host (skips SSM lookup)"); logInfo(" --ssl <mode> SSL mode: require, no-verify, disable"); logInfo(" --port <port> Local port for tunnel (default: 5432)"); logInfo(" --stage <stage> Stage for legacy discovery mode"); logInfo(""); logInfo("Examples:"); logInfo(" # Explicit mode (recommended)"); logInfo(" mesh db exec --tenant mesh --env dev --ssl=require -- npx prisma db push"); logInfo(""); logInfo(" # Direct mode (with existing tunnel)"); logInfo(" mesh db exec --secret=arn:aws:... --ssl=require -- pnpm dev"); process.exit(1); } const localPort = options.port ? parseInt(options.port, 10) : DEFAULT_LOCAL_PORT; const secretsManager = new SecretsManagerClient({}); let tunnel = null; let databaseUrl; if (options.secret) { logInfo(`Direct mode: using secret ${options.secret}`); const tunnelExists = await checkPort(localPort); if (!tunnelExists) { logError(`No tunnel found on port ${localPort}`); logInfo("In direct mode, start a tunnel first with: mesh db connect"); logInfo("Or use discovery mode (without --secret) to auto-start tunnel"); process.exit(1); } logSuccess(`Using existing tunnel on port ${localPort}`); logInfo(`Fetching credentials from secret...`); const secret = await fetchSecretWithRetry(secretsManager, options.secret); if (secret.DATABASE_URL) { databaseUrl = secret.DATABASE_URL; logSuccess("Got DATABASE_URL from secret"); } else if (secret.username && secret.password) { const dbName = secret.dbname ?? secret.database ?? "postgres"; databaseUrl = `postgresql://${secret.username}:${encodeURIComponent(secret.password)}@localhost:${localPort}/${dbName}`; logSuccess(`Got credentials for user: ${secret.username}`); } else { logError("Secret must contain DATABASE_URL or username/password fields"); process.exit(1); } try { const parsed = new URL(databaseUrl); parsed.hostname = "localhost"; parsed.port = String(localPort); if (options.ssl) { parsed.searchParams.set("sslmode", options.ssl); if (options.ssl === "require" || options.ssl === "no-verify") { parsed.searchParams.set("sslaccept", "accept_invalid_certs"); } } databaseUrl = parsed.toString(); } catch { logWarn("Could not parse DATABASE_URL for rewriting"); } } else { let platformTenant; let platformEnv; if (options.tenant && options.env) { platformTenant = options.tenant; platformEnv = options.env; logInfo(`Explicit mode: Platform Tenant=${platformTenant}, Platform Env=${platformEnv}`); } else if (options.tenant || options.env) { logError("Both --tenant and --env are required when using explicit mode"); logInfo("Usage: mesh db exec --tenant mesh --env dev-temporal --app rdc --app-tenant encore --app-stage dev -- <command>"); logInfo("Or omit both to use auto-detection"); process.exit(1); } else { const ctx = detectContext(); platformTenant = ctx.tenant; platformEnv = ctx.platformEnv; logInfo(`Discovery mode: Platform=${platformEnv}, Tenant=${platformTenant}`); } const appTenant = options.appTenant ?? platformTenant; const appStage = options.appStage ?? platformEnv; const appName = options.app; if (appName) { logInfo(`App credentials: tenant=${appTenant}, stage=${appStage}, app=${appName}`); } const tunnelExists = await checkPort(localPort); const sstOutputs = findSstOutputs(); if (sstOutputs?.secretArn && tunnelExists) { logInfo(`Found SST outputs in: ${sstOutputs.rootDir}`); try { const secret = await fetchSecretWithRetry(secretsManager, sstOutputs.secretArn); if (secret.DATABASE_URL) { databaseUrl = rewriteDatabaseUrl(secret.DATABASE_URL, { endpoint: `localhost:${localPort}`, sslMode: options.ssl, }); logSuccess(`Using DATABASE_URL from SST outputs`); } } catch { logWarn("Could not fetch from SST outputs, falling back to credential lookup"); } } if (!databaseUrl) { if (!tunnelExists) { const bastion = await getPlatformBastionInfo(platformTenant, platformEnv); const rdsService = bastion.services.rds; if (!rdsService) { throw new Error("RDS service not available in platform bastion"); } tunnel = await startTunnelBackground(bastion.instanceId, rdsService.host, rdsService.port, localPort); } else { logSuccess(`Using existing tunnel on port ${localPort}`); } const rawUrl = await getDatabaseUrl(appTenant, appStage, { app: appName }); databaseUrl = rewriteDatabaseUrl(rawUrl, { endpoint: `localhost:${localPort}`, sslMode: options.ssl, }); } } const env = { ...process.env, DATABASE_URL: databaseUrl }; if (options.ssl === "require" || options.ssl === "no-verify") { env.NODE_TLS_REJECT_UNAUTHORIZED = "0"; logInfo("Set NODE_TLS_REJECT_UNAUTHORIZED=0 for SSL through tunnel"); } const cleanup = () => { if (tunnel) { logInfo("Stopping tunnel..."); tunnel.kill(); } }; process.on("exit", cleanup); process.on("SIGINT", cleanup); process.on("SIGTERM", cleanup); const [cmd, ...args] = commandArgs; logInfo(`Running: ${commandArgs.join(" ")}`); console.log(""); const child = spawn(cmd, args, { stdio: "inherit", env }); child.on("exit", (code) => { cleanup(); process.exit(code ?? 0); }); child.on("error", (err) => { logError(`Failed to run command: ${err.message}`); cleanup(); process.exit(1); }); }