UNPKG

@mesh-tech/mesh-cli

Version:

CLI for Mesh platform development utilities

444 lines (443 loc) 19.3 kB
import * as fs from "fs"; import * as os from "os"; import * as path from "path"; import { hubOperatorRoleKeys, hubRoleKeyTenant, restrictedRoleBase } from "@mesh-tech/api-registry"; import { logInfo, logSuccess, logWarn } from "../../utils/log.js"; import { MeshCliError } from "../../utils/errors.js"; import { writeContextConfig } from "../login.js"; import { compose, hubPort, DEFAULT_HUB_PORT } from "./stack.js"; import { LOCAL_TENANT, LOCAL_ENV } from "./seed.js"; import { meshCacheDir } from "../../utils/cache-home.js"; export const ZITADEL_ISSUER = "http://localhost:8080"; export const LOGIN_CONTEXT = "local"; export const PLATFORM_ORG = "mesh"; export const CLI_PROJECT_NAME = "mesh-cli"; export const CLI_APP_NAME = "cli"; export const CLI_REDIRECT_URI = "http://localhost:9876/callback"; export const HUB_PROJECT_NAME = "hub"; export const HUB_APP_NAME = "ui"; export const hubRedirectUri = () => `http://localhost:${hubPort()}/oauth2/callback`; export const HUB_DEFAULT_REDIRECT_URI = `http://localhost:${DEFAULT_HUB_PORT}/oauth2/callback`; export const HUB_ROLES = hubOperatorRoleKeys([LOCAL_TENANT]); export const ZITADEL_SSM_PARAM = `/mesh-platform/${LOCAL_TENANT}/${LOCAL_ENV}/platform/zitadel`; export const TEST_USERS_SSM_PREFIX = `/mesh-platform/${LOCAL_TENANT}/${LOCAL_ENV}/temporal/test-users`; export const TEST_USERS = [ { name: "dev", email: "dev@local.mesh", firstName: "Dev", lastName: "User", password: "LocalDev1!", }, { name: "ops", email: "ops@local.mesh", firstName: "Ops", lastName: "User", password: "LocalDev1!", }, ]; export function buildCliAppPayload() { return { name: CLI_APP_NAME, redirectUris: [CLI_REDIRECT_URI], responseTypes: ["OIDC_RESPONSE_TYPE_CODE"], grantTypes: ["OIDC_GRANT_TYPE_AUTHORIZATION_CODE", "OIDC_GRANT_TYPE_DEVICE_CODE"], appType: "OIDC_APP_TYPE_NATIVE", authMethodType: "OIDC_AUTH_METHOD_TYPE_NONE", accessTokenType: "OIDC_TOKEN_TYPE_JWT", accessTokenRoleAssertion: true, idTokenRoleAssertion: true, idTokenUserinfoAssertion: true, devMode: true, }; } export function readSeederPat() { const tmp = path.join(fs.mkdtempSync(path.join(os.tmpdir(), "mesh-zitadel-")), "pat.txt"); try { compose(["cp", "zitadel:/machinekey/pat.txt", tmp]); const pat = fs.readFileSync(tmp, "utf-8").trim(); if (!pat) throw new Error("empty PAT file"); return pat; } catch { throw new MeshCliError("Zitadel seeder PAT not found — the zitadel volume predates the bootstrap machine user (first-instance settings only apply on the first init).", { remediation: { command: "mesh stop --destroy && mesh start" } }); } finally { fs.rmSync(path.dirname(tmp), { recursive: true, force: true }); } } export async function api(pat, method, apiPath, body, orgId) { const res = await fetch(`${ZITADEL_ISSUER}${apiPath}`, { method, headers: { Authorization: `Bearer ${pat}`, "Content-Type": "application/json", ...(orgId ? { "x-zitadel-orgid": orgId } : {}), }, body: body === undefined ? undefined : JSON.stringify(body), signal: AbortSignal.timeout(15_000), }); const text = await res.text(); const data = text ? JSON.parse(text) : {}; if (!res.ok) { const err = new Error(`Zitadel ${method} ${apiPath} → ${res.status}: ${data?.message ?? text}`); err.status = res.status; err.zitadelCode = data?.code; throw err; } return data; } export const isAlreadyExists = (err) => err?.status === 409 || err?.zitadelCode === 6 || /already exists/i.test(err?.message ?? ""); export async function ensureZitadelProject(pat, name, opts = {}) { const search = await api(pat, "POST", "/management/v1/projects/_search", { queries: [{ nameQuery: { name, method: "TEXT_QUERY_METHOD_EQUALS" } }] }, opts.orgId); const existing = search?.result?.[0]?.id; if (existing) { if (opts.logExisting) logInfo(`Zitadel project '${name}' already exists`); return existing; } const created = await api(pat, "POST", "/management/v1/projects", { name, projectRoleAssertion: true }, opts.orgId); logSuccess(`Created Zitadel project '${name}'${opts.describe ? ` (${opts.describe})` : ""}`); return created.id; } export async function searchZitadelApp(pat, projectId, name, orgId) { const search = await api(pat, "POST", `/management/v1/projects/${projectId}/apps/_search`, { queries: [{ nameQuery: { name, method: "TEXT_QUERY_METHOD_EQUALS" } }] }, orgId); return search?.result?.[0]; } const ensureCliProject = (pat) => ensureZitadelProject(pat, CLI_PROJECT_NAME, { describe: "platform client feature", logExisting: true, }); async function ensureCliApp(pat, projectId) { const existing = (await searchZitadelApp(pat, projectId, CLI_APP_NAME))?.oidcConfig?.clientId; if (existing) { logInfo(`Zitadel app '${CLI_APP_NAME}' already exists`); return existing; } const created = await api(pat, "POST", `/management/v1/projects/${projectId}/apps/oidc`, buildCliAppPayload()); logSuccess(`Created Zitadel application '${CLI_APP_NAME}' (PKCE + device code)`); return created.clientId; } async function ensureTestUsers(pat) { for (const user of TEST_USERS) { try { await api(pat, "POST", "/management/v1/users/human/_import", { userName: user.email, profile: { firstName: user.firstName, lastName: user.lastName, displayName: `${user.firstName} ${user.lastName}`, }, email: { email: user.email, isEmailVerified: true }, password: user.password, passwordChangeRequired: false, }); logSuccess(`Created test user ${user.email}`); } catch (err) { if (isAlreadyExists(err)) { logInfo(`Test user ${user.email} already exists`); } else { throw err; } } } } export function hubAuthPath() { return path.join(meshCacheDir("mesh-local"), "hub-auth.json"); } export function readHubAuth() { try { const parsed = JSON.parse(fs.readFileSync(hubAuthPath(), "utf-8")); if (parsed?.clientId && parsed?.clientSecret && parsed?.cookieSecret) return parsed; return null; } catch { return null; } } const ensureHubProject = (pat) => ensureZitadelProject(pat, HUB_PROJECT_NAME, { describe: "Hub platform app" }); function hubRoleDisplayName(roleKey) { const tenant = hubRoleKeyTenant(roleKey); return tenant === null ? roleKey : `${restrictedRoleBase(roleKey) ?? roleKey} (${tenant})`; } async function ensureHubRoles(pat, projectId) { for (const role of HUB_ROLES) { try { await api(pat, "POST", `/management/v1/projects/${projectId}/roles`, { roleKey: role, displayName: hubRoleDisplayName(role), }); logSuccess(`Created Hub role '${role}'`); } catch (err) { if (!isAlreadyExists(err)) throw err; } } } export async function reconcileHubRedirectUris(pat, projectId, app) { const cfg = app?.oidcConfig ?? {}; const redirectUris = [ ...new Set([...(cfg.redirectUris ?? []), hubRedirectUri(), HUB_DEFAULT_REDIRECT_URI]), ]; const postLogoutRedirectUris = [ ...new Set([ ...(cfg.postLogoutRedirectUris ?? []), `http://localhost:${hubPort()}`, `http://localhost:${hubPort()}/`, `http://localhost:${DEFAULT_HUB_PORT}`, `http://localhost:${DEFAULT_HUB_PORT}/`, ]), ]; if (redirectUris.length === (cfg.redirectUris ?? []).length && postLogoutRedirectUris.length === (cfg.postLogoutRedirectUris ?? []).length) { return; } await api(pat, "PUT", `/management/v1/projects/${projectId}/apps/${app.id}/oidc_config`, { redirectUris, postLogoutRedirectUris, responseTypes: cfg.responseTypes ?? ["OIDC_RESPONSE_TYPE_CODE"], grantTypes: cfg.grantTypes ?? [ "OIDC_GRANT_TYPE_AUTHORIZATION_CODE", "OIDC_GRANT_TYPE_REFRESH_TOKEN", ], appType: "OIDC_APP_TYPE_WEB", authMethodType: "OIDC_AUTH_METHOD_TYPE_BASIC", accessTokenType: cfg.accessTokenType ?? "OIDC_TOKEN_TYPE_JWT", accessTokenRoleAssertion: cfg.accessTokenRoleAssertion ?? true, idTokenRoleAssertion: cfg.idTokenRoleAssertion ?? true, idTokenUserinfoAssertion: cfg.idTokenUserinfoAssertion ?? true, devMode: cfg.devMode ?? true, }); logSuccess(`Registered Hub redirect URI http://localhost:${hubPort()}/oauth2/callback (MESH_HUB_PORT)`); } async function ensureHubApp(pat, projectId) { const existing = await searchZitadelApp(pat, projectId, HUB_APP_NAME); if (existing) { await reconcileHubRedirectUris(pat, projectId, existing); const clientId = existing?.oidcConfig?.clientId; const persisted = readHubAuth(); if (persisted && persisted.clientId === clientId) return { clientId }; const regenerated = await api(pat, "POST", `/management/v1/projects/${projectId}/apps/${existing.id}/oidc_config/_generate_client_secret`, {}); logSuccess(`Regenerated Hub UI client secret (local copy was missing)`); return { clientId, clientSecret: regenerated.clientSecret }; } const created = await api(pat, "POST", `/management/v1/projects/${projectId}/apps/oidc`, { name: HUB_APP_NAME, redirectUris: [...new Set([hubRedirectUri(), HUB_DEFAULT_REDIRECT_URI])], postLogoutRedirectUris: [ ...new Set([ `http://localhost:${hubPort()}`, `http://localhost:${hubPort()}/`, `http://localhost:${DEFAULT_HUB_PORT}`, `http://localhost:${DEFAULT_HUB_PORT}/`, ]), ], responseTypes: ["OIDC_RESPONSE_TYPE_CODE"], grantTypes: ["OIDC_GRANT_TYPE_AUTHORIZATION_CODE", "OIDC_GRANT_TYPE_REFRESH_TOKEN"], appType: "OIDC_APP_TYPE_WEB", authMethodType: "OIDC_AUTH_METHOD_TYPE_BASIC", accessTokenType: "OIDC_TOKEN_TYPE_JWT", accessTokenRoleAssertion: true, idTokenRoleAssertion: true, idTokenUserinfoAssertion: true, devMode: true, }); logSuccess(`Created Zitadel application '${HUB_APP_NAME}' (Hub UI web client, code flow)`); return { clientId: created.clientId, clientSecret: created.clientSecret }; } async function ensureHubGrants(pat, projectId) { const grants = [ { email: "admin@local.mesh", roles: ["ops"] }, { email: "dev@local.mesh", roles: ["ops"] }, { email: "ops@local.mesh", roles: ["developer"] }, ]; for (const grant of grants) { const search = await api(pat, "POST", "/management/v1/users/_search", { queries: [{ userNameQuery: { userName: grant.email, method: "TEXT_QUERY_METHOD_EQUALS" } }], }); const userId = search?.result?.[0]?.id; if (!userId) continue; try { await api(pat, "POST", `/management/v1/users/${userId}/grants`, { projectId, roleKeys: grant.roles, }); logSuccess(`Granted Hub role(s) ${grant.roles.join(", ")} → ${grant.email}`); } catch (err) { if (!isAlreadyExists(err)) throw err; } } } export async function seedHubAuth(pat) { const projectId = await ensureHubProject(pat); await ensureHubRoles(pat, projectId); const app = await ensureHubApp(pat, projectId); await ensureHubGrants(pat, projectId); const persisted = readHubAuth(); const persistedCookie = persisted?.cookieSecret && [16, 24, 32].includes(persisted.cookieSecret.length) ? persisted.cookieSecret : undefined; const config = { clientId: app.clientId, projectId, clientSecret: app.clientSecret ?? persisted?.clientSecret ?? "", cookieSecret: persistedCookie ?? (await import("crypto")).randomBytes(16).toString("hex"), }; fs.writeFileSync(hubAuthPath(), JSON.stringify(config, null, 2), { mode: 0o600 }); return config; } async function publishHubAuthzPointer(projectId, orgId, awsConfig) { const { SSMClient, GetParameterCommand, PutParameterCommand } = await import("@aws-sdk/client-ssm"); const ssm = new SSMClient(awsConfig); const name = `/mesh-platform/${LOCAL_TENANT}/${LOCAL_ENV}/apps/hub/stacks/local/authz`; let current = {}; try { const existing = await ssm.send(new GetParameterCommand({ Name: name })); current = JSON.parse(existing.Parameter?.Value ?? "{}"); } catch { } const value = { spicedb: { instanceRefs: [] }, ...current, zitadel: { projectId, orgId, issuer: ZITADEL_ISSUER }, }; await ssm.send(new PutParameterCommand({ Name: name, Type: "String", Overwrite: true, Value: JSON.stringify(value), Description: "Hub authz pointer (local analog of the platform Pulumi program)", })); logSuccess(`Hub authz pointer published → project ${projectId} in org ${orgId}`); } async function writeRegistryParams(cliClientId, awsConfig) { const { SSMClient, PutParameterCommand } = await import("@aws-sdk/client-ssm"); const ssm = new SSMClient(awsConfig); await ssm.send(new PutParameterCommand({ Name: ZITADEL_SSM_PARAM, Type: "String", Overwrite: true, Value: JSON.stringify({ endpoint: ZITADEL_ISSUER, cliClientId }), Description: "Zitadel platform export (seeded by mesh start)", })); for (const user of TEST_USERS) { await ssm.send(new PutParameterCommand({ Name: `${TEST_USERS_SSM_PREFIX}/${user.name}`, Type: "String", Overwrite: true, Value: JSON.stringify({ email: user.email, password: user.password, roles: [] }), Description: `Local test user '${user.name}' (dev-grade, seeded by mesh start)`, })); } } export const OPS_HUB_SECRET_ID = "mesh/local/dev/zitadel/ops-hub"; async function ensureOpsHubAdmin(pat, awsConfig) { const { SecretsManagerClient, GetSecretValueCommand, CreateSecretCommand, PutSecretValueCommand, } = await import("@aws-sdk/client-secrets-manager"); const sm = new SecretsManagerClient(awsConfig); const existing = await sm .send(new GetSecretValueCommand({ SecretId: OPS_HUB_SECRET_ID })) .catch(() => null); if (existing?.SecretString) { logSuccess("Hub admin key already provisioned (Zitadel writes enabled)"); return; } const userName = "hub-opshub"; const found = await api(pat, "POST", "/management/v1/users/_search", { queries: [{ userNameQuery: { userName, method: "TEXT_QUERY_METHOD_EQUALS" } }], }); let userId = found?.result?.[0]?.id; if (!userId) { const created = await api(pat, "POST", "/management/v1/users/machine", { userName, name: "Hub Ops Hub admin", description: "Local Hub Zitadel admin plane (seeded by mesh start)", }); userId = created?.userId; } if (!userId) throw new MeshCliError("could not create the Hub's Zitadel admin user"); await api(pat, "POST", "/admin/v1/members", { userId, roles: ["IAM_OWNER"] }).catch(() => undefined); const key = await api(pat, "POST", `/management/v1/users/${userId}/keys`, { type: "KEY_TYPE_JSON", }); if (!key?.keyDetails) throw new MeshCliError("Zitadel did not return a machine key for the Hub admin user"); const raw = JSON.parse(Buffer.from(key.keyDetails, "base64").toString("utf8")); const secretString = JSON.stringify({ keyId: raw.keyId, key: raw.key, userId: raw.userId }); await sm .send(new CreateSecretCommand({ Name: OPS_HUB_SECRET_ID, SecretString: secretString })) .catch(async () => { await sm.send(new PutSecretValueCommand({ SecretId: OPS_HUB_SECRET_ID, SecretString: secretString })); }); logSuccess(`Hub admin key provisioned → ${OPS_HUB_SECRET_ID} (Zitadel writes enabled)`); } const LOCAL_SMTP = { host: "mailpit:1025", from: "no-reply@local.mesh", fromName: "Mesh (local)", }; async function ensureLocalSmtp(pat) { try { const existing = await api(pat, "POST", "/admin/v1/smtp/_search", {}).catch(() => null); const configs = existing?.result ?? []; const match = configs.find((c) => c.host === LOCAL_SMTP.host); if (!match) { const created = await api(pat, "POST", "/admin/v1/smtp", { senderAddress: LOCAL_SMTP.from, senderName: LOCAL_SMTP.fromName, tls: false, host: LOCAL_SMTP.host, user: "", password: "", }); if (created?.id) await api(pat, "POST", `/admin/v1/smtp/${created.id}/_activate`, {}); logSuccess(`Local mailbox wired to Zitadel → ${LOCAL_SMTP.host} (view at http://localhost:8025)`); return; } if (match.state !== "SMTP_CONFIG_ACTIVE" && match.id) { await api(pat, "POST", `/admin/v1/smtp/${match.id}/_activate`, {}); } } catch (err) { logWarn(`Could not wire the local mailbox to Zitadel (${err.message}). ` + "Activation and password-reset mail will not be delivered locally."); } } export async function seedZitadel(awsConfig) { const pat = readSeederPat(); const org = await api(pat, "POST", "/admin/v1/orgs/_search", { queries: [{ nameQuery: { name: PLATFORM_ORG, method: "TEXT_QUERY_METHOD_EQUALS" } }], }); if (!org?.result?.[0]) { throw new MeshCliError(`Platform org '${PLATFORM_ORG}' not found — the zitadel volume predates the org naming.`, { remediation: { command: "mesh stop --destroy && mesh start" } }); } logSuccess(`Platform org '${PLATFORM_ORG}' ready (platform tenant root — holds platform-service auth config)`); try { await api(pat, "PUT", "/v2/features/instance", { loginV2: { required: false } }); } catch { } await ensureLocalSmtp(pat); const projectId = await ensureCliProject(pat); const cliClientId = await ensureCliApp(pat, projectId); await ensureTestUsers(pat); const hubAuth = await seedHubAuth(pat); await publishHubAuthzPointer(hubAuth.projectId, org.result[0].id, awsConfig); await writeRegistryParams(cliClientId, awsConfig); await ensureOpsHubAdmin(pat, awsConfig); writeContextConfig(LOGIN_CONTEXT, { issuer: ZITADEL_ISSUER, clientId: cliClientId }); logSuccess(`Login context '${LOGIN_CONTEXT}' configured → try: mesh login ${LOGIN_CONTEXT}`); return { projectId, cliClientId, hubAuth }; }