failure-lambda
Version:
Failure injection for AWS Lambda - chaos engineering made simple
1,156 lines (1,138 loc) • 37.4 kB
JavaScript
#!/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