UNPKG

@mesh-tech/mesh-cli

Version:

CLI for Mesh platform development utilities

400 lines (399 loc) 15.3 kB
import { spawn } from "child_process"; import * as fs from "fs"; import * as os from "os"; import * as path from "path"; import { SecretsManagerClient, GetSecretValueCommand, } from "@aws-sdk/client-secrets-manager"; import { detectContext, getPlatformBastionInfo, logInfo, logError, } from "../../utils/index.js"; function resolveTenantEnv(options) { if (options.tenant && options.env) { return { tenant: options.tenant, env: options.env }; } const ctx = detectContext(options.env); return { tenant: options.tenant ?? ctx.tenant, env: options.env ?? ctx.platformEnv, stage: ctx.stage, }; } function readPulumiAwsRegion(stage) { const preferred = stage ? `Pulumi.${stage}.yaml` : undefined; const rank = (f) => f === preferred ? 0 : f === "Pulumi.yaml" ? 2 : 1; try { const files = fs .readdirSync(".") .filter((f) => f.startsWith("Pulumi.") && f.endsWith(".yaml")) .sort((a, b) => rank(a) - rank(b)); for (const f of files) { const m = fs .readFileSync(f, "utf-8") .match(/^\s*aws:region:\s*["']?([^"'\n]+)["']?/m); if (m?.[1]) return m[1].trim(); } } catch { } return undefined; } function awsProfileSections(name) { return [ [path.join(os.homedir(), ".aws", "config"), `[profile ${name}]`], [path.join(os.homedir(), ".aws", "credentials"), `[${name}]`], ]; } function awsProfileExists(name) { for (const [file, header] of awsProfileSections(name)) { try { const lines = fs.readFileSync(file, "utf-8").split("\n"); if (lines.some((l) => l.trim() === header)) return true; } catch { } } return false; } export function parseProfileRegion(text, header) { let inSection = false; for (const raw of text.split("\n")) { const line = raw.trim(); if (line.startsWith("[")) { inSection = line === header; continue; } if (!inSection) continue; const m = line.match(/^region\s*=\s*(.+)$/); const value = m?.[1]?.split(/\s+[#;]/)[0]?.trim(); if (value) return value; } return undefined; } function awsProfileRegion(profile) { if (!profile) return undefined; for (const [file, header] of awsProfileSections(profile)) { try { const region = parseProfileRegion(fs.readFileSync(file, "utf-8"), header); if (region) return region; } catch { } } return undefined; } export function awsDefaultProfileSections() { return [ [path.join(os.homedir(), ".aws", "config"), "[default]"], [path.join(os.homedir(), ".aws", "credentials"), "[default]"], ]; } function awsDefaultProfileRegion() { for (const [file, header] of awsDefaultProfileSections()) { try { const region = parseProfileRegion(fs.readFileSync(file, "utf-8"), header); if (region) return region; } catch { } } return undefined; } function applyAwsDefaults(tenant, env, stage) { if (!process.env.AWS_PROFILE && !process.env.AWS_ACCESS_KEY_ID) { const profile = [`${tenant}-${env}`, tenant].find(awsProfileExists); if (profile) { process.env.AWS_PROFILE = profile; logInfo(`AWS profile: ${profile} (from ~/.aws, tenant convention)`); } } if (!process.env.AWS_REGION && !process.env.AWS_DEFAULT_REGION) { const region = readPulumiAwsRegion(stage) ?? (process.env.AWS_PROFILE ? awsProfileRegion(process.env.AWS_PROFILE) : awsDefaultProfileRegion()) ?? "us-east-2"; process.env.AWS_REGION = region; logInfo(`AWS region: ${region} (auto)`); } } const SERVICE_CONFIG = { "temporal-frontend": { bastionKey: "temporal-frontend", localPort: 7233, displayName: "Temporal Frontend (gRPC)", }, "temporal-ui": { bastionKey: "temporal-ui", localPort: 8080, displayName: "Temporal Web UI", }, db: { bastionKey: "rds", localPort: 5432, displayName: "Database (PostgreSQL)", }, }; const SERVICE_GROUPS = { dev: ["temporal-frontend", "temporal-ui", "db"], temporal: ["temporal-frontend", "temporal-ui"], }; function getEffectivePort(serviceName, options) { const config = SERVICE_CONFIG[serviceName]; if (!config) return 0; switch (serviceName) { case "db": return options.dbPort ? parseInt(options.dbPort, 10) : config.localPort; case "temporal-ui": return options.temporalUiPort ? parseInt(options.temporalUiPort, 10) : config.localPort; case "temporal-frontend": return options.temporalFrontendPort ? parseInt(options.temporalFrontendPort, 10) : config.localPort; default: return config.localPort; } } function parseServices(input) { if (SERVICE_GROUPS[input]) { return SERVICE_GROUPS[input]; } return input.split(",").map((s) => s.trim()); } function spawnSsmTunnel(instanceId, service, localPort) { return spawn("aws", [ "ssm", "start-session", "--target", instanceId, "--document-name", "AWS-StartPortForwardingSessionToRemoteHost", "--parameters", JSON.stringify({ host: [service.host], portNumber: [String(service.port)], localPortNumber: [String(localPort)], }), ], { stdio: "inherit" }); } async function listServices(options) { const { tenant, env } = options; const bastion = await getPlatformBastionInfo(tenant, env); console.log(""); logInfo(`Tenant: ${tenant}, Platform Env: ${env}`); console.log(""); console.log("Available services:"); console.log(""); for (const [name, config] of Object.entries(SERVICE_CONFIG)) { const endpoint = bastion.services[config.bastionKey]; const status = endpoint ? "✓" : "✗"; const localPort = getEffectivePort(name, options); console.log(` ${status} ${name.padEnd(20)} localhost:${localPort}${endpoint?.host ?? "not available"}:${endpoint?.port ?? ""}`); } console.log(""); console.log("Service groups:"); for (const [group, services] of Object.entries(SERVICE_GROUPS)) { console.log(` ${group.padEnd(20)} ${services.join(", ")}`); } console.log(""); logInfo("Usage:"); console.log(" mesh tunnel dev # All dev services"); console.log(" mesh tunnel --services temporal # Just temporal services"); console.log(" mesh tunnel --services db # Just database"); console.log(" mesh tunnel external <name> # Registered external service"); console.log(""); } async function tunnelServices(serviceNames, options) { const { tenant, env } = options; const bastion = await getPlatformBastionInfo(tenant, env); const servicesToStart = []; for (const name of serviceNames) { const config = SERVICE_CONFIG[name]; if (!config) { logError(`Unknown service: ${name}`); logInfo(`Available services: ${Object.keys(SERVICE_CONFIG).join(", ")}`); process.exit(1); } const endpoint = bastion.services[config.bastionKey]; if (!endpoint) { logError(`Service '${name}' not available in platform bastion.`); logInfo("Make sure the service is enabled and deployed."); process.exit(1); } servicesToStart.push({ name, config, endpoint }); } if (servicesToStart.length === 0) { logError("No services specified."); process.exit(1); } console.log(""); logInfo(`Tenant: ${tenant}, Platform Env: ${env}`); console.log(""); logInfo("Starting tunnels..."); for (const { name, config, endpoint } of servicesToStart) { const localPort = getEffectivePort(name, options); console.log(` ${config.displayName.padEnd(25)} localhost:${localPort}${endpoint.host}:${endpoint.port}`); } console.log(""); logInfo("Press Ctrl+C to stop the tunnels"); console.log(""); const processes = []; for (const { name, endpoint } of servicesToStart) { const localPort = getEffectivePort(name, options); processes.push(spawnSsmTunnel(bastion.instanceId, endpoint, localPort)); } const cleanup = () => { for (const proc of processes) { proc.kill(); } process.exit(0); }; process.on("SIGINT", cleanup); process.on("SIGTERM", cleanup); await Promise.race(processes.map((proc) => new Promise((resolve) => { proc.on("exit", () => resolve()); proc.on("error", () => resolve()); }))); cleanup(); } export function resolveCredentialAxis(options) { return { appTenant: options.appTenant ?? options.tenant, appStage: options.appStage ?? options.stage ?? options.env, }; } function isValidPort(port) { return Number.isInteger(port) && port >= 1 && port <= 65535; } export function externalSecretId(appTenant, appStage, name, key) { const base = `mesh/${appTenant}/${appStage}/external/${name}`; return key ? `${base}/${key}` : base; } export function resolveExternalPorts(creds, portOverride) { const host = typeof creds.host === "string" ? creds.host.trim() : ""; const remotePort = Number(creds.port); if (!host || !isValidPort(remotePort)) { throw new Error("Secret has no usable host/port fields — only host:port externals are tunnelable."); } if (portOverride === undefined) { return { host, remotePort, localPort: remotePort }; } const localPort = Number(portOverride); if (!isValidPort(localPort)) { throw new Error(`--port must be an integer between 1 and 65535 (got "${portOverride}").`); } return { host, remotePort, localPort }; } async function tunnelExternal(name, options) { const { tenant, env, key } = options; const { appTenant, appStage } = resolveCredentialAxis(options); const secretId = externalSecretId(appTenant, appStage, name, key); const setHint = `mesh secrets set external/${name}${key ? ` --key=${key}` : ""}`; const sm = new SecretsManagerClient({}); let creds; try { const out = await sm.send(new GetSecretValueCommand({ SecretId: secretId })); creds = JSON.parse(out.SecretString ?? "{}"); } catch (err) { logError(`Could not read ${secretId}: ${err instanceof Error ? err.message : String(err)}`); logInfo(`Is the external service registered and seeded? (${setHint})`); logInfo(`Credential axis used: app tenant '${appTenant}', app stage '${appStage}' (override with --app-tenant/--app-stage) — ` + `zero-flag runs key on the stage detected from the app dir, while explicit -t/-e keys on the platform env.`); if (!key) { logInfo("Multi-instance externals (credentials.keyedBy) store one secret per instance — pass --key <key>."); } process.exit(1); } let host; let remotePort; let localPort; try { ({ host, remotePort, localPort } = resolveExternalPorts(creds, options.port)); } catch (err) { logError(err instanceof Error ? err.message : String(err)); logInfo(`Secret: ${secretId}`); process.exit(1); } const bastion = await getPlatformBastionInfo(tenant, env); console.log(""); logInfo(`Tenant: ${tenant}, Platform Env: ${env}`); if (appTenant !== tenant || appStage !== env) { logInfo(`App credentials: tenant=${appTenant}, stage=${appStage}`); } console.log(""); console.log(` ${name.padEnd(25)} localhost:${localPort}${host}:${remotePort}`); console.log(""); logInfo("Press Ctrl+C to stop the tunnel"); console.log(""); const proc = spawnSsmTunnel(bastion.instanceId, { host, port: remotePort }, localPort); const cleanup = () => { proc.kill(); process.exit(0); }; process.on("SIGINT", cleanup); process.on("SIGTERM", cleanup); await new Promise((resolve) => { proc.on("exit", () => resolve()); proc.on("error", () => resolve()); }); cleanup(); } export function registerTunnelCommands(program) { const tunnel = program .command("tunnel") .description("Start SSM tunnel to platform services") .option("-t, --tenant <tenant>", "Platform tenant (default: detected from Pulumi/SST config in cwd)") .option("-e, --env <env>", "Platform environment (default: detected from cwd, else dev)") .option("-l, --list", "List available services") .option("-s, --services <services>", "Services to tunnel (comma-separated or group name)") .option("--db-port <port>", "Custom local port for database (default: 5432)") .option("--temporal-ui-port <port>", "Custom local port for Temporal UI (default: 8080)") .option("--temporal-frontend-port <port>", "Custom local port for Temporal Frontend (default: 7233)") .argument("[group]", "Service group to tunnel (e.g., dev, temporal)") .action(async (group, rawOptions) => { const { tenant, env, stage } = resolveTenantEnv(rawOptions); applyAwsDefaults(tenant, env, stage); const options = { ...rawOptions, tenant, env }; if (options.list) { await listServices(options); return; } let services; if (options.services) { services = parseServices(options.services); } else if (group) { services = parseServices(group); } else { await listServices(options); return; } await tunnelServices(services, options); }); tunnel .command("external") .description("Tunnel to a registered external service via the platform bastion " + "(host/port from mesh/{app-tenant}/{app-stage}/external/{name})") .argument("<name>", "External service name (e.g., plaid-db)") .option("-t, --tenant <tenant>", "Platform tenant (default: detected from Pulumi/SST config in cwd)") .option("-e, --env <env>", "Platform environment (default: detected from cwd, else dev)") .option("--app-tenant <tenant>", "Tenant for the external service's credentials (default: --tenant)") .option("--app-stage <stage>", "Stage for the external service's credentials (default: --env)") .option("-k, --key <key>", "Instance key for a multi-instance external (credentials.keyedBy)") .option("-p, --port <port>", "Local port (default: the service's remote port)") .action(async (name, _options, command) => { const merged = command.optsWithGlobals(); const { tenant, env, stage } = resolveTenantEnv(merged); applyAwsDefaults(tenant, env, stage); await tunnelExternal(name, { ...merged, tenant, env, stage }); }); }