UNPKG

@mesh-tech/mesh-cli

Version:

CLI for Mesh platform development utilities

552 lines (551 loc) 20.2 kB
import { execFileSync } from "node:child_process"; import * as fs from "node:fs"; import * as net from "node:net"; import * as path from "node:path"; import { isLinkedDependencyDir, isPortFree } from "./dev.js"; import { appUsesMeshPackages, probeRegistryToken, registryLoginFix, } from "../utils/auth-preflight.js"; import { probeCredentials, isRemoteEnvironment } from "./login.js"; export function aggregateStatus(results) { if (results.some((r) => r.status === "error")) return "error"; if (results.some((r) => r.status === "warn")) return "warn"; return "ok"; } export async function runChecks(ctx, phase, checks) { const applicable = checks.filter((c) => c.phases.includes(phase)); return Promise.all(applicable.map(async (check) => { try { return { check, result: await check.run(ctx, phase) }; } catch (err) { const msg = err instanceof Error ? err.message : String(err); return { check, result: { status: "error", summary: `check '${check.id}' threw: ${msg}`, }, }; } })); } export const ICONS = { ok: "✓", warn: "⚠", error: "✗" }; export function renderHuman(results) { const lines = []; for (const { check, result } of results) { lines.push(`${ICONS[result.status]} ${check.title}${result.summary}`); if (result.detail) lines.push(` ${result.detail}`); if (result.remediation) lines.push(` Fix: ${result.remediation}`); } return lines.join("\n"); } export function jsonReport(results) { return { status: aggregateStatus(results.map((r) => r.result)), checks: results.map(({ check, result }) => ({ id: check.id, status: result.status, summary: result.summary, remediation: result.remediation ?? null, detail: result.detail ?? null, })), }; } export function renderJson(results) { return JSON.stringify(jsonReport(results), null, 2); } function fmtTtl(seconds) { if (seconds <= 0) return "expired"; const totalMin = Math.round(seconds / 60); const h = Math.floor(totalMin / 60); const m = totalMin % 60; if (h > 0) return m > 0 ? `${h}h ${m}m` : `${h}h`; return `${m}m`; } export function credProbeToResult(probe, context, headless) { const loginCmd = `mesh login ${context}${headless ? " --device" : ""}`; switch (probe.state) { case "ok": return { status: probe.ttlSeconds < 300 ? "warn" : "ok", summary: `deployer creds valid ${fmtTtl(probe.ttlSeconds)}${probe.email ? ` (${probe.email})` : ""}`, detail: `expires ${probe.expiresAt}`, }; case "no-session": return { status: "error", summary: `no Zitadel session for ${context}`, remediation: loginCmd, }; case "expired-session": return { status: "error", summary: `Zitadel session for ${context} expired and could not refresh`, remediation: loginCmd, }; case "assume-denied": return { status: "error", summary: "session valid but AssumeRole denied — wrong IAM role/policy", detail: probe.detail, remediation: `check mesh:deployerRole in the stack config and the role's trust/permissions; re-login if role changed: ${loginCmd}`, }; case "stale-env-override": return { status: "error", summary: "stale AWS_* env vars would override the self-refreshing login profile", remediation: "unset AWS_ACCESS_KEY_ID AWS_SECRET_ACCESS_KEY AWS_SESSION_TOKEN", }; } } export const credsCheck = { id: "creds", title: "Credentials", phases: ["preflight", "ondemand"], async run(ctx) { switch (ctx.credMethod) { case "sso": return { status: "ok", summary: "using AWS SSO credentials (auto-refresh from SSO cache)", }; case "environment": return { status: "ok", summary: "using AWS credentials from environment" }; case "zitadel": return { status: "ok", summary: "using Zitadel deployer credentials (credential_process auto-refresh)", }; } if (!ctx.platformContext || !ctx.deployerRole) { return { status: "warn", summary: "no deployer role/context resolved — using ambient AWS credentials", detail: "add mesh:deployerRole to the Pulumi stack config to use mesh login credentials", }; } const probe = await probeCredentials(ctx.platformContext, ctx.deployerRole); return credProbeToResult(probe, ctx.platformContext, isRemoteEnvironment()); }, }; export const registryCheck = { id: "registry", title: "Registry auth", phases: ["preflight", "ondemand"], async run(ctx, phase) { const probe = await probeRegistryToken(phase === "preflight" ? { timeoutMs: 3_000 } : undefined); switch (probe.state) { case "fresh": return { status: "ok", summary: "CodeArtifact token accepted by the @mesh-tech registry" }; case "expired": return { status: "warn", summary: "CodeArtifact token expired/rejected — installing @mesh-tech packages will fail with E401", detail: probe.detail, remediation: registryLoginFix(), }; case "unreachable": return { status: "ok", summary: "registry unreachable — CodeArtifact token not verified (offline?)", detail: probe.detail, }; case "missing": return appUsesMeshPackages(ctx.appRoot) ? { status: "warn", summary: "no CodeArtifact auth in ~/.npmrc, but this app depends on @mesh-tech packages — pnpm install will fail with E401", remediation: registryLoginFix(), } : { status: "ok", summary: "no CodeArtifact auth configured (needed only to install @mesh-tech packages)", }; } }, }; export function parsePortSquatter(lsofOut, psOut) { const firstLine = lsofOut.trim().split("\n")[0]?.trim(); const pid = Number(firstLine); if (!firstLine || !Number.isInteger(pid)) return null; const command = psOut.trim().split("\n")[0]?.trim() || "unknown"; return { pid, command }; } export function sharesWorktreeRoot(serviceSrc, repoRoot) { const src = path.resolve(serviceSrc); const root = path.resolve(repoRoot); return src === root || src.startsWith(root + path.sep); } export function classifyPortListener(listenerPid, sessionPids) { if (listenerPid === null) return "dead"; return sessionPids.has(listenerPid) ? "owned" : "foreign"; } export function collectSessionPids(panePids, psOut) { const children = new Map(); for (const line of psOut.trim().split("\n")) { const [pidStr, ppidStr] = line.trim().split(/\s+/); const pid = Number(pidStr); const ppid = Number(ppidStr); if (!Number.isInteger(pid) || !Number.isInteger(ppid)) continue; const kids = children.get(ppid) ?? []; kids.push(pid); children.set(ppid, kids); } const result = new Set(); const queue = panePids.filter((p) => Number.isInteger(p)); while (queue.length > 0) { const pid = queue.shift(); if (result.has(pid)) continue; result.add(pid); for (const child of children.get(pid) ?? []) queue.push(child); } return result; } function whoHasPort(port) { try { const lsof = execFileSync("lsof", ["-ti", `:${port}`], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], }); const pid = lsof.trim().split("\n")[0]?.trim(); if (!pid) return null; let ps = ""; try { ps = execFileSync("ps", ["-o", "comm=", "-p", pid], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], }); } catch { } return parsePortSquatter(lsof, ps); } catch { return null; } } function sessionProcessTree(sessionName) { let panePids; try { const out = execFileSync("tmux", ["list-panes", "-s", "-t", sessionName, "-F", "#{pane_pid}"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }); panePids = out .trim() .split("\n") .map((l) => Number(l.trim())) .filter((n) => Number.isInteger(n)); } catch { return new Set(); } if (panePids.length === 0) return new Set(); let psOut = ""; try { psOut = execFileSync("ps", ["-eo", "pid=,ppid="], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], }); } catch { return new Set(panePids); } return collectSessionPids(panePids, psOut); } export const tmuxCheck = { id: "tmux", title: "tmux", phases: ["preflight", "ondemand"], async run() { try { execFileSync("which", ["tmux"], { stdio: "ignore" }); return { status: "ok", summary: "installed" }; } catch { return { status: "error", summary: "tmux is not installed", remediation: "brew install tmux", }; } }, }; export const portsCheck = { id: "ports", title: "Ports", phases: ["preflight", "ondemand"], async run(ctx) { const services = ctx.sessionState?.devOutput.services; if (!services || Object.keys(services).length === 0) { return { status: "ok", summary: "no session — ports are checked at allocation time", }; } const sessionPids = sessionProcessTree(ctx.sessionName); const foreign = []; const dead = []; const unknown = []; for (const [name, svc] of Object.entries(services)) { const free = await isPortFree(svc.port); const who = free ? null : whoHasPort(svc.port); const ownership = classifyPortListener(who?.pid ?? null, sessionPids); if (ownership === "owned") continue; if (ownership === "foreign") { foreign.push(`${name} :${svc.port} held by ${who.command} (pid ${who.pid})`); continue; } if (free) { dead.push(`${name} :${svc.port} not listening`); } else { unknown.push(`${name} :${svc.port} in use (owner unknown)`); } } const total = Object.keys(services).length; if (foreign.length === 0 && dead.length === 0 && unknown.length === 0) { return { status: "ok", summary: `all ${total} service port(s) owned by this session`, }; } const parts = []; if (foreign.length) parts.push(`${foreign.length} squatted by another process`); if (dead.length) parts.push(`${dead.length} not listening (service down)`); if (unknown.length) parts.push(`${unknown.length} in use by an unidentified process`); const remediation = foreign.length > 0 ? "stop the squatting process (or its SSH tunnel), then `mesh dev restart <svc>`" : dead.length > 0 ? "restart the stopped service: `mesh dev restart <svc>`" : "identify the port owner (`lsof -i :<port>`), then `mesh dev restart <svc>`"; return { status: foreign.length > 0 || dead.length > 0 ? "error" : "warn", summary: `service ports: ${parts.join(", ")}`, detail: [...foreign, ...dead, ...unknown].join("; "), remediation, }; }, }; export function isConfigStale(configMtimeMs, startedAtIso) { return configMtimeMs > Date.parse(startedAtIso); } export const configStalenessCheck = { id: "config-staleness", title: "Config freshness", phases: ["ondemand"], async run(ctx) { const state = ctx.sessionState; if (!state) { return { status: "ok", summary: "no session — config read fresh at launch" }; } const configPath = path.join(ctx.appRoot, `Pulumi.${ctx.stack}.yaml`); if (!fs.existsSync(configPath)) { return { status: "ok", summary: `no Pulumi.${ctx.stack}.yaml to compare`, }; } const mtimeMs = fs.statSync(configPath).mtimeMs; if (isConfigStale(mtimeMs, state.startedAt)) { return { status: "warn", summary: `Pulumi.${ctx.stack}.yaml changed since launch — a restart won't pick this up`, detail: `config mtime ${new Date(mtimeMs).toISOString()} > session start ${state.startedAt}`, remediation: `mesh deploy up --stack ${ctx.stack} && mesh dev`, }; } return { status: "ok", summary: "stack config unchanged since launch" }; }, }; export const worktreeCheck = { id: "worktree", title: "Worktree", phases: ["preflight", "ondemand"], async run(ctx) { const services = ctx.sessionState?.devOutput.services; if (!services) { return { status: "ok", summary: "no session — worktree checked at launch" }; } let repoRoot; try { repoRoot = execFileSync("git", ["rev-parse", "--show-toplevel"], { cwd: ctx.appRoot, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], }).trim(); } catch { return { status: "warn", summary: "could not resolve git worktree root for the app", }; } const foreign = Object.entries(services) .filter(([, svc]) => svc.src && !sharesWorktreeRoot(svc.src, repoRoot) && !isLinkedDependencyDir(svc.src, ctx.appRoot)) .map(([name, svc]) => `${name}${svc.src}`); if (foreign.length === 0) { return { status: "ok", summary: `all services resolve under ${repoRoot}`, }; } return { status: "warn", summary: `${foreign.length} service(s) resolve outside this worktree — you may be running another worktree's code`, detail: foreign.join("; "), remediation: "relaunch from this worktree: `mesh dev` (re-reads stack output paths)", }; }, }; export function canConnect(host, port, timeoutMs = 800) { return new Promise((resolve) => { const socket = new net.Socket(); let settled = false; const done = (ok) => { if (settled) return; settled = true; socket.destroy(); resolve(ok); }; socket.setTimeout(timeoutMs); socket.once("connect", () => done(true)); socket.once("timeout", () => done(false)); socket.once("error", () => done(false)); socket.connect(port, host); }); } function isLocalHost(host) { return host === "localhost" || host === "127.0.0.1" || host === "::1"; } export const tunnelsCheck = { id: "tunnels", title: "Tunnels", phases: ["ondemand"], async run(ctx) { const tunnels = ctx.sessionState?.devOutput.tunnels; if (!tunnels || Object.keys(tunnels).length === 0) { return { status: "ok", summary: "no tunnels in this session" }; } const local = Object.entries(tunnels).filter(([, t]) => isLocalHost(t.host)); if (local.length === 0) { return { status: "ok", summary: `${Object.keys(tunnels).length} tunnel(s), all VPC-direct (not dialed)`, }; } const dead = []; for (const [name, t] of local) { if (!(await canConnect(t.host, t.port))) dead.push(`${name} (${t.host}:${t.port})`); } if (dead.length === 0) { return { status: "ok", summary: `${local.length} SSM tunnel(s) live` }; } return { status: "error", summary: `${dead.length} SSM tunnel(s) down`, detail: dead.join("; "), remediation: "relaunch to re-establish tunnels: `mesh dev`", }; }, }; export const temporalCheck = { id: "temporal", title: "Temporal", phases: ["ondemand"], async run(ctx) { const state = ctx.sessionState; const t = state?.devOutput.tunnels["temporal"]; if (!state || !t) { return { status: "ok", summary: "no temporal tunnel in this session" }; } const addr = `${t.host}:${t.port}`; if (!(await canConnect(t.host, t.port))) { return { status: "error", summary: `Temporal frontend unreachable at ${addr}`, remediation: "tunnel likely down — relaunch: `mesh dev`", }; } const p = state.devOutput.platform; const app = state.devOutput.app; if (!p || !app) { return { status: "ok", summary: `frontend reachable at ${addr} (namespace unknown)` }; } const namespace = `${p.tenant}-${p.env}-${app}`; try { const { Connection } = await import("@temporalio/client"); const connection = await Connection.connect({ address: addr, connectTimeout: "3s", }); try { await connection.workflowService.describeNamespace({ namespace }); return { status: "ok", summary: `frontend reachable; namespace ${namespace} present` }; } finally { await connection.close().catch(() => { }); } } catch (err) { const code = err.code; if (code === 5) { return { status: "error", summary: `namespace ${namespace} not found on the server`, remediation: `verify the app is deployed to this env (mesh deploy up --stack ${ctx.stack})`, }; } if (code === 7 || code === 16) { return { status: "ok", summary: `frontend reachable; namespace ${namespace} auth-gated (not verified)`, }; } return { status: "warn", summary: "frontend reachable but namespace check errored", detail: err instanceof Error ? err.message : String(err), }; } }, }; export const ALL_CHECKS = [ tmuxCheck, credsCheck, registryCheck, configStalenessCheck, portsCheck, worktreeCheck, tunnelsCheck, temporalCheck, ]; export async function runDoctor(ctx, opts) { const results = await runChecks(ctx, "ondemand", ALL_CHECKS); const status = aggregateStatus(results.map((r) => r.result)); if (opts.json) { console.log(renderJson(results)); } else { console.log(renderHuman(results)); console.log(`\n${ICONS[status]} overall: ${status}`); } return status; }