UNPKG

@mesh-tech/mesh-cli

Version:

CLI for Mesh platform development utilities

484 lines (483 loc) 20.6 kB
import { execFileSync } from "child_process"; import * as fs from "fs"; import * as path from "path"; import { logInfo, logSuccess, logWarn } from "../../utils/log.js"; import { MeshCliError } from "../../utils/errors.js"; import { LOCAL_ENV, LOCAL_AWS_CONFIG } from "./seed.js"; import { probeTcp, upsertLocalSecret } from "./helpers.js"; import { localProbesDir } from "./stack.js"; const EXTERNAL_MODES = ["mock", "local", "remote"]; const DECL_DOCS = { docs: 'package.json → "mesh": { "externals": { … } }' }; export function externalMode(name, decl) { const mode = decl.mode ?? (decl.compose ? "local" : decl.openapi || decl.src ? "mock" : decl.sandbox || decl.credentials || decl.remote ? "remote" : undefined); if (!mode || !EXTERNAL_MODES.includes(mode)) { throw new MeshCliError(`External '${name}': cannot determine mode — set "mode" to mock | local | remote (or declare openapi/src, compose, or credentials).`, { remediation: DECL_DOCS }); } if (mode === "local") { if (!decl.compose) { throw new MeshCliError(`External '${name}': local mode requires "compose" — the docker compose file running the local version of the service.`, { remediation: DECL_DOCS }); } if (typeof decl.port !== "number") { throw new MeshCliError(`External '${name}': local (compose) mode requires "port" — the port the compose file publishes on localhost.`, { remediation: DECL_DOCS }); } } if (mode === "mock" && !decl.openapi && !decl.src) { throw new MeshCliError(`External '${name}': mock mode emulates the service — declare "openapi" (spec → Prism mock) or "src" (mock process).`, { remediation: DECL_DOCS }); } if (mode === "remote" && !remoteInlineCredentials(decl) && !decl.external) { throw new MeshCliError(`External '${name}': remote mode needs a credential source — inline "credentials", "remote": { "env": … }, or "external" (pulls that environment's configured secret).`, { remediation: DECL_DOCS }); } return mode; } export function parseExternalsSelection(input) { const names = []; const overrides = new Map(); for (const entry of input.split(",").map((s) => s.trim()).filter(Boolean)) { const [rawName, mode, ...rest] = entry.split("=").map((s) => s.trim()); const name = rawName ?? ""; if (!name) { throw new MeshCliError(`Bad --externals entry '${entry}' — expected name or name=mode with mode ∈ ${EXTERNAL_MODES.join(" | ")}.`, { remediation: { command: "mesh dev --externals plaid-db=remote,plaid" } }); } if (mode !== undefined) { if (rest.length > 0 || !EXTERNAL_MODES.includes(mode)) { throw new MeshCliError(`Bad --externals entry '${entry}' — expected name or name=mode with mode ∈ ${EXTERNAL_MODES.join(" | ")}.`, { remediation: { command: "mesh dev --externals plaid-db=remote,plaid" } }); } overrides.set(name, mode); } if (!names.includes(name)) names.push(name); } return { names, overrides }; } export function remoteInlineCredentials(decl) { if (decl.remote?.credentials) return decl.remote.credentials; if (decl.credentials && !decl.compose && !decl.openapi && !decl.src) return decl.credentials; return undefined; } export function isServiceMode(name, decl) { return externalMode(name, decl) === "mock"; } export function interpolateCredentialValue(raw, endpoint, env = process.env) { let value = raw.replace(/\{\{env:([A-Za-z_][A-Za-z0-9_]*)\}\}/g, (_, name) => { const resolved = env[name]; if (resolved === undefined) { throw new MeshCliError(`Credential value references {{env:${name}}} but ${name} is not set in your environment.`, { remediation: { command: `export ${name}=… # then re-run mesh dev` } }); } return resolved; }); if (endpoint) { value = value .replaceAll("{{url}}", endpoint.url) .replaceAll("{{host}}", endpoint.host) .replaceAll("{{port}}", String(endpoint.port)); } return value; } export function openapiMockCommand(decl) { return (decl.command ?? [ "npx", "-y", "@stoplight/prism-cli@5", "mock", "-p", "$PORT", "-h", "0.0.0.0", decl.openapi, ]); } export function composeProjectName(sessionName, mockName) { return `mesh-ext-${sessionName}-${mockName}`.toLowerCase().replace(/[^a-z0-9_-]/g, "-"); } async function waitForPort(port, timeoutMs) { const deadline = Date.now() + timeoutMs; while (Date.now() < deadline) { if (await probeTcp(port, { timeoutMs: 2000 })) return true; await new Promise((r) => setTimeout(r, 2000)); } return false; } export async function planComposeExternal(composeFile, project, port, probes) { if (probes.ownsRunning(composeFile, project)) return { action: "up" }; if (await probes.portServed(port)) { const publisher = probes.portPublisher(composeFile, port); if (!publisher || !publisher.definedByFile) { return { action: "conflict", ...(publisher ? { container: publisher.container } : {}) }; } return { action: "adopt-served", container: publisher.container }; } const foreign = probes.foreignPinned(composeFile, project); if (foreign) return { action: "adopt-stopped", container: foreign }; return { action: "up" }; } const dockerProbes = { ownsRunning: ownsRunningRealization, portServed: (port) => probeTcp(port, { timeoutMs: 2000 }), foreignPinned: foreignPinnedContainer, portPublisher: composeFilePortPublisher, }; export async function composeExternalUp(appRoot, sessionName, name, decl, probes = dockerProbes) { const composeFile = path.resolve(appRoot, decl.compose); if (!fs.existsSync(composeFile)) { throw new MeshCliError(`External '${name}': compose file not found at ${composeFile}.`, { remediation: { docs: 'package.json → "mesh": { "externals": { … } }' }, }); } const project = composeProjectName(sessionName, name); const plan = await planComposeExternal(composeFile, project, decl.port, probes); if (plan.action === "conflict") { throw new MeshCliError(`External '${name}': localhost:${decl.port} is already served by ${plan.container ? `container '${plan.container}'` : "a process outside docker"}, which ${decl.compose} does not define — refusing to seed '${name}' credentials against it.`, { remediation: { command: `docker ps --filter publish=${decl.port} # stop it, or change the declared port`, }, }); } if (plan.action === "adopt-served") { logInfo(`External '${name}' already served on localhost:${decl.port} by container '${plan.container}' — adopting it (started outside this checkout; \`mesh dev --kill\` won't touch it).`); return undefined; } if (plan.action === "adopt-stopped") { const { container } = plan; logInfo(`External '${name}': container '${container}' exists from another checkout — starting and adopting it.`); execFileSync("docker", ["start", container], { stdio: ["ignore", "ignore", "inherit"] }); if (!(await waitForPort(decl.port, 300_000))) { throw new MeshCliError(`External '${name}': adopted container '${container}' never served localhost:${decl.port}.`, { remediation: { command: `docker logs ${container} # then: docker rm -f ${container} and re-run` } }); } return undefined; } execFileSync("docker", ["compose", "-p", project, "-f", composeFile, "up", "-d", "--wait", "--wait-timeout", "300"], { stdio: ["ignore", "inherit", "inherit"] }); return { name, composeFile, project }; } function ownsRunningRealization(composeFile, project) { try { const out = execFileSync("docker", ["compose", "-p", project, "-f", composeFile, "ps", "--format", "json"], { encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"] }).trim(); return out.length > 0 && out !== "[]"; } catch { return false; } } function composeServices(composeFile) { try { const config = JSON.parse(execFileSync("docker", ["compose", "-f", composeFile, "config", "--format", "json"], { encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"], })); const services = config?.services; return services && typeof services === "object" ? services : undefined; } catch { return undefined; } } function portPublisher(port) { try { const out = execFileSync("docker", [ "ps", "--filter", `publish=${port}`, "--format", '{{.Names}}\t{{.Image}}\t{{.Label "com.docker.compose.project"}}\t{{.Label "com.docker.compose.service"}}', ], { encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"] }); const line = out .split("\n") .map((l) => l.trim()) .filter(Boolean)[0]; if (!line) return undefined; const [container, image, project, service] = line.split("\t"); if (!container) return undefined; return { container, image: image || undefined, project: project || undefined, service: service || undefined, }; } catch { return undefined; } } function composeFileOwns(composeFile, publisher) { if (!publisher) return false; const services = composeServices(composeFile); if (!services) return false; for (const [key, svc] of Object.entries(services)) { if (svc?.container_name && svc.container_name === publisher.container) return true; if (publisher.service && publisher.service === key) { if (!svc?.image || !publisher.image || svc.image === publisher.image) return true; } } return false; } function composeFilePortPublisher(composeFile, port) { const publisher = portPublisher(port); if (!publisher) return undefined; return { container: publisher.container, definedByFile: composeFileOwns(composeFile, publisher) }; } function foreignPinnedContainer(composeFile, project) { for (const svc of Object.values(composeServices(composeFile) ?? {})) { const pinned = svc?.container_name; if (!pinned) continue; try { const owner = execFileSync("docker", ["inspect", pinned, "--format", '{{ index .Config.Labels "com.docker.compose.project" }}'], { encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"] }).trim(); if (owner !== project) return pinned; } catch { } } return undefined; } export function composeExternalsDown(refs) { for (const ref of refs ?? []) { try { execFileSync("docker", ["compose", "-p", ref.project, "-f", ref.composeFile, "down", "--remove-orphans"], { stdio: ["ignore", "inherit", "inherit"], }); } catch { } } } const SECRET_KEY_RE = /key|secret|password|token/i; export function readLocalMocks(appRoot) { try { const pkg = JSON.parse(fs.readFileSync(path.join(appRoot, "package.json"), "utf-8")); const mocks = pkg?.mesh?.mocks; const externals = pkg?.mesh?.externals; return { ...(mocks && typeof mocks === "object" ? mocks : {}), ...(externals && typeof externals === "object" ? externals : {}), }; } catch { return {}; } } export function externalSecretPath(tenant, external) { return `mesh/${tenant}/${LOCAL_ENV}/external/${external}`; } export async function fetchRemoteExternalCredentials(tenant, env, external, profile) { const secretId = `mesh/${tenant}/${env}/external/${external}`; const region = process.env.MESH_PLATFORM_REGION ?? process.env.AWS_REGION ?? "us-east-2"; const fallbackProfile = !process.env.AWS_ACCESS_KEY_ID && !process.env.AWS_PROFILE ? (process.env.MESH_AWS_PROFILE ?? profile) : undefined; try { const { SecretsManagerClient, GetSecretValueCommand } = await import("@aws-sdk/client-secrets-manager"); if (fallbackProfile) process.env.AWS_PROFILE = fallbackProfile; const sm = new SecretsManagerClient({ region }); let res; try { res = await sm.send(new GetSecretValueCommand({ SecretId: secretId })); } finally { if (fallbackProfile) delete process.env.AWS_PROFILE; } const parsed = JSON.parse(res.SecretString ?? "{}"); if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { throw new Error("secret value is not a JSON object"); } return Object.fromEntries(Object.entries(parsed).map(([key, v]) => [key, String(v)])); } catch (err) { throw new MeshCliError(`External '${external}': could not pull remote credentials from the ${env} environment (${secretId}, region ${region}): ${err instanceof Error ? err.message : err}`, { remediation: { command: `AWS_PROFILE=<${tenant}-${env} profile> mesh dev … # or set "remote": { "profile": … } on the declaration / MESH_AWS_PROFILE`, }, }); } } function hostForProber(target) { return target.replace(/\b(?:localhost|127\.0\.0\.1)\b/, "host.docker.internal"); } function httpProbeUrl(raw) { if (!raw) return undefined; let parsed; try { parsed = new URL(raw); } catch { return undefined; } if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return undefined; parsed.username = ""; parsed.password = ""; return hostForProber(parsed.toString()); } function derivedTcpTarget(creds) { if (creds.host && creds.port) return `${hostForProber(creds.host)}:${creds.port}`; if (creds.bucket) return `${creds.bucket}.s3.${creds.region || "us-east-1"}.amazonaws.com:443`; return undefined; } export function resolveProbeTarget(mode, decl, endpoint, creds) { if (mode === "remote") { const url = httpProbeUrl(creds.endpoint ?? creds.url ?? creds.baseUrl); const tcpTarget = derivedTcpTarget(creds); if (decl.probe === "tcp" || (!url && tcpTarget)) { if (!tcpTarget && decl.probe === "tcp") { logWarn(`External '${decl.external ?? ""}': probe: "tcp" declared but the remote credentials carry no host/port (or bucket) to probe — no uptime probe registered.`); } return tcpTarget ? { target: tcpTarget, module: "tcp_connect" } : undefined; } return url ? { target: url.replace(/\/+$/, "") + (decl.healthPath ?? "") } : undefined; } if (!endpoint) return undefined; if (decl.probe === "tcp") { return { target: `host.docker.internal:${endpoint.port}`, module: "tcp_connect" }; } return { target: hostForProber(endpoint.url).replace(/\/+$/, "") + (decl.healthPath ?? "/health"), }; } export function externalDisplayName(name, decl) { return (decl.displayName ?? name .split(/[-_\s]+/) .filter(Boolean) .map((w) => w.charAt(0).toUpperCase() + w.slice(1)) .join(" ")); } export function externalDocs(decl) { if (!decl.docs) return undefined; return typeof decl.docs === "string" ? { url: decl.docs } : decl.docs; } export function localProbeFile(tenant, app, external) { return path.join(localProbesDir(), `${tenant}-${app}-${external}.json`); } export function localProbesRemove(files) { for (const file of files ?? []) { try { fs.rmSync(file, { force: true }); } catch { } } } export async function seedLocalMock(args) { const { tenant, app, name, decl, endpoint } = args; if (!decl.external) return undefined; const mode = externalMode(name, decl); let value; if (mode === "remote") { const inline = remoteInlineCredentials(decl); value = inline ? Object.fromEntries(Object.entries(inline).map(([key, raw]) => [ key, interpolateCredentialValue(raw, undefined), ])) : await fetchRemoteExternalCredentials(tenant, decl.remote?.env ?? "dev", decl.external, decl.remote?.profile); } else { if (!decl.credentials) return undefined; value = Object.fromEntries(Object.entries(decl.credentials).map(([key, raw]) => [ key, interpolateCredentialValue(raw, endpoint), ])); } const secretName = externalSecretPath(tenant, decl.external); await upsertLocalSecret(secretName, value); await upsertLocalSecret(`${secretName}/.config`, Object.fromEntries(Object.entries(value).filter(([key]) => !SECRET_KEY_RE.test(key)))); const { SSMClient, PutParameterCommand } = await import("@aws-sdk/client-ssm"); const ssm = new SSMClient(LOCAL_AWS_CONFIG); const base = `/mesh-platform/${tenant}/${LOCAL_ENV}/apps/${app}/stacks/local/external-services/${decl.external}`; await ssm.send(new PutParameterCommand({ Name: `${base}/meta`, Type: "String", Overwrite: true, Value: JSON.stringify({ name: decl.external, displayName: externalDisplayName(name, decl), type: decl.type ?? "other", description: mode === "local" ? `${externalDisplayName(name, decl)} — local replica via mesh dev (docker compose)` : mode === "remote" ? remoteInlineCredentials(decl) ? `${externalDisplayName(name, decl)} — remote service (vendor credentials)` : `${externalDisplayName(name, decl)} — remote service (${decl.remote?.env ?? "dev"} environment credentials)` : `${externalDisplayName(name, decl)} — emulated by mesh dev (mock)`, secretPrefix: secretName, ...(externalDocs(decl) ? { docs: externalDocs(decl) } : {}), }), Description: `External service registration (local mock ${name})`, })); await ssm.send(new PutParameterCommand({ Name: `${base}/credentials`, Type: "String", Overwrite: true, Value: JSON.stringify({ fields: Object.fromEntries(Object.keys(value).map((key) => [ key, { type: "field", name: key, description: "", secret: SECRET_KEY_RE.test(key), optional: false }, ])), keyedBy: null, }), Description: `External service credential schema (local mock ${name})`, })); const probe = resolveProbeTarget(mode, decl, endpoint, value); await ssm.send(new PutParameterCommand({ Name: `${base}/healthCheck`, Type: "String", Overwrite: true, Value: JSON.stringify({ intervalSeconds: 30, timeoutSeconds: 10, hasCustomCheck: false, hasProbe: !!probe }), Description: `External service health check config (local mock ${name})`, })); const probeFile = localProbeFile(tenant, app, decl.external); if (probe) { fs.writeFileSync(probeFile, JSON.stringify([ { targets: [probe.target], labels: { type: "external-service", tenant, env: LOCAL_ENV, app, external_service: decl.external, target: decl.external, ...(probe.module ? { __probe_module: probe.module } : {}), }, }, ], null, 2)); } else { localProbesRemove([probeFile]); } const endpointShown = endpoint?.url ?? value.endpoint ?? value.url ?? value.baseUrl ?? (value.host ? `${value.host}${value.port ? `:${value.port}` : ""}` : "credentials"); logSuccess(`External '${name}' wired (${mode}): ${secretName}${endpointShown}` + ` (Hub registration${probe ? " + uptime probe" : ""})`); return probe ? probeFile : undefined; }