@mesh-tech/mesh-cli
Version:
CLI for Mesh platform development utilities
387 lines (386 loc) • 18.7 kB
JavaScript
import { execFileSync } from "child_process";
import * as path from "path";
import * as fs from "fs";
import { parse as parseYaml } from "yaml";
import { logError, logInfo, logSuccess, logWarn } from "../utils/log.js";
import { findAppRoot, findStackConfigs, getCurrentStack, readStackConfig } from "../utils/pulumi.js";
import { resolveWorktreeIdentity, worktreeStackName } from "../utils/worktree-identity.js";
import { resolvePulumiEnv } from "../utils/pulumi-run.js";
function readTopLevelYamlKey(appRoot, stack, key) {
const configFile = path.join(appRoot, `Pulumi.${stack}.yaml`);
if (!fs.existsSync(configFile))
return null;
const content = fs.readFileSync(configFile, "utf-8");
const pattern = new RegExp(`^${key}:\\s*(.+)$`, "m");
const match = content.match(pattern);
if (!match)
return null;
return match[1].trim().replace(/^["']|["']$/g, "");
}
function readConfigBlockKey(appRoot, stack, key) {
const configFile = path.join(appRoot, `Pulumi.${stack}.yaml`);
if (!fs.existsSync(configFile))
return null;
const content = fs.readFileSync(configFile, "utf-8");
const pattern = new RegExp(`^\\s{2}${key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}:\\s*(.+)$`, "m");
const match = content.match(pattern);
if (!match)
return null;
return match[1].trim().replace(/^["']|["']$/g, "");
}
async function checkParentZoneDelegation(parentZone, awsEnv) {
let publicNs = [];
try {
const { Resolver } = await import("node:dns/promises");
const resolver = new Resolver();
resolver.setServers(["1.1.1.1", "8.8.8.8"]);
publicNs = (await resolver.resolveNs(parentZone)).map((n) => n.replace(/\.$/, "").toLowerCase());
}
catch {
}
let zoneNs = [];
try {
const { Route53Client, ListHostedZonesByNameCommand, GetHostedZoneCommand } = await import("@aws-sdk/client-route-53");
const client = new Route53Client({
credentials: awsEnv.AWS_ACCESS_KEY_ID
? {
accessKeyId: awsEnv.AWS_ACCESS_KEY_ID,
secretAccessKey: awsEnv.AWS_SECRET_ACCESS_KEY ?? "",
sessionToken: awsEnv.AWS_SESSION_TOKEN,
}
: undefined,
});
const byName = await client.send(new ListHostedZonesByNameCommand({ DNSName: parentZone, MaxItems: 1 }));
const zone = byName.HostedZones?.[0];
if (zone?.Id && zone.Name === `${parentZone}.`) {
const detail = await client.send(new GetHostedZoneCommand({ Id: zone.Id }));
zoneNs = (detail.DelegationSet?.NameServers ?? []).map((n) => n.toLowerCase());
}
else {
logWarn(`Parent zone '${parentZone}' does not exist in Route53 yet — the platform deploy expects it (it hosts the env-zone delegations). Create it first (one-time, operator step).`);
return;
}
}
catch {
}
if (publicNs.length === 0) {
const records = zoneNs.length > 0
? zoneNs.map((ns) => ` ${parentZone.split(".")[0]} NS ${ns}`).join("\n")
: ` (run this command again with AWS credentials to print the exact nameservers)`;
logWarn(`Parent zone '${parentZone}' has NO public NS delegation — hub/identity/temporal hostnames for this stack will NOT resolve and ACM certs stay pending until it lands.\n` +
` One-time step: at the DNS host for '${parentZone.split(".").slice(1).join(".")}' (e.g. Cloudflare), add:\n` +
`${records}\n` +
` The deploy itself can proceed now (certs validate automatically once delegation propagates).`);
}
else if (zoneNs.length > 0 && !publicNs.some((ns) => zoneNs.includes(ns))) {
logWarn(`Parent zone '${parentZone}' is publicly delegated to DIFFERENT nameservers than the Route53 zone in this account:\n` +
` public: ${publicNs.join(", ")}\n` +
` zone: ${zoneNs.join(", ")}\n` +
` The env zone would be created in a zone the internet never consults — fix the delegation before deploying.`);
}
else {
logSuccess(`Parent zone delegation OK: ${parentZone} resolves publicly.`);
}
}
function readBaseConfigFromYaml(appRoot, stack) {
const file = path.join(appRoot, `Pulumi.${stack}.yaml`);
if (!fs.existsSync(file))
return {};
let doc;
try {
doc = parseYaml(fs.readFileSync(file, "utf-8"));
}
catch {
return {};
}
const cfg = doc?.config ?? {};
const out = {};
for (const [key, val] of Object.entries(cfg)) {
out[key] =
val !== null && typeof val === "object"
? { value: "", objectValue: val }
: { value: String(val) };
}
return out;
}
function getGitHubUsername() {
try {
const result = execFileSync("gh", ["api", "user", "--jq", ".login"], {
encoding: "utf-8",
stdio: ["pipe", "pipe", "pipe"],
});
const login = result.trim();
if (login)
return login;
}
catch { }
try {
const result = execFileSync("git", ["config", "user.email"], {
encoding: "utf-8",
stdio: ["pipe", "pipe", "pipe"],
});
const email = result.trim();
const match = email.match(/^(\d+\+)?([^@]+)@/);
if (match)
return match[2];
}
catch { }
return null;
}
export function registerStackCommand(program) {
const stack = program
.command("stack")
.description("Manage personal Pulumi stacks");
stack
.command("init")
.description("Create a personal dev stack (dev-{github-username})")
.option("--from <stack>", "Base stack to copy config from")
.option("--name <name>", "Override stack name (default: dev-{username})")
.option("--adopt", "resume an EXISTING stack with this name (skips the availability check)", false)
.option("--worktree", "Isolate this stack per git worktree — append the worktree token to the name so concurrent worktrees deploy to distinct stacks (no SSM/namespace collisions)")
.option("--platform", "Personal PLATFORM stack: a fully isolated platform env (Zitadel, Hub, Temporal, tenant envs) on the SHARED core — sets mesh:coreEnv to the base env, mesh:clusterOwner false, disables org-wide singletons (package registry, VPN), and keeps deploy ON. Run from a platform Pulumi program.", false)
.option("--parent-zone <zone>", "With --platform: parent DNS zone for the env zone (mesh:dns.public.parentZone) — platform hostnames become {sub}.{env}.{parentZone} (e.g. hub.<env>.<parentZone>), so personal stacks never collide on DNS")
.action(async (opts) => {
const appRoot = findAppRoot(process.cwd());
if (!appRoot) {
logError("No Pulumi.yaml found. Run from within a Pulumi app directory.");
process.exit(1);
}
const username = getGitHubUsername();
if (!username) {
logError("Could not determine GitHub username.\n" +
"Install gh CLI (https://cli.github.com) and run: gh auth login");
process.exit(1);
}
let baseStack = opts.from;
if (!baseStack) {
const stacks = findStackConfigs(appRoot);
if (stacks.includes("dev")) {
baseStack = "dev";
}
else if (stacks.length === 1) {
baseStack = stacks[0];
}
else if (stacks.length > 1) {
logError(`Multiple stacks found: ${stacks.join(", ")}\n` +
"Specify which to base on: mesh stack init --from <stack>");
process.exit(1);
}
else {
logError("No stack configs found (Pulumi.<stack>.yaml).");
process.exit(1);
}
}
const baseConfigPath = `${appRoot}/Pulumi.${baseStack}.yaml`;
if (!fs.existsSync(baseConfigPath)) {
logError(`Stack config not found: Pulumi.${baseStack}.yaml`);
process.exit(1);
}
const baseTenant = readConfigBlockKey(appRoot, baseStack, "mesh:tenant");
if (opts.platform && !baseTenant) {
logError(`--platform: could not read mesh:tenant from Pulumi.${baseStack}.yaml — is this a platform program?`);
process.exit(1);
}
const baseName = opts.name ??
(opts.platform ? `${baseTenant}-${username}-dev` : `dev-${username}`);
let newStack = baseName;
if (opts.worktree) {
const wt = resolveWorktreeIdentity(appRoot);
newStack = worktreeStackName(baseName, wt);
if (wt.token) {
logInfo(`--worktree: isolating as "${newStack}" (linked worktree "${wt.slug}")`);
}
else {
logInfo(`--worktree: primary checkout has no worktree token — using "${baseName}" (isolation not needed here).`);
}
}
const newConfigPath = path.join(appRoot, `Pulumi.${newStack}.yaml`);
const configExists = fs.existsSync(newConfigPath);
const secretsProvider = readTopLevelYamlKey(appRoot, baseStack, "secretsprovider");
const credEnv = await resolvePulumiEnv({ appRoot, stack: baseStack });
const pulumiEnv = { ...process.env, ...credEnv };
delete pulumiEnv.AWS_PROFILE;
if (!opts.adopt) {
let existing = [];
try {
const raw = execFileSync("pulumi", ["stack", "ls", "--json"], {
cwd: appRoot,
encoding: "utf-8",
env: pulumiEnv,
stdio: ["pipe", "pipe", "pipe"],
});
existing = JSON.parse(raw).map((s) => s.name);
}
catch {
}
if (existing.includes(newStack) || configExists) {
logError(`Personal stack name '${newStack}' is not available` +
(configExists ? ` (Pulumi.${newStack}.yaml already exists here)` : " (a stack with this name exists in the backend)") +
".");
logInfo(` Pick another name: mesh stack init --name ${newStack}-2`);
logInfo(` Resume YOUR OWN stack: mesh stack init --name ${newStack} --adopt`);
process.exit(1);
}
}
const initArgs = ["stack", "init", newStack];
if (secretsProvider) {
initArgs.push("--secrets-provider", secretsProvider);
logInfo(`Using KMS secrets provider: ${secretsProvider}`);
}
try {
execFileSync("pulumi", initArgs, {
cwd: appRoot,
env: pulumiEnv,
stdio: "inherit",
});
logSuccess(`Stack initialized: ${newStack}${secretsProvider ? " (KMS secrets)" : ""}`);
}
catch (err) {
if (err.stderr?.includes("already exists") && opts.adopt) {
logWarn(`Stack ${newStack} already exists — adopting (--adopt).`);
}
else if (err.stderr?.includes("already exists")) {
logError(`Stack ${newStack} already exists. Re-run with --adopt if it is yours.`);
process.exit(1);
}
else {
process.exit(err.status ?? 1);
}
}
try {
execFileSync("pulumi", ["stack", "select", newStack], {
cwd: appRoot,
env: pulumiEnv,
stdio: ["pipe", "pipe", "pipe"],
});
}
catch { }
if (!configExists) {
let baseConfig = {};
try {
const raw = execFileSync("pulumi", ["config", "--json", "--stack", baseStack], { cwd: appRoot, env: pulumiEnv, encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] });
baseConfig = JSON.parse(raw);
}
catch {
baseConfig = readBaseConfigFromYaml(appRoot, baseStack);
if (Object.keys(baseConfig).length === 0) {
logWarn(`Could not read base config from the backend or Pulumi.${baseStack}.yaml — ` +
`the new stack will need manual config.`);
}
}
for (const [key, entry] of Object.entries(baseConfig)) {
if (key === "mesh:deploy")
continue;
try {
if (entry.objectValue !== undefined) {
execFileSync("pulumi", ["config", "set", key, JSON.stringify(entry.objectValue)], { cwd: appRoot, env: pulumiEnv, stdio: ["pipe", "pipe", "pipe"] });
}
else if (entry.value === "true" || entry.value === "false") {
execFileSync("pulumi", ["config", "set", "--type", "bool", key, entry.value], { cwd: appRoot, env: pulumiEnv, stdio: ["pipe", "pipe", "pipe"] });
}
else {
execFileSync("pulumi", ["config", "set", key, entry.value], { cwd: appRoot, env: pulumiEnv, stdio: ["pipe", "pipe", "pipe"] });
}
}
catch { }
}
if (opts.platform) {
const baseEnv = readConfigBlockKey(appRoot, baseStack, "mesh:coreEnv") ??
(baseTenant && baseStack.startsWith(`${baseTenant}-`)
? baseStack.slice(baseTenant.length + 1)
: baseStack);
const setCfg = (args) => {
try {
execFileSync("pulumi", ["config", "set", ...args], {
cwd: appRoot,
env: pulumiEnv,
stdio: ["pipe", "pipe", "pipe"],
});
}
catch { }
};
setCfg(["mesh:coreEnv", baseEnv]);
setCfg(["--type", "bool", "mesh:clusterOwner", "false"]);
setCfg(["--type", "bool", "mesh:packageRegistry", "false"]);
setCfg(["--type", "bool", "mesh:headscale", "false"]);
setCfg(["--type", "bool", "mesh:subnetRouter", "false"]);
setCfg(["--type", "bool", "mesh:devbox", "false"]);
setCfg(["--type", "bool", "mesh:stableUrl", "true"]);
try {
const tenantsRaw = readConfigBlockKey(appRoot, newStack, "mesh:tenants");
if (tenantsRaw) {
const tenants = JSON.parse(tenantsRaw);
const envSegment = baseTenant && newStack.startsWith(`${baseTenant}-`)
? newStack.slice(baseTenant.length + 1)
: newStack;
for (const [tName, tCfg] of Object.entries(tenants)) {
if (!tCfg.pulumiStateBucket) {
tCfg.pulumiStateBucket = `${baseTenant}-tenant-${tName}-${envSegment}-pulumi-state`;
}
}
setCfg(["mesh:tenants", JSON.stringify(tenants)]);
}
}
catch {
logWarn("Could not env-scope tenant pulumiStateBucket overrides — check mesh:tenants by hand (bucket names collide across envs otherwise).");
}
if (opts.parentZone) {
setCfg([
"mesh:dns",
JSON.stringify({
public: { parentZone: opts.parentZone, waitForValidation: false },
}),
]);
}
else {
logWarn("No --parent-zone given: platform hostnames will derive FLAT from the core zone and can collide with the primary platform. Set it now or later with: mesh deploy config set mesh:dns '{\"public\":{\"parentZone\":\"<zone>\"}}'");
}
if (opts.parentZone) {
await checkParentZoneDelegation(opts.parentZone, pulumiEnv);
}
logSuccess(`Configured Pulumi.${newStack}.yaml (personal platform env on core '${baseEnv}'${opts.parentZone ? `, DNS zone ${newStack.startsWith(`${baseTenant}-`) ? newStack.slice(baseTenant.length + 1) : newStack}.${opts.parentZone}` : ""})`);
}
else {
execFileSync("pulumi", ["config", "set", "--type", "bool", "mesh:deploy", "false"], {
cwd: appRoot,
env: pulumiEnv,
stdio: ["pipe", "pipe", "pipe"],
});
logSuccess(`Configured Pulumi.${newStack}.yaml (based on ${baseStack}, deploy: false)`);
}
}
console.log("");
logInfo(opts.platform ? `Your personal platform stack is ready.` : `Your personal stack is ready.`);
logInfo(`Run: mesh deploy up --stack ${newStack} --yes`);
logInfo(opts.platform
? `Then point app stacks at it: mesh:platformEnv ${newStack.startsWith(`${baseTenant}-`) ? newStack.slice(baseTenant.length + 1) : newStack}`
: `Then: mesh dev --stage ${newStack}`);
});
stack
.command("rm <name>")
.description("Remove a personal Pulumi stack (backend state + local config)")
.option("--yes", "Skip the confirmation prompt")
.action(async (name, opts) => {
const appRoot = findAppRoot(process.cwd());
if (!appRoot) {
logError("No Pulumi.yaml found. Run from within a Pulumi app directory.");
process.exit(1);
}
const credStack = readStackConfig(appRoot, name, "mesh:deployerRole")
? name
: [getCurrentStack(appRoot), "dev", ...findStackConfigs(appRoot)].find((s) => !!s && s !== name && !!readStackConfig(appRoot, s, "mesh:deployerRole")) ?? name;
const credEnv = await resolvePulumiEnv({ appRoot, stack: credStack });
const pulumiEnv = { ...process.env, ...credEnv };
delete pulumiEnv.AWS_PROFILE;
const args = ["stack", "rm", name];
if (opts.yes)
args.push("--yes");
try {
execFileSync("pulumi", args, { cwd: appRoot, env: pulumiEnv, stdio: "inherit" });
logSuccess(`Removed stack ${name}`);
}
catch (err) {
process.exit(err.status ?? 1);
}
});
}