UNPKG

failure-lambda

Version:

Failure injection for AWS Lambda - chaos engineering made simple

1,156 lines (1,138 loc) 37.4 kB
#!/usr/bin/env node import "./chunk-UT3JLF3M.js"; // src/cli.ts import { parseArgs } from "util"; // src/cli/store.ts import { SSMClient as SSMClient2, GetParameterCommand as GetParameterCommand2, PutParameterCommand } from "@aws-sdk/client-ssm"; // src/config.ts import { SSMClient, GetParameterCommand } from "@aws-sdk/client-ssm"; // src/types.ts var FAILURE_MODE_ORDER = [ "latency", "timeout", "diskspace", "denylist", "statuscode", "exception", "corruption" ]; // src/log.ts var SOURCE = "failure-lambda"; function warn(data) { console.warn(JSON.stringify({ source: SOURCE, level: "warn", ...data })); } // src/config.ts var KNOWN_FLAGS = new Set(FAILURE_MODE_ORDER); var MAX_DISK_SPACE_MB = 10240; var MAX_REGEX_LENGTH = 512; function isUnsafeRegex(pattern) { if (pattern.length > MAX_REGEX_LENGTH) return true; let depth = 0; const hasQuantifierInGroup = [false]; for (let i = 0; i < pattern.length; i++) { const ch = pattern[i]; if (ch === "\\") { i++; continue; } if (ch === "[") { i++; while (i < pattern.length) { if (pattern[i] === "\\") i++; else if (pattern[i] === "]") break; i++; } continue; } if (ch === "(") { depth++; hasQuantifierInGroup[depth] = false; continue; } if (ch === ")") { const groupHadQuantifier = hasQuantifierInGroup[depth] ?? false; depth = Math.max(0, depth - 1); if (groupHadQuantifier && i + 1 < pattern.length) { const next = pattern[i + 1]; if (next === "+" || next === "*" || next === "{") { return true; } } if (depth > 0 && groupHadQuantifier) { hasQuantifierInGroup[depth] = true; } continue; } if (depth > 0 && (ch === "+" || ch === "*")) { hasQuantifierInGroup[depth] = true; } if (depth > 0 && ch === "{") { const rest = pattern.slice(i); if (/^\{\d+,/.test(rest)) { hasQuantifierInGroup[depth] = true; } } } return false; } function validateFlagValue(mode, raw) { const errors = []; if (typeof raw.enabled !== "boolean") { errors.push({ field: `${mode}.enabled`, message: "must be a boolean", value: raw.enabled }); } if (raw.percentage !== void 0) { if (typeof raw.percentage !== "number" || !Number.isInteger(raw.percentage) || raw.percentage < 0 || raw.percentage > 100) { errors.push({ field: `${mode}.percentage`, message: "must be an integer between 0 and 100", value: raw.percentage }); } } if (mode === "latency") { if (raw.min_latency !== void 0) { if (typeof raw.min_latency !== "number" || raw.min_latency < 0) { errors.push({ field: `${mode}.min_latency`, message: "must be a non-negative number", value: raw.min_latency }); } } if (raw.max_latency !== void 0) { if (typeof raw.max_latency !== "number" || raw.max_latency < 0) { errors.push({ field: `${mode}.max_latency`, message: "must be a non-negative number", value: raw.max_latency }); } } if (typeof raw.min_latency === "number" && typeof raw.max_latency === "number" && raw.min_latency > raw.max_latency) { errors.push({ field: `${mode}.max_latency`, message: "max_latency must be >= min_latency", value: raw.max_latency }); } } if (mode === "exception") { if (raw.exception_msg !== void 0 && typeof raw.exception_msg !== "string") { errors.push({ field: `${mode}.exception_msg`, message: "must be a string", value: raw.exception_msg }); } } if (mode === "statuscode") { if (raw.status_code !== void 0) { if (typeof raw.status_code !== "number" || raw.status_code < 100 || raw.status_code > 599) { errors.push({ field: `${mode}.status_code`, message: "must be an HTTP status code (100-599)", value: raw.status_code }); } } } if (mode === "diskspace") { if (raw.disk_space !== void 0) { if (typeof raw.disk_space !== "number" || raw.disk_space <= 0 || raw.disk_space > MAX_DISK_SPACE_MB) { errors.push({ field: `${mode}.disk_space`, message: `must be between 1 and ${MAX_DISK_SPACE_MB} (MB)`, value: raw.disk_space }); } } } if (mode === "denylist") { if (raw.deny_list !== void 0) { if (!Array.isArray(raw.deny_list) || !raw.deny_list.every((item) => typeof item === "string")) { errors.push({ field: `${mode}.deny_list`, message: "must be an array of strings", value: raw.deny_list }); } else { for (let i = 0; i < raw.deny_list.length; i++) { const pattern = raw.deny_list[i]; try { new RegExp(pattern); } catch { errors.push({ field: `${mode}.deny_list[${i}]`, message: "invalid regular expression", value: pattern }); continue; } if (isUnsafeRegex(pattern)) { errors.push({ field: `${mode}.deny_list[${i}]`, message: "potentially unsafe pattern (nested quantifiers may cause excessive backtracking)", value: pattern }); } } } } } if (mode === "timeout") { if (raw.timeout_buffer_ms !== void 0) { if (typeof raw.timeout_buffer_ms !== "number" || raw.timeout_buffer_ms < 0) { errors.push({ field: `${mode}.timeout_buffer_ms`, message: "must be a non-negative number", value: raw.timeout_buffer_ms }); } } } if (mode === "corruption") { if (raw.body !== void 0 && typeof raw.body !== "string") { errors.push({ field: `${mode}.body`, message: "must be a string", value: raw.body }); } } if (raw.match !== void 0) { if (!Array.isArray(raw.match)) { errors.push({ field: `${mode}.match`, message: "must be an array of match condition objects", value: raw.match }); } else { const VALID_OPERATORS = /* @__PURE__ */ new Set(["eq", "exists", "startsWith", "regex"]); for (let i = 0; i < raw.match.length; i++) { const condition = raw.match[i]; if (typeof condition !== "object" || condition === null) { errors.push({ field: `${mode}.match[${i}]`, message: "must be an object with a string path field", value: condition }); continue; } const cond = condition; if (typeof cond.path !== "string") { errors.push({ field: `${mode}.match[${i}].path`, message: "must be a string", value: cond.path }); } const operator = cond.operator ?? "eq"; if (cond.operator !== void 0 && !VALID_OPERATORS.has(operator)) { errors.push({ field: `${mode}.match[${i}].operator`, message: `must be one of: eq, exists, startsWith, regex`, value: cond.operator }); } if (operator !== "exists" && typeof cond.value !== "string") { errors.push({ field: `${mode}.match[${i}].value`, message: "must be a string (required for all operators except 'exists')", value: cond.value }); } if (operator === "regex" && typeof cond.value === "string") { try { new RegExp(cond.value); } catch { errors.push({ field: `${mode}.match[${i}].value`, message: "invalid regular expression", value: cond.value }); continue; } if (isUnsafeRegex(cond.value)) { errors.push({ field: `${mode}.match[${i}].value`, message: "potentially unsafe pattern (nested quantifiers may cause excessive backtracking)", value: cond.value }); } } } } } return errors; } function parseFlags(raw) { if ("isEnabled" in raw || "failureMode" in raw) { warn({ action: "config", message: "detected 0.x configuration format \u2014 this version requires the v1.0 feature-flag format. See https://github.com/gunnargrosch/failure-lambda#migration-from-0x" }); } const config = {}; for (const key of Object.keys(raw)) { if (!KNOWN_FLAGS.has(key)) { continue; } const mode = key; const flagRaw = raw[mode]; if (typeof flagRaw !== "object" || flagRaw === null || Array.isArray(flagRaw)) { warn({ action: "config", mode, message: "must be an object, skipping" }); continue; } const flagObj = flagRaw; const validationErrors = validateFlagValue(mode, flagObj); if (validationErrors.length > 0) { for (const validationError of validationErrors) { warn({ action: "config", field: validationError.field, message: validationError.message, value: validationError.value }); } warn({ action: "config", mode, message: "skipping flag due to validation errors" }); continue; } config[mode] = flagObj; } return config; } // src/cli/prompts.ts var _clack = null; async function clack() { if (_clack === null) _clack = await import("./dist-SGXTLZKZ.js"); return _clack; } async function unwrapOrCancel(result) { const p = await clack(); if (p.isCancel(result)) { p.cancel("Operation cancelled."); process.exit(0); } return result; } var MODE_DESCRIPTIONS = { latency: "Add random latency to invocations", timeout: "Force Lambda timeout", diskspace: "Fill /tmp with data", denylist: "Block outgoing network requests by hostname pattern", statuscode: "Return an HTTP error status code", exception: "Throw an exception", corruption: "Replace the response body" }; async function promptCommand() { const p = await clack(); return await unwrapOrCancel( await p.select({ message: "What do you want to do?", options: [ { value: "status", label: "Status", hint: "Show current configuration" }, { value: "json", label: "View JSON", hint: "Show raw configuration JSON" }, { value: "enable", label: "Enable", hint: "Enable a failure mode" }, { value: "disable", label: "Disable", hint: "Disable a failure mode" }, { value: "switch", label: "Switch configuration", hint: "Select a different saved profile" }, { value: "exit", label: "Exit" } ] }) ); } var NEW_CONFIG = "__new__"; async function promptProfile(settings) { const p = await clack(); const entries = Object.entries(settings.profiles); if (entries.length === 0) { return null; } const choice = await unwrapOrCancel( await p.select({ message: "Select a saved configuration", options: [ ...entries.map(([name, profile]) => ({ value: name, label: name, hint: `${profile.region} - ${sourceLabel(profile.source)}` })), { value: NEW_CONFIG, label: "New configuration", hint: "Enter new region and config source" } ] }) ); if (choice === NEW_CONFIG) { return null; } return settings.profiles[choice]; } async function promptSaveProfile(profile, settings) { const p = await clack(); const save = await unwrapOrCancel( await p.confirm({ message: "Save this configuration for next time?", initialValue: true }) ); if (!save) { return { save: false }; } const existingNames = Object.keys(settings.profiles); const defaultName = profile.source.type === "ssm" ? `ssm-${profile.source.parameterName.split("/").filter(Boolean).join("-")}` : `appconfig-${profile.source.applicationId}`; const name = await unwrapOrCancel( await p.text({ message: "Profile name", defaultValue: defaultName, placeholder: defaultName, validate: (value) => { const resolved = value || defaultName; if (existingNames.includes(resolved.trim())) return `Profile "${resolved.trim()}" already exists`; return void 0; } }) ); return { save: true, name: name.trim() }; } async function promptConfirmCreate(label) { const p = await clack(); return await unwrapOrCancel( await p.confirm({ message: `${label} does not exist. Create it?`, initialValue: true }) ); } async function promptRegion() { const p = await clack(); const region = await unwrapOrCancel( await p.text({ message: "AWS region", defaultValue: "eu-north-1", placeholder: "eu-north-1" }) ); return region; } async function promptConfigSource() { const p = await clack(); const sourceType = await unwrapOrCancel( await p.select({ message: "Where is your failure-lambda configuration stored?", options: [ { value: "ssm", label: "SSM Parameter Store" }, { value: "appconfig", label: "AppConfig" } ] }) ); if (sourceType === "ssm") { const parameterName = await unwrapOrCancel( await p.text({ message: "SSM parameter name", placeholder: "/my-app/failure-config", validate: (value) => { if (!value || value.trim() === "") return "Parameter name is required"; return void 0; } }) ); return { type: "ssm", parameterName }; } const applicationId = await unwrapOrCancel( await p.text({ message: "AppConfig application ID", validate: (value) => { if (!value || value.trim() === "") return "Application ID is required"; return void 0; } }) ); const environmentId = await unwrapOrCancel( await p.text({ message: "AppConfig environment ID", validate: (value) => { if (!value || value.trim() === "") return "Environment ID is required"; return void 0; } }) ); const configurationProfileId = await unwrapOrCancel( await p.text({ message: "AppConfig configuration profile ID", validate: (value) => { if (!value || value.trim() === "") return "Configuration profile ID is required"; return void 0; } }) ); return { type: "appconfig", applicationId, environmentId, configurationProfileId }; } async function promptEnableMode(currentConfig, requestedMode) { const p = await clack(); let mode; if (requestedMode) { if (!FAILURE_MODE_ORDER.includes(requestedMode)) { throw new Error( `Unknown mode: ${requestedMode}. Valid modes: ${FAILURE_MODE_ORDER.join(", ")}` ); } mode = requestedMode; } else { mode = await unwrapOrCancel( await p.select({ message: "Which failure mode do you want to enable?", options: FAILURE_MODE_ORDER.map((m) => ({ value: m, label: m, hint: `${MODE_DESCRIPTIONS[m]}${currentConfig[m]?.enabled ? " (currently enabled)" : ""}` })) }) ); } const currentFlag = currentConfig[mode]; const percentageStr = await unwrapOrCancel( await p.text({ message: "Injection percentage (0 to 100)", defaultValue: String(currentFlag?.percentage ?? 100), placeholder: String(currentFlag?.percentage ?? 100), validate: (value) => { if (!value) return void 0; const n = Number(value); if (Number.isNaN(n) || !Number.isInteger(n) || n < 0 || n > 100) return "Must be an integer between 0 and 100"; return void 0; } }) ); const percentage = Number(percentageStr); const flag = { enabled: true, percentage }; await promptModeSpecificParams(mode, flag, currentFlag); const addMatch = await unwrapOrCancel( await p.confirm({ message: "Add event-based match conditions?", initialValue: false }) ); if (addMatch) { flag.match = await promptMatchConditions(); } return { mode, flag }; } async function promptModeSpecificParams(mode, flag, current) { const p = await clack(); switch (mode) { case "latency": { const minStr = await unwrapOrCancel( await p.text({ message: "Minimum latency (ms)", defaultValue: String(current?.min_latency ?? 100), placeholder: String(current?.min_latency ?? 100), validate: (v) => { if (!v) return void 0; const n = Number(v); if (Number.isNaN(n) || n < 0) return "Must be a non-negative number"; return void 0; } }) ); const maxStr = await unwrapOrCancel( await p.text({ message: "Maximum latency (ms)", defaultValue: String(current?.max_latency ?? 400), placeholder: String(current?.max_latency ?? 400), validate: (v) => { if (!v) return void 0; const n = Number(v); if (Number.isNaN(n) || n < 0) return "Must be a non-negative number"; return void 0; } }) ); flag.min_latency = Number(minStr); flag.max_latency = Number(maxStr); break; } case "timeout": { const bufferStr = await unwrapOrCancel( await p.text({ message: "Timeout buffer (ms before Lambda timeout)", defaultValue: String(current?.timeout_buffer_ms ?? 500), placeholder: String(current?.timeout_buffer_ms ?? 500), validate: (v) => { if (!v) return void 0; const n = Number(v); if (Number.isNaN(n) || n < 0) return "Must be a non-negative number"; return void 0; } }) ); flag.timeout_buffer_ms = Number(bufferStr); break; } case "diskspace": { const sizeStr = await unwrapOrCancel( await p.text({ message: "Disk space to fill (MB)", defaultValue: String(current?.disk_space ?? 100), placeholder: String(current?.disk_space ?? 100), validate: (v) => { if (!v) return void 0; const n = Number(v); if (Number.isNaN(n) || n <= 0 || n > 10240) return "Must be between 1 and 10240 (MB)"; return void 0; } }) ); flag.disk_space = Number(sizeStr); break; } case "denylist": { const patternsStr = await unwrapOrCancel( await p.text({ message: "Deny list patterns (comma-separated regex)", defaultValue: current?.deny_list?.join(", ") ?? "s3.*.amazonaws.com, dynamodb.*.amazonaws.com", placeholder: "s3.*.amazonaws.com, dynamodb.*.amazonaws.com" }) ); flag.deny_list = patternsStr.split(",").map((s) => s.trim()).filter(Boolean); break; } case "statuscode": { const codeStr = await unwrapOrCancel( await p.text({ message: "HTTP status code to return", defaultValue: String(current?.status_code ?? 404), placeholder: String(current?.status_code ?? 404), validate: (v) => { if (!v) return void 0; const n = Number(v); if (Number.isNaN(n) || n < 100 || n > 599) return "Must be an HTTP status code (100-599)"; return void 0; } }) ); flag.status_code = Number(codeStr); break; } case "exception": { const msg = await unwrapOrCancel( await p.text({ message: "Exception message", defaultValue: current?.exception_msg ?? "Injected exception", placeholder: "Injected exception" }) ); flag.exception_msg = msg; break; } case "corruption": { const body = await unwrapOrCancel( await p.text({ message: "Replacement response body (leave empty for default)", defaultValue: current?.body ?? "", placeholder: '{"error": "corrupted"}' }) ); if (body) { flag.body = body; } break; } } } async function promptMatchConditions() { const p = await clack(); const conditions = []; let addMore = true; while (addMore) { const path = await unwrapOrCancel( await p.text({ message: "Event path (dot-separated, e.g. requestContext.http.method)", validate: (v) => { if (!v || v.trim() === "") return "Path is required"; return void 0; } }) ); const operator = await unwrapOrCancel( await p.select({ message: "Match operator", options: [ { value: "eq", label: "eq", hint: "Exact string match" }, { value: "startsWith", label: "startsWith", hint: "Starts with prefix" }, { value: "regex", label: "regex", hint: "Regular expression match" }, { value: "exists", label: "exists", hint: "Path exists (any value)" } ] }) ); const condition = { path, operator }; if (operator !== "exists") { const value = await unwrapOrCancel( await p.text({ message: "Expected value", validate: (v) => { if (!v || v.trim() === "") return "Value is required"; return void 0; } }) ); condition.value = value; } conditions.push(condition); addMore = await unwrapOrCancel( await p.confirm({ message: "Add another match condition?", initialValue: false }) ); } return conditions; } async function promptDisableMode(currentConfig, requestedMode, disableAll) { if (disableAll) { const updated = { ...currentConfig }; for (const mode of FAILURE_MODE_ORDER) { if (updated[mode]) { updated[mode] = { ...updated[mode], enabled: false }; } } return updated; } if (requestedMode) { if (!FAILURE_MODE_ORDER.includes(requestedMode)) { throw new Error( `Unknown mode: ${requestedMode}. Valid modes: ${FAILURE_MODE_ORDER.join(", ")}` ); } const mode = requestedMode; const flag2 = currentConfig[mode]; if (!flag2) { return { ...currentConfig, [mode]: { enabled: false } }; } return { ...currentConfig, [mode]: { ...flag2, enabled: false } }; } const p = await clack(); const enabledModes = FAILURE_MODE_ORDER.filter((m) => currentConfig[m]?.enabled); if (enabledModes.length === 0) { throw new Error("No modes are currently enabled."); } const ALL = "__all__"; const choice = await unwrapOrCancel( await p.select({ message: "Which failure mode do you want to disable?", options: [ ...enabledModes.map((m) => ({ value: m, label: m, hint: MODE_DESCRIPTIONS[m] })), { value: ALL, label: "Disable all", hint: `All ${enabledModes.length} enabled modes` } ] }) ); if (choice === ALL) { const updated = { ...currentConfig }; for (const mode of enabledModes) { updated[mode] = { ...updated[mode], enabled: false }; } return updated; } const flag = currentConfig[choice]; return { ...currentConfig, [choice]: { ...flag, enabled: false } }; } // src/cli/store.ts async function resolveConfigSource(flags) { if (flags.param) { return { type: "ssm", parameterName: flags.param }; } if (flags.app && flags.env && flags.profile) { return { type: "appconfig", applicationId: flags.app, environmentId: flags.env, configurationProfileId: flags.profile }; } if (process.env.FAILURE_INJECTION_PARAM) { return { type: "ssm", parameterName: process.env.FAILURE_INJECTION_PARAM }; } if (process.env.FAILURE_APPCONFIG_APPLICATION && process.env.FAILURE_APPCONFIG_ENVIRONMENT && process.env.FAILURE_APPCONFIG_CONFIGURATION) { return { type: "appconfig", applicationId: process.env.FAILURE_APPCONFIG_APPLICATION, environmentId: process.env.FAILURE_APPCONFIG_ENVIRONMENT, configurationProfileId: process.env.FAILURE_APPCONFIG_CONFIGURATION }; } return promptConfigSource(); } async function resolveRegion(flagRegion) { if (flagRegion) { return flagRegion; } const envRegion = process.env.AWS_REGION ?? process.env.AWS_DEFAULT_REGION; if (envRegion) { return envRegion; } return promptRegion(); } async function readConfig(source, region) { if (source.type === "ssm") { return readFromSSM(source.parameterName, region); } return readFromAppConfig(source, region); } async function writeConfig(source, config, region) { const json = JSON.stringify(config, null, 2); if (source.type === "ssm") { await writeToSSM(source.parameterName, json, region); } else { await writeToAppConfig(source, json, region); } } async function readFromSSM(parameterName, region) { const client = new SSMClient2({ region }); try { const response = await client.send(new GetParameterCommand2({ Name: parameterName })); const rawJson = response.Parameter?.Value ?? "{}"; const parsed = JSON.parse(rawJson); return { config: parseFlags(parsed), rawJson }; } catch (err) { if (err instanceof Error && err.name === "ParameterNotFound") { return { config: {}, rawJson: "{}", notFound: true }; } throw err; } } async function readFromAppConfig(source, region) { const { AppConfigDataClient, StartConfigurationSessionCommand, GetLatestConfigurationCommand } = await import("./dist-es-EEVCNQFQ.js"); const client = new AppConfigDataClient({ region }); const session = await client.send( new StartConfigurationSessionCommand({ ApplicationIdentifier: source.applicationId, EnvironmentIdentifier: source.environmentId, ConfigurationProfileIdentifier: source.configurationProfileId }) ); const response = await client.send( new GetLatestConfigurationCommand({ ConfigurationToken: session.InitialConfigurationToken }) ); const rawJson = response.Configuration ? new TextDecoder().decode(response.Configuration) : "{}"; if (!rawJson || rawJson.trim() === "") { return { config: {}, rawJson: "{}" }; } const parsed = JSON.parse(rawJson); return { config: parseFlags(parsed), rawJson }; } async function writeToSSM(parameterName, json, region) { const client = new SSMClient2({ region }); await client.send( new PutParameterCommand({ Name: parameterName, Value: json, Type: "String", Overwrite: true }) ); } async function writeToAppConfig(source, json, region) { const { AppConfigClient, CreateHostedConfigurationVersionCommand, StartDeploymentCommand } = await import("./dist-es-5PSOFLXJ.js"); const client = new AppConfigClient({ region }); const versionResponse = await client.send( new CreateHostedConfigurationVersionCommand({ ApplicationId: source.applicationId, ConfigurationProfileId: source.configurationProfileId, Content: new TextEncoder().encode(json), ContentType: "application/json" }) ); await client.send( new StartDeploymentCommand({ ApplicationId: source.applicationId, EnvironmentId: source.environmentId, ConfigurationProfileId: source.configurationProfileId, ConfigurationVersion: String(versionResponse.VersionNumber), DeploymentStrategyId: "AppConfig.AllAtOnce" }) ); } function mergeFlag(config, mode, flag) { const updated = { ...config, [mode]: flag }; const errors = validateFlagValue(mode, flag); if (errors.length > 0) { throw new Error( `Validation failed: ${errors.map((e) => ` ${e.field}: ${e.message}`).join("\n")}` ); } return updated; } function sourceLabel(source) { if (source.type === "ssm") { return `SSM Parameter: ${source.parameterName}`; } return `AppConfig: app=${source.applicationId} env=${source.environmentId} profile=${source.configurationProfileId}`; } // src/cli/display.ts var _clack2 = null; async function clack2() { if (_clack2 === null) _clack2 = await import("./dist-SGXTLZKZ.js"); return _clack2; } function modeDetail(mode, flag) { const parts = [`${flag.percentage ?? 100}%`]; switch (mode) { case "latency": parts.push(`${flag.min_latency ?? 100}-${flag.max_latency ?? 400}ms`); break; case "timeout": parts.push(`buffer=${flag.timeout_buffer_ms ?? 0}ms`); break; case "diskspace": parts.push(`${flag.disk_space ?? 100}MB`); break; case "denylist": if (flag.deny_list?.length) { parts.push(`patterns=[${flag.deny_list.join(", ")}]`); } break; case "statuscode": parts.push(`code=${flag.status_code ?? 404}`); break; case "exception": parts.push(`msg="${flag.exception_msg ?? "Error"}"`); break; case "corruption": if (flag.body) { const preview = flag.body.length > 40 ? flag.body.slice(0, 40) + "..." : flag.body; parts.push(`body="${preview}"`); } break; } if (flag.match?.length) { parts.push(`match=[${flag.match.length} condition${flag.match.length > 1 ? "s" : ""}]`); } return parts.join(", "); } async function displayStatus(config, source, region) { const p = await clack2(); p.log.info(`Region: ${region}`); p.log.info(`Source: ${sourceLabel(source)}`); const lines = []; let enabledCount = 0; for (const mode of FAILURE_MODE_ORDER) { const flag = config[mode]; if (flag?.enabled) { enabledCount++; lines.push(` ${mode}: enabled (${modeDetail(mode, flag)})`); } else { lines.push(` ${mode}: disabled`); } } p.log.message(lines.join("\n")); p.log.info(`${enabledCount} of ${FAILURE_MODE_ORDER.length} modes enabled`); } async function displayConfigPreview(config) { const p = await clack2(); const json = JSON.stringify(config, null, 2); p.note(json, "Configuration preview"); } // src/cli/settings.ts import { readFile, writeFile, mkdir } from "fs/promises"; import { homedir } from "os"; import { join, dirname } from "path"; var SETTINGS_PATH = join(homedir(), ".failure-lambda.json"); async function loadSettings() { try { const raw = await readFile(SETTINGS_PATH, "utf-8"); const parsed = JSON.parse(raw); return { profiles: parsed.profiles ?? {} }; } catch { return { profiles: {} }; } } async function saveSettings(settings) { await mkdir(dirname(SETTINGS_PATH), { recursive: true }); await writeFile(SETTINGS_PATH, JSON.stringify(settings, null, 2) + "\n", "utf-8"); } // src/cli.ts var _clack3 = null; async function clack3() { if (_clack3 === null) _clack3 = await import("./dist-SGXTLZKZ.js"); return _clack3; } var HELP = ` failure-lambda - Manage failure injection configuration Usage: failure-lambda status Show current configuration failure-lambda status --json Output raw configuration as JSON failure-lambda enable [mode] Enable a failure mode failure-lambda disable [mode] Disable a failure mode failure-lambda disable --all Disable all failure modes Config source (pick one): --param <name> SSM Parameter Store parameter name --app <id> AppConfig application ID --env <id> AppConfig environment ID --profile <id> AppConfig configuration profile ID AWS region: --region <region> AWS region (e.g. eu-north-1) Falls back to AWS_REGION / AWS_DEFAULT_REGION env vars, or prompts interactively. Environment variables (used if flags not set): FAILURE_INJECTION_PARAM SSM parameter name FAILURE_APPCONFIG_APPLICATION AppConfig application ID FAILURE_APPCONFIG_ENVIRONMENT AppConfig environment ID FAILURE_APPCONFIG_CONFIGURATION AppConfig configuration profile ID If neither flags nor environment variables are set, you will be prompted interactively. Saved profiles are stored in ~/.failure-lambda.json. Options: --region <region> AWS region (overrides AWS_REGION env var) --json Output raw JSON (with status command) --all Disable all modes (with disable command) --help Show this help --version Show version Failure modes: latency Add random latency to invocations timeout Force Lambda timeout diskspace Fill /tmp with data denylist Block outgoing network requests by hostname pattern statuscode Return an HTTP error status code exception Throw an exception corruption Replace the response body `.trim(); async function main() { const { values, positionals } = parseArgs({ allowPositionals: true, options: { param: { type: "string" }, app: { type: "string" }, env: { type: "string" }, profile: { type: "string" }, region: { type: "string" }, json: { type: "boolean", default: false }, all: { type: "boolean", default: false }, help: { type: "boolean", default: false }, version: { type: "boolean", default: false } } }); if (values.help) { console.log(HELP); return; } if (values.version) { console.log(true ? "1.0.0" : "unknown"); return; } const commandArg = positionals[0]; const modeArg = positionals[1]; if (commandArg && !["status", "enable", "disable"].includes(commandArg)) { console.log(HELP); process.exitCode = 1; return; } const p = await clack3(); p.intro("failure-lambda"); const hasFlags = !!(values.param || values.app && values.env && values.profile || values.region); let region; let source; if (hasFlags) { region = await resolveRegion(values.region); source = await resolveConfigSource({ param: values.param, app: values.app, env: values.env, profile: values.profile }); } else { ({ region, source } = await selectOrCreateProfile()); } if (commandArg) { await runCommand(commandArg, source, region, { modeArg, disableAll: values.all, json: values.json }); p.outro("Done"); return; } let command = await promptCommand(); while (command !== "exit") { if (command === "switch") { ({ region, source } = await selectOrCreateProfile()); } else { await runCommand(command, source, region); } command = await promptCommand(); } p.outro("Done"); } async function selectOrCreateProfile() { const settings = await loadSettings(); const savedProfile = await promptProfile(settings); if (savedProfile) { return { region: savedProfile.region, source: savedProfile.source }; } const region = await resolveRegion(); const source = await resolveConfigSource({}); const result = await promptSaveProfile({ region, source }, settings); if (result.save && result.name) { settings.profiles[result.name] = { region, source }; await saveSettings(settings); } return { region, source }; } async function runCommand(command, source, region, opts = {}) { const p = await clack3(); const spin = p.spinner(); if (command === "status") { spin.start("Reading configuration..."); const { config, rawJson, notFound } = await readConfig(source, region); spin.stop("Configuration loaded"); if (opts.json) { console.log(rawJson); return; } if (notFound) { p.log.warn(`${sourceLabel(source)} does not exist yet.`); } await displayStatus(config, source, region); return; } if (command === "json") { spin.start("Reading configuration..."); const { config } = await readConfig(source, region); spin.stop("Configuration loaded"); await displayConfigPreview(config); return; } if (command === "enable") { spin.start("Reading current configuration..."); const { config: currentConfig, notFound } = await readConfig(source, region); spin.stop("Configuration loaded"); if (notFound) { const create = await promptConfirmCreate(sourceLabel(source)); if (!create) return; } const { mode, flag } = await promptEnableMode(currentConfig, opts.modeArg); const updatedConfig = mergeFlag(currentConfig, mode, flag); await displayConfigPreview(updatedConfig); spin.start(`Writing configuration to ${sourceLabel(source)}...`); await writeConfig(source, updatedConfig, region); spin.stop(`${mode} enabled`); return; } if (command === "disable") { spin.start("Reading current configuration..."); const { config: currentConfig, notFound } = await readConfig(source, region); spin.stop("Configuration loaded"); if (notFound) { p.log.warn(`${sourceLabel(source)} does not exist yet. Nothing to disable.`); return; } const updatedConfig = await promptDisableMode(currentConfig, opts.modeArg, opts.disableAll); await displayConfigPreview(updatedConfig); spin.start(`Writing configuration to ${sourceLabel(source)}...`); await writeConfig(source, updatedConfig, region); spin.stop(opts.disableAll ? "All modes disabled" : `${opts.modeArg ?? "Mode"} disabled`); return; } } main().catch(async (err) => { const p = await clack3(); p.cancel(err instanceof Error ? err.message : String(err)); process.exitCode = 1; }); //# sourceMappingURL=cli.js.map