@mesh-tech/mesh-cli
Version:
CLI for Mesh platform development utilities
400 lines (399 loc) • 15.3 kB
JavaScript
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 });
});
}