@mesh-tech/mesh-cli
Version:
CLI for Mesh platform development utilities
382 lines (381 loc) • 15.1 kB
JavaScript
import { SSMClient, GetParametersByPathCommand, } from "@aws-sdk/client-ssm";
import { SecretsManagerClient, GetSecretValueCommand, PutSecretValueCommand, CreateSecretCommand, } from "@aws-sdk/client-secrets-manager";
import input from "@inquirer/input";
import password from "@inquirer/password";
import { buildInstanceIndexValue, instanceIndexSecretId, isInstanceKey, parseInstanceIndex, } from "@mesh-tech/secrets";
import { detectContext } from "../../utils/context.js";
import { logInfo, logSuccess, logError } from "../../utils/log.js";
import { resolveStackOption } from "../../utils/stack-flag.js";
import { findAppRoot, readStackConfig } from "../../utils/pulumi.js";
import { resolveAwsCredentials } from "../../utils/aws-auth.js";
export async function ensureAwsCredentialsForStack(stack) {
if (process.env.AWS_ACCESS_KEY_ID || process.env.AWS_SESSION_TOKEN)
return;
if (!stack)
return;
const appRoot = findAppRoot(process.cwd());
if (!appRoot)
return;
const roleArn = readStackConfig(appRoot, stack, "mesh:deployerRole");
if (!roleArn)
return;
const resolved = await resolveAwsCredentials(roleArn, appRoot, stack);
if (resolved) {
Object.assign(process.env, resolved.env);
delete process.env.AWS_PROFILE;
}
}
function normalizeEntry(raw) {
if (raw.type === "group" && raw.fields) {
return {
type: "group",
name: raw.name ?? "",
description: raw.description ?? "",
fields: normalizeFields(raw.fields),
};
}
return {
type: "field",
name: raw.name ?? "",
description: raw.description ?? "",
secret: raw.secret ?? false,
optional: raw.optional ?? false,
};
}
function normalizeFields(raw) {
return Object.fromEntries(Object.entries(raw).map(([key, value]) => [key, normalizeEntry(value)]));
}
export async function discoverExternalServices(ssmClient, tenant, platformEnv) {
const services = new Map();
const basePath = `/mesh-platform/${tenant}/${platformEnv}`;
let nextToken;
do {
const response = await ssmClient.send(new GetParametersByPathCommand({
Path: basePath,
Recursive: true,
NextToken: nextToken,
}));
for (const param of response.Parameters ?? []) {
if (!param.Name || !param.Value)
continue;
const metaMatch = param.Name.match(/\/external-services\/([^/]+)\/meta$/);
if (metaMatch?.[1]) {
const serviceName = metaMatch[1];
const meta = JSON.parse(param.Value);
const existing = services.get(serviceName);
services.set(serviceName, { ...existing, meta });
}
const credMatch = param.Name.match(/\/external-services\/([^/]+)\/credentials$/);
if (credMatch?.[1]) {
const serviceName = credMatch[1];
const raw = JSON.parse(param.Value);
const schema = {
fields: normalizeFields(raw.fields),
keyedBy: raw.keyedBy,
};
const existing = services.get(serviceName);
if (existing) {
existing.schema = schema;
}
else {
services.set(serviceName, { meta: {}, schema });
}
}
}
nextToken = response.NextToken;
} while (nextToken);
return services;
}
async function promptFields(fields, existing, result, opts, depth = 0) {
for (const [fieldKey, entry] of Object.entries(fields)) {
if (entry.type === "group") {
const indent = " ".repeat(depth);
console.error("");
console.error(`${indent}── ${entry.name} ──`);
const existingGroup = (existing[fieldKey] ?? {});
const resultGroup = {};
await promptFields(entry.fields, existingGroup, resultGroup, opts, depth + 1);
result[fieldKey] = resultGroup;
}
else {
if (entry.optional && !opts.all) {
const existingVal = existing[fieldKey];
if (existingVal !== undefined && existingVal !== "") {
result[fieldKey] = existingVal;
}
continue;
}
const existingVal = existing[fieldKey];
const hasExisting = !!existingVal && existingVal !== "";
const message = entry.description
? `${entry.name} (${entry.description})`
: entry.name;
const label = entry.optional ? `${message} [optional]` : message;
let value;
if (entry.secret) {
value = await password({ message: label, mask: "*" });
if (!value && hasExisting) {
value = existingVal;
}
}
else {
value = await input({
message: label,
default: hasExisting ? existingVal : undefined,
});
}
result[fieldKey] = value || (hasExisting ? existingVal : "");
}
}
}
function displaySummary(fields, values, depth = 0) {
const indent = " ".repeat(depth);
for (const [key, entry] of Object.entries(fields)) {
if (entry.type === "group") {
console.error(`${indent}${entry.name}:`);
const group = (values[key] ?? {});
displaySummary(entry.fields, group, depth + 1);
}
else {
const val = values[key];
if (!val)
continue;
const display = entry.secret ? "********" : String(val);
console.error(`${indent}${entry.name}: ${display}`);
}
}
}
export async function setCommand(servicePath, opts) {
const stackOpt = resolveStackOption(opts);
const context = detectContext(stackOpt);
const region = opts.region ?? process.env.AWS_REGION ?? "us-east-2";
await ensureAwsCredentialsForStack(stackOpt ?? context.stage);
const ssmClient = new SSMClient({ region });
const smClient = new SecretsManagerClient({ region });
const services = await discoverExternalServices(ssmClient, context.tenant, context.platformEnv);
if (services.size === 0) {
logError("No external services found. Deploy an ExternalService first:\n" +
" new mesh.apps.ExternalService(\"symitar\", { env, credentials: { ... } })");
process.exit(1);
}
let serviceName;
if (servicePath) {
serviceName = servicePath.replace(/^external\//, "");
}
if (!serviceName || !services.has(serviceName)) {
if (serviceName) {
logError(`External service "${serviceName}" not found.`);
}
console.error("\nAvailable external services:");
for (const [name, svc] of services) {
const desc = svc.meta?.description ? ` — ${svc.meta.description}` : "";
const type = svc.meta?.type ? ` (${svc.meta.type})` : "";
console.error(` ${name}${type}${desc}`);
}
console.error(`\nUsage: mesh secrets set external/<name>`);
process.exit(serviceName ? 1 : 0);
}
const svc = services.get(serviceName);
const schema = svc.schema;
if (!schema) {
logError(`No credential schema found for "${serviceName}".`);
process.exit(1);
}
const secretPrefix = svc.meta.secretPrefix;
const secretId = opts.key ? `${secretPrefix}/${opts.key}` : secretPrefix;
if (schema.keyedBy && !opts.key) {
logError(`"${serviceName}" is multi-instance (keyed by ${schema.keyedBy}). ` +
`Specify an instance:\n mesh secrets set external/${serviceName} --key=<${schema.keyedBy}>`);
process.exit(1);
}
if (opts.key && !isInstanceKey(opts.key)) {
logError(`Invalid instance key "${opts.key}": keys must not contain "/", start with ".", ` +
`or end with ".config" — those names are reserved for the index and config mirrors.`);
process.exit(1);
}
let existing = {};
try {
const response = await smClient.send(new GetSecretValueCommand({ SecretId: secretId }));
if (response.SecretString) {
existing = JSON.parse(response.SecretString);
}
}
catch (err) {
if (!isResourceNotFound(err)) {
throw err;
}
}
if (opts.json) {
let values;
try {
values = JSON.parse(opts.json);
}
catch {
logError("Invalid JSON input. Provide valid JSON, e.g.: --json='{\"key\": \"value\"}'");
return;
}
const merged = deepMerge(existing, values);
await writeSecret(smClient, secretId, merged, svc.meta);
await writeConfigMirror(smClient, secretId, schema, merged, svc.meta);
if (opts.key) {
await addKeyToInstanceIndex(smClient, secretPrefix, opts.key, serviceName);
}
logSuccess(`Credentials written to ${secretId}`);
return;
}
console.error("");
logInfo(`Setting credentials for: ${svc.meta.description || serviceName}`);
if (opts.key) {
logInfo(`Instance: ${opts.key}`);
}
logInfo(`Secret: ${secretId}`);
const values = {};
await promptFields(schema.fields, existing, values, { all: !!opts.all });
console.error("");
const merged = deepMerge(existing, values);
await writeSecret(smClient, secretId, merged, svc.meta);
await writeConfigMirror(smClient, secretId, schema, merged, svc.meta);
if (opts.key) {
await addKeyToInstanceIndex(smClient, secretPrefix, opts.key, serviceName);
}
logSuccess(`Credentials written to ${secretId}`);
console.error("\nStored fields:");
displaySummary(schema.fields, merged);
console.error("");
}
export function isResourceNotFound(err) {
const e = err;
return (e?.name === "ResourceNotFoundException" ||
e?.__type === "ResourceNotFoundException" ||
e?.Code === "ResourceNotFoundException" ||
e?.code === "ResourceNotFoundException" ||
(typeof e?.message === "string" && e.message.includes("ResourceNotFoundException")));
}
function deepMerge(target, source) {
const result = { ...target };
for (const [key, value] of Object.entries(source)) {
if (value !== null &&
typeof value === "object" &&
!Array.isArray(value) &&
typeof result[key] === "object" &&
result[key] !== null &&
!Array.isArray(result[key])) {
result[key] = deepMerge(result[key], value);
}
else {
result[key] = value;
}
}
return result;
}
async function writeSecret(client, secretId, values, meta) {
const secretString = JSON.stringify(values);
try {
await client.send(new PutSecretValueCommand({
SecretId: secretId,
SecretString: secretString,
}));
}
catch (err) {
if (isResourceNotFound(err)) {
await client.send(new CreateSecretCommand({
Name: secretId,
Description: `External service credentials for ${meta.name} (instance)`,
SecretString: secretString,
Tags: [
{ Key: "mesh:type", Value: "external-service" },
{ Key: "mesh:service", Value: meta.name },
],
}));
}
else {
throw err;
}
}
}
function extractNonSecretFields(fields, values) {
const result = {};
for (const [key, entry] of Object.entries(fields)) {
if (entry.type === "group") {
const groupValues = (values[key] ?? {});
const filtered = extractNonSecretFields(entry.fields, groupValues);
if (Object.keys(filtered).length > 0) {
result[key] = filtered;
}
}
else if (!entry.secret && values[key] !== undefined) {
result[key] = values[key];
}
}
return result;
}
export async function addKeyToInstanceIndex(client, secretPrefix, key, serviceName) {
const indexId = instanceIndexSecretId(secretPrefix);
try {
let keys = [];
try {
const res = await client.send(new GetSecretValueCommand({ SecretId: indexId }));
keys = parseInstanceIndex(res.SecretString ?? "");
}
catch (err) {
if (!isResourceNotFound(err))
throw err;
}
if (keys.includes(key))
return;
const secretString = buildInstanceIndexValue([...keys, key]);
try {
await client.send(new PutSecretValueCommand({ SecretId: indexId, SecretString: secretString }));
}
catch (err) {
if (!isResourceNotFound(err))
throw err;
await client.send(new CreateSecretCommand({
Name: indexId,
Description: `Instance-key index for ${serviceName} (read by listInstances(); maintained by mesh secrets set/reindex and the Hub)`,
SecretString: secretString,
Tags: [
{ Key: "mesh:type", Value: "external-service-index" },
{ Key: "mesh:service", Value: serviceName },
],
}));
logInfo(`Created ${indexId} OUT OF BAND — the declaring stack does not manage it. ` +
`Deploy the declaring stack (it creates the index) before writing keys, or ` +
`import this secret into the stack before its next deploy.`);
}
}
catch (err) {
logInfo(`Warning: failed to record instance "${key}" in ${indexId} — ` +
`listInstances() will not see it until you run: ` +
`mesh secrets reindex external/${serviceName} (${err})`);
}
}
async function writeConfigMirror(client, secretId, schema, merged, meta) {
const configId = `${secretId}/.config`;
const configValues = extractNonSecretFields(schema.fields, merged);
const secretString = JSON.stringify(configValues);
try {
try {
await client.send(new PutSecretValueCommand({
SecretId: configId,
SecretString: secretString,
}));
}
catch (err) {
if (isResourceNotFound(err)) {
await client.send(new CreateSecretCommand({
Name: configId,
Description: `Non-secret credential fields for ${meta.name} (Hub display)`,
SecretString: secretString,
Tags: [{ Key: "mesh:type", Value: "external-service-config" }],
}));
}
else {
throw err;
}
}
}
catch (err) {
logInfo(`Warning: failed to write .config mirror to ${configId}: ${err}`);
}
}