UNPKG

failure-lambda

Version:

Failure injection for AWS Lambda - chaos engineering made simple

748 lines (733 loc) 24.5 kB
"use strict"; var __create = Object.create; var __defProp = Object.defineProperty; var __getOwnPropDesc = Object.getOwnPropertyDescriptor; var __getOwnPropNames = Object.getOwnPropertyNames; var __getProtoOf = Object.getPrototypeOf; var __hasOwnProp = Object.prototype.hasOwnProperty; var __export = (target, all) => { for (var name in all) __defProp(target, name, { get: all[name], enumerable: true }); }; var __copyProps = (to, from, except, desc) => { if (from && typeof from === "object" || typeof from === "function") { for (let key of __getOwnPropNames(from)) if (!__hasOwnProp.call(to, key) && key !== except) __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable }); } return to; }; var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps( // If the importer is in node compatibility mode or this is not an ESM // file that has been converted to a CommonJS file using a Babel- // compatible transform (i.e. "__esModule" has not been set), then set // "default" to the CommonJS "module.exports" for node compatibility. isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target, mod )); var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod); // src/middy.ts var middy_exports = {}; __export(middy_exports, { failureLambdaMiddleware: () => failureLambdaMiddleware }); module.exports = __toCommonJS(middy_exports); // src/config.ts var import_client_ssm = require("@aws-sdk/client-ssm"); // src/types.ts var FAILURE_MODE_ORDER = [ "latency", "timeout", "diskspace", "denylist", "statuscode", "exception", "corruption" ]; var DEFAULT_FLAGS_CONFIG = {}; // src/log.ts var SOURCE = "failure-lambda"; function log(data) { console.log(JSON.stringify({ source: SOURCE, level: "info", ...data })); } function warn(data) { console.warn(JSON.stringify({ source: SOURCE, level: "warn", ...data })); } function error(data) { console.error(JSON.stringify({ source: SOURCE, level: "error", ...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; } var DEFAULT_CACHE_TTL_SECONDS = 60; var configCache = null; var ssmClient = null; var hasLoggedSource = false; function getSSMClient() { if (ssmClient === null) { ssmClient = new import_client_ssm.SSMClient({}); } return ssmClient; } function isAppConfigSource() { return Boolean(process.env.FAILURE_APPCONFIG_CONFIGURATION); } function getCacheTtlMs() { const envValue = process.env.FAILURE_CACHE_TTL; if (envValue === void 0 || envValue === "") { if (isAppConfigSource()) { return 0; } return DEFAULT_CACHE_TTL_SECONDS * 1e3; } const parsed = Number(envValue); if (Number.isNaN(parsed) || parsed < 0) { warn({ action: "config", message: `invalid FAILURE_CACHE_TTL="${envValue}", using default ${DEFAULT_CACHE_TTL_SECONDS}s` }); return DEFAULT_CACHE_TTL_SECONDS * 1e3; } if (parsed > 0 && isAppConfigSource()) { warn({ action: "config", message: `FAILURE_CACHE_TTL=${parsed}s with AppConfig \u2014 the AppConfig extension already caches at its poll interval; library caching adds staleness` }); } return parsed * 1e3; } function isCacheValid() { if (configCache === null) { return false; } const ttlMs = getCacheTtlMs(); if (ttlMs === 0) { return false; } return Date.now() - configCache.fetchedAt < ttlMs; } 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; } function resolveFailures(config) { const failures = []; for (const mode of FAILURE_MODE_ORDER) { const flag = config[mode]; if (flag === void 0 || !flag.enabled) { continue; } failures.push({ mode, percentage: Math.max(0, Math.min(100, flag.percentage ?? 100)), flag }); } return failures; } async function fetchFromAppConfig() { const appConfigPort = process.env.AWS_APPCONFIG_EXTENSION_HTTP_PORT ?? "2772"; const application = process.env.FAILURE_APPCONFIG_APPLICATION; const environment = process.env.FAILURE_APPCONFIG_ENVIRONMENT; const configuration = process.env.FAILURE_APPCONFIG_CONFIGURATION; const url = `http://localhost:${appConfigPort}/applications/${application}/environments/${environment}/configurations/${configuration}`; const response = await fetch(url); if (!response.ok) { throw new Error( `AppConfig fetch failed: ${response.status} ${response.statusText}` ); } const json = await response.json(); return parseFlags(json); } async function fetchFromSSM() { const parameterName = process.env.FAILURE_INJECTION_PARAM; const client = getSSMClient(); const command = new import_client_ssm.GetParameterCommand({ Name: parameterName }); const response = await client.send(command); const rawValue = response.Parameter?.Value; if (rawValue === void 0) { throw new Error(`SSM parameter "${parameterName}" has no value`); } const json = JSON.parse(rawValue); return parseFlags(json); } async function getConfig() { if (isCacheValid() && configCache !== null) { return configCache.config; } try { let config; let source; if (process.env.FAILURE_APPCONFIG_CONFIGURATION) { config = await fetchFromAppConfig(); source = "appconfig"; } else if (process.env.FAILURE_INJECTION_PARAM) { config = await fetchFromSSM(); source = "ssm"; } else { return { ...DEFAULT_FLAGS_CONFIG }; } if (!hasLoggedSource) { const cacheTtlMs = getCacheTtlMs(); log({ action: "config", config_source: source, cache_ttl_seconds: cacheTtlMs / 1e3, enabled_flags: Object.keys(config).filter( (k) => config[k]?.enabled ) }); hasLoggedSource = true; } configCache = { config, fetchedAt: Date.now() }; return config; } catch (err) { error({ action: "config", message: "error fetching config", error: String(err) }); return { ...DEFAULT_FLAGS_CONFIG }; } } // src/failures/latency.ts async function injectLatency(flag) { const minLatency = flag.min_latency ?? 0; const maxLatency = flag.max_latency ?? 0; const latencyRange = Math.max(0, maxLatency - minLatency); const injectedLatency = Math.floor(minLatency + Math.random() * latencyRange); log({ mode: "latency", action: "inject", latency_ms: injectedLatency, min_latency: minLatency, max_latency: maxLatency }); await new Promise((resolve) => setTimeout(resolve, injectedLatency)); } // src/failures/exception.ts function injectException(flag) { const message = flag.exception_msg ?? "Injected exception"; log({ mode: "exception", action: "inject", exception_msg: message }); throw new Error(message); } // src/failures/statuscode.ts function injectStatusCode(flag) { const statusCode = flag.status_code ?? 500; log({ mode: "statuscode", action: "inject", status_code: statusCode }); return { statusCode, headers: { "Content-Type": "application/json" }, body: JSON.stringify({ message: `Injected status code ${statusCode}` }) }; } // src/failures/diskspace.ts var import_node_child_process = require("child_process"); var import_node_fs = require("fs"); var DISKSPACE_PREFIX = "diskspace-failure-"; function injectDiskSpace(flag) { const diskSpaceMB = flag.disk_space ?? 100; log({ mode: "diskspace", action: "inject", disk_space_mb: diskSpaceMB }); const result = (0, import_node_child_process.spawnSync)("dd", [ "if=/dev/zero", `of=/tmp/${DISKSPACE_PREFIX}${Date.now()}.tmp`, "count=1024", `bs=${diskSpaceMB * 1024}` ]); if (result.error) { error({ mode: "diskspace", action: "error", message: result.error.message }); } else if (result.status !== 0) { const stderr = result.stderr?.toString().trim(); error({ mode: "diskspace", action: "error", message: `dd exited with status ${result.status}`, stderr }); } } function clearDiskSpace() { try { const files = (0, import_node_fs.readdirSync)("/tmp").filter((f) => f.startsWith(DISKSPACE_PREFIX)); for (const file of files) { (0, import_node_fs.unlinkSync)(`/tmp/${file}`); } if (files.length > 0) { log({ mode: "diskspace", action: "clear", files_removed: files.length }); } } catch (e) { warn({ mode: "diskspace", action: "clear_error", message: e.message }); } } // src/failures/denylist.ts var import_node_dns = __toESM(require("dns"), 1); var originalLookup = import_node_dns.default.lookup; var isActive = false; var activePatterns = []; function clearDenylist() { if (isActive) { import_node_dns.default.lookup = originalLookup; isActive = false; activePatterns = []; } } function injectDenylist(flag) { const denylistPatterns = flag.deny_list ?? []; log({ mode: "denylist", action: "inject", patterns: denylistPatterns }); activePatterns = []; for (const pattern of denylistPatterns) { try { activePatterns.push(new RegExp(pattern)); } catch (e) { warn({ mode: "denylist", action: "error", message: `invalid regex "${pattern}"`, error: e.message }); } } if (!isActive) { import_node_dns.default.lookup = function blockedLookup(hostname, ...args) { const callback = args[args.length - 1]; const rest = args.slice(0, -1); if (activePatterns.some((regex) => regex.test(hostname))) { log({ mode: "denylist", action: "block", hostname }); const err = new Error( `getaddrinfo ENOTFOUND ${hostname}` ); err.code = "ENOTFOUND"; err.hostname = hostname; err.syscall = "getaddrinfo"; process.nextTick(() => callback(err)); return; } originalLookup.call(import_node_dns.default, hostname, ...rest, callback); }; isActive = true; } } // src/failures/timeout.ts async function injectTimeout(flag, context) { const bufferMs = flag.timeout_buffer_ms ?? 0; const remaining = context.getRemainingTimeInMillis(); const sleepMs = Math.max(0, remaining - bufferMs); log({ mode: "timeout", action: "inject", sleep_ms: sleepMs, buffer_ms: bufferMs, remaining_ms: remaining }); await new Promise((resolve) => setTimeout(resolve, sleepMs)); } // src/failures/corruption.ts function corruptResponse(flag, result) { if (flag.body !== void 0) { log({ mode: "corruption", action: "inject", method: "replace" }); if (typeof result === "object" && result !== null && "body" in result) { return { ...result, body: flag.body }; } warn({ mode: "corruption", message: "response has no body field; wrapping in { body }" }); return { body: flag.body }; } log({ mode: "corruption", action: "inject", method: "mangle" }); if (typeof result === "object" && result !== null && "body" in result) { const obj = result; if (typeof obj.body === "string") { return { ...obj, body: mangleString(obj.body) }; } } warn({ mode: "corruption", message: "response has no string body field to mangle; returning unchanged" }); return result; } function mangleString(input) { if (input.length === 0) return input; const truncatePoint = Math.floor(input.length * (0.3 + Math.random() * 0.5)); return input.slice(0, truncatePoint) + "\uFFFD".repeat(3); } // src/matching.ts var regexCache = /* @__PURE__ */ new Map(); function getCachedRegex(pattern) { let cached = regexCache.get(pattern); if (cached === void 0) { cached = new RegExp(pattern); regexCache.set(pattern, cached); } return cached; } function getNestedValue(obj, path) { let current = obj; for (const part of path.split(".")) { if (current === null || current === void 0 || typeof current !== "object") return void 0; current = current[part]; } return current; } function matchOperator(actual, operator, value) { switch (operator) { case "exists": return actual !== null && actual !== void 0; case "startsWith": if (actual === null || actual === void 0) return false; return String(actual).startsWith(value ?? ""); case "regex": if (actual === null || actual === void 0) return false; return getCachedRegex(value ?? "").test(String(actual)); case "eq": default: if (actual === null || actual === void 0) return false; return String(actual) === value; } } function matchesConditions(event, conditions) { return conditions.every((condition) => { const actual = getNestedValue(event, condition.path); const operator = condition.operator ?? "eq"; return matchOperator(actual, operator, condition.value); }); } // src/orchestration.ts async function runPreHandlerInjections(failures, event, context, dryRun = false) { if (!dryRun) { clearDenylist(); clearDiskSpace(); } for (const failure of failures) { if (failure.mode === "corruption") continue; if (failure.flag.match && !matchesConditions(event, failure.flag.match)) continue; const roll = Math.random() * 100; if (roll >= failure.percentage) continue; if (dryRun) { log({ mode: failure.mode, action: "dryrun", percentage: failure.percentage, roll }); continue; } switch (failure.mode) { case "latency": await injectLatency(failure.flag); break; case "timeout": await injectTimeout(failure.flag, context); break; case "diskspace": injectDiskSpace(failure.flag); break; case "denylist": injectDenylist(failure.flag); break; case "statuscode": return { shortCircuit: injectStatusCode(failure.flag) }; case "exception": injectException(failure.flag); } } return void 0; } function runPostHandlerInjections(failures, event, result, dryRun = false) { let current = result; for (const failure of failures) { if (failure.mode !== "corruption") continue; if (failure.flag.match && !matchesConditions(event, failure.flag.match)) continue; const roll = Math.random() * 100; if (roll >= failure.percentage) continue; if (dryRun) { log({ mode: failure.mode, action: "dryrun", percentage: failure.percentage, roll }); continue; } current = corruptResponse(failure.flag, current); } return current; } // src/middy.ts function failureLambdaMiddleware(options) { return { before: async (request) => { if (process.env.FAILURE_LAMBDA_DISABLED === "true") { return; } const configProvider = options?.configProvider ?? getConfig; const flagsConfig = await configProvider(); const failures = resolveFailures(flagsConfig); request.internal = { ...request.internal, failureLambdaFailures: failures }; const dryRun = options?.dryRun === true; const preResult = await runPreHandlerInjections( failures, request.event, request.context, dryRun ); if (preResult) { request.response = preResult.shortCircuit; return preResult.shortCircuit; } }, after: async (request) => { const failures = request.internal?.failureLambdaFailures ?? []; const dryRun = options?.dryRun === true; request.response = runPostHandlerInjections( failures, request.event, request.response, dryRun ); }, onError: async (request) => { error({ action: "error", message: request.error?.message ?? "unknown error" }); clearDenylist(); clearDiskSpace(); } }; } // Annotate the CommonJS export names for ESM import in node: 0 && (module.exports = { failureLambdaMiddleware }); if (module.exports.default) { Object.assign(module.exports.default, module.exports); module.exports = module.exports.default; } //# sourceMappingURL=middy.cjs.map