Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions .claude/harness/baselines/handler-no-direct-sdk-import.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,48 @@
"filePath": "infrastructure/lib/problem-deploy/handlers/generic-scoring-handler/index.ts",
"line": 7,
"match": "@aws-sdk/lib-dynamodb"
},
{
"ruleId": "handler-no-direct-sdk-import",
"filePath": "infrastructure/lib/problem-deploy/handlers/disruption-executor-handler/index.ts",
"line": 15,
"match": "@aws-sdk/client-cloudformation"
},
{
"ruleId": "handler-no-direct-sdk-import",
"filePath": "infrastructure/lib/problem-deploy/handlers/disruption-executor-handler/index.ts",
"line": 16,
"match": "@aws-sdk/client-dynamodb"
},
{
"ruleId": "handler-no-direct-sdk-import",
"filePath": "infrastructure/lib/problem-deploy/handlers/disruption-executor-handler/index.ts",
"line": 17,
"match": "@aws-sdk/client-lambda"
},
{
"ruleId": "handler-no-direct-sdk-import",
"filePath": "infrastructure/lib/problem-deploy/handlers/disruption-executor-handler/index.ts",
"line": 18,
"match": "@aws-sdk/client-scheduler"
},
{
"ruleId": "handler-no-direct-sdk-import",
"filePath": "infrastructure/lib/problem-deploy/handlers/disruption-executor-handler/index.ts",
"line": 19,
"match": "@aws-sdk/client-ssm"
},
{
"ruleId": "handler-no-direct-sdk-import",
"filePath": "infrastructure/lib/problem-deploy/handlers/disruption-executor-handler/index.ts",
"line": 20,
"match": "@aws-sdk/client-sts"
},
{
"ruleId": "handler-no-direct-sdk-import",
"filePath": "infrastructure/lib/problem-deploy/handlers/disruption-executor-handler/index.ts",
"line": 21,
"match": "@aws-sdk/lib-dynamodb"
}
]
}
176 changes: 176 additions & 0 deletions infrastructure/lib/problem-deploy/disruption-executor-lambda.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
import * as path from "node:path";
import { ArnFormat, Duration, Stack } from "aws-cdk-lib";
import type { ITable } from "aws-cdk-lib/aws-dynamodb";
import { type IEventBus, Rule } from "aws-cdk-lib/aws-events";
import { LambdaFunction } from "aws-cdk-lib/aws-events-targets";
import * as iam from "aws-cdk-lib/aws-iam";
import { Architecture } from "aws-cdk-lib/aws-lambda";
import { NodejsFunction } from "aws-cdk-lib/aws-lambda-nodejs";
import { Construct } from "constructs";
import {
LAMBDA_NODEJS_BUNDLING_TARGET,
LAMBDA_NODEJS_RUNTIME,
LAMBDA_SOURCE_MAP_ENABLED,
} from "../utils/lambda-runtime.js";
import { buildExternalIdParameterArnPattern } from "./handlers/shared/external-id-store.js";

export interface DisruptionExecutorLambdaProps {
readonly environmentName: string;
/** disruption-fire が `*DisruptionFired` を publish する EventBus。 本 Lambda がその rule の target。 */
readonly eventBus: IEventBus;
/** team deployment 解決 (GSI1 Query) 用。 */
readonly deploymentsTable: ITable;
/** EXEC# 冪等行 (conditional Put) 用。 fire の REQUEST#/AUDIT# と同居。 */
readonly disruptionsTable: ITable;
/** `{ [problemId]: ProblemDisruptionEntry[] }` (action 込)。 build 時 literal 置換で env 4KB を回避。 */
readonly problemsDisruptions?: Readonly<Record<string, unknown>>;
}

/**
* [ADR-031 / Issue #1419] cross-account disruption executor Lambda (Phase B)。
*
* EventBridge `tenantcloud.disruptions` source の `*DisruptionFired` を拾い、 該当 team deployment へ
* AssumeRole して実障害を注入し、 ADR-029 INV-2 のため revert を aws-scheduler に予約する。 注入の破壊力は
* **競技者側 CompetitorDeployRole (AdministratorAccess)** に由来し、 本 Lambda 自身の IAM は最小:
* - sts:AssumeRole は `TenkaCloud-*` ロールのみ (= deploy worker / describe-stack と同 scope)
* - ssm:GetParameter + kms:Decrypt は tenant ExternalId の SecureString のみ (describe-stack と同パターン)
* - DDB は deployments の Query (GSI1) + disruptions の PutItem (EXEC# 冪等) のみ
* - scheduler:CreateSchedule + iam:PassRole は revert scheduler role のみ
* SDK の SendCommand / Invoke / UpdateStack 権限は **本 Lambda の role には無い** (= 注入は assumed
* credentials で行う)。 = blast radius を IAM で封じつつ、 破壊操作は competitor の同意済 role に閉じる。
*
* revert は scheduler が本 Lambda 自身を `mode:"revert"` payload で呼び戻す one-shot (= EXEC# 冪等 name)。
*/
export class DisruptionExecutorLambda extends Construct {
public readonly fn: NodejsFunction;
public readonly schedulerRole: iam.Role;

constructor(scope: Construct, id: string, props: DisruptionExecutorLambdaProps) {
super(scope, id);
const stack = Stack.of(this);

// self-invoke (scheduler → executor) の ARN を循環なしで得るため functionName を固定し、 ARN を構築する。
const functionName = `${stack.stackName}-disruption-executor`.slice(0, 64);
const executorArn = stack.formatArn({
service: "lambda",
resource: "function",
resourceName: functionName,
arnFormat: ArnFormat.COLON_RESOURCE_NAME,
});

// scheduler が executor を起動するために assume する role (= revert 予約の Target.RoleArn)。
this.schedulerRole = new iam.Role(this, "RevertSchedulerRole", {
assumedBy: new iam.ServicePrincipal("scheduler.amazonaws.com"),
inlinePolicies: {
InvokeExecutor: new iam.PolicyDocument({
statements: [
new iam.PolicyStatement({
actions: ["lambda:InvokeFunction"],
resources: [executorArn],
}),
],
}),
},
});

this.fn = new NodejsFunction(this, "Function", {
functionName,
runtime: LAMBDA_NODEJS_RUNTIME,
architecture: Architecture.ARM_64,
entry: path.resolve(import.meta.dirname, "handlers/disruption-executor-handler/index.ts"),
handler: "handler",
timeout: Duration.seconds(60),
memorySize: 512,
environment: {
DEPLOYMENTS_TABLE_NAME: props.deploymentsTable.tableName,
DISRUPTIONS_TABLE_NAME: props.disruptionsTable.tableName,
REVERT_SCHEDULER_ROLE_ARN: this.schedulerRole.roleArn,
EXECUTOR_FUNCTION_ARN: executorArn,
NODE_OPTIONS: "--enable-source-maps",
},
bundling: {
minify: true,
target: LAMBDA_NODEJS_BUNDLING_TARGET,
sourceMap: LAMBDA_SOURCE_MAP_ENABLED,
externalModules: [],
// disruptions catalog (action 込) を build 時 literal 置換 (env 4KB 回避、 fire と同 catalog)。
define: {
"process.env.BATTLE_PROBLEMS_DISRUPTIONS": JSON.stringify(
JSON.stringify(props.problemsDisruptions ?? {}),
),
},
},
});

// --- 最小 IAM ---
const ssmArn = buildExternalIdParameterArnPattern(
stack.region,
stack.account,
props.environmentName,
);
this.fn.addToRolePolicy(
new iam.PolicyStatement({
effect: iam.Effect.ALLOW,
actions: ["ssm:GetParameter"],
resources: [ssmArn],
}),
);
this.fn.addToRolePolicy(
new iam.PolicyStatement({
effect: iam.Effect.ALLOW,
actions: ["kms:Decrypt"],
resources: ["*"],
conditions: { StringLike: { "kms:EncryptionContext:PARAMETER_ARN": ssmArn } },
}),
);
this.fn.addToRolePolicy(
new iam.PolicyStatement({
effect: iam.Effect.ALLOW,
actions: ["sts:AssumeRole"],
resources: ["arn:aws:iam::*:role/TenkaCloud-*"],
}),
);
// deployments: team deployment 解決は GSI1 Query のみ。
this.fn.addToRolePolicy(
new iam.PolicyStatement({
effect: iam.Effect.ALLOW,
actions: ["dynamodb:Query"],
resources: [
props.deploymentsTable.tableArn,
`${props.deploymentsTable.tableArn}/index/GSI1`,
],
}),
);
// disruptions: EXEC# 冪等 claim は conditional PutItem のみ。
this.fn.addToRolePolicy(
new iam.PolicyStatement({
effect: iam.Effect.ALLOW,
actions: ["dynamodb:PutItem"],
resources: [props.disruptionsTable.tableArn],
}),
);
// revert 予約 (scheduler) + その実行 role を渡す PassRole。
this.fn.addToRolePolicy(
new iam.PolicyStatement({
effect: iam.Effect.ALLOW,
actions: ["scheduler:CreateSchedule"],
resources: ["*"],
}),
);
this.fn.addToRolePolicy(
new iam.PolicyStatement({
effect: iam.Effect.ALLOW,
actions: ["iam:PassRole"],
resources: [this.schedulerRole.roleArn],
conditions: { StringEquals: { "iam:PassedToService": "scheduler.amazonaws.com" } },
}),
);

// `*DisruptionFired` (= disruption-fire の publish) を拾って executor を起動する。
new Rule(this, "FiredRule", {
eventBus: props.eventBus,
eventPattern: { source: ["tenkacloud.disruptions"] },
targets: [new LambdaFunction(this.fn)],
});
}
}
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { CloudFormationClient, DescribeStacksCommand } from "@aws-sdk/client-cloudformation";
import { GetParameterCommand, SSMClient } from "@aws-sdk/client-ssm";
import type { Credentials } from "@aws-sdk/client-sts";
import { AssumeRoleCommand, STSClient } from "@aws-sdk/client-sts";
import { SSMClient } from "@aws-sdk/client-ssm";
import { type Credentials, STSClient } from "@aws-sdk/client-sts";
import { assumeCompetitorRole } from "../shared/assume-competitor-role.js";
import { errorDeployTrace, logDeployTrace } from "../shared/trace-log.js";

export interface DescribeStackStateMachineInput {
Expand Down Expand Up @@ -32,147 +32,6 @@ function requireString(value: unknown, field: string): string {
return value;
}

function assertCompleteCredentials(credentials: Credentials | undefined): Credentials {
if (!credentials?.AccessKeyId || !credentials.SecretAccessKey || !credentials.SessionToken) {
throw new Error("AssumeRole returned incomplete credentials");
}
return credentials;
}

/**
* Issue #1245 + #856: rotation race の AssumeRole 失敗のうち、 ExternalId mismatch に起因する
* 4xx だけを 1 generation 前で retry する。 Network / Throttling / 5xx 系は retry せず即 fail。
*
* `verify.ts` 側の `shouldRetryWithPreviousVersion` と同じ error name 集合を共有し、
* blanket-catch (= 全 error で previous version を試す) のような band-aid を避ける。
*/
const ASSUME_ROLE_FALLBACK_ERROR_NAMES: ReadonlySet<string> = new Set([
"AccessDenied",
"AccessDeniedException",
"Forbidden",
]);

function shouldRetryWithPreviousVersion(err: unknown): boolean {
const name = err instanceof Error ? err.name : "";
return ASSUME_ROLE_FALLBACK_ERROR_NAMES.has(name);
}

async function assumeCompetitorRole(
deps: DescribeStackDeps,
params: {
readonly region: string;
readonly jobId: string;
readonly competitorRoleArn?: string;
readonly externalIdParameterName?: string;
},
): Promise<Credentials | undefined> {
const hasRole =
typeof params.competitorRoleArn === "string" && params.competitorRoleArn.length > 0;
const hasExternalId =
typeof params.externalIdParameterName === "string" && params.externalIdParameterName.length > 0;
if (!hasRole && !hasExternalId) return undefined;
if (!hasRole || !hasExternalId) {
throw new Error("competitorRoleArn and externalIdParameterName must be provided together");
}
// 上の 2 guard で competitorRoleArn / externalIdParameterName が string であることは確定。
const competitorRoleArn = params.competitorRoleArn as string;
const externalIdParameterName = params.externalIdParameterName as string;

const externalIdOut = await deps.ssm.send(
new GetParameterCommand({
Name: externalIdParameterName,
WithDecryption: true,
}),
);
const externalId = externalIdOut.Parameter?.Value;
if (!externalId) {
throw new Error(`ExternalId not found in SSM SecureString: ${externalIdParameterName}`);
}

try {
return await assumeRoleWithExternalId(deps, competitorRoleArn, params.jobId, externalId);
} catch (currentErr) {
return await retryWithPreviousExternalId(deps, {
region: params.region,
jobId: params.jobId,
competitorRoleArn,
externalIdParameterName,
currentVersion: Number(externalIdOut.Parameter?.Version ?? 0),
currentErr,
});
}
}

/**
* Issue #1245: rotation race の retry path を 1 関数に切り出す。
*
* 旧 implementation の問題点:
* - 全 error class で blanket fallback (= Throttling / Network 系も前 version で retry していた)
* - 成功時の log が `console.warn` の自由 string であり、 metrics filter が当てづらく silent
*
* 修正後:
* - `shouldRetryWithPreviousVersion` で AccessDenied 系 (= ExternalId mismatch) に絞る
* - 1 generation 前 SSM version が無ければ original error を rethrow (= silent skip しない)
* - 成功時は `errorDeployTrace` で `deploy.describe-stack.assume-role.grace-fallback` を発火し、
* operator alarm に pick up させる (= grace 多発 = rotation pipeline のバグ可視化)
* - retry でも ExternalId は必ず渡される (= 「ExternalId 無し AssumeRole」は禁止)
*/
async function retryWithPreviousExternalId(
deps: DescribeStackDeps,
args: {
readonly region: string;
readonly jobId: string;
readonly competitorRoleArn: string;
readonly externalIdParameterName: string;
readonly currentVersion: number;
readonly currentErr: unknown;
},
): Promise<Credentials> {
const { currentErr } = args;
if (!shouldRetryWithPreviousVersion(currentErr)) throw currentErr;
const previousVersion = args.currentVersion - 1;
if (previousVersion <= 0) throw currentErr;
const previousExternalIdOut = await deps.ssm.send(
new GetParameterCommand({
Name: `${args.externalIdParameterName}:${previousVersion}`,
WithDecryption: true,
}),
);
const previousExternalId = previousExternalIdOut.Parameter?.Value;
if (!previousExternalId) throw currentErr;
const credentials = await assumeRoleWithExternalId(
deps,
args.competitorRoleArn,
args.jobId,
previousExternalId,
);
errorDeployTrace("deploy.describe-stack.assume-role.grace-fallback", {
jobId: args.jobId,
correlationId: args.jobId,
region: args.region,
externalIdVersion: previousVersion,
reason: currentErr instanceof Error ? currentErr.name : "Unknown",
});
return credentials;
}

async function assumeRoleWithExternalId(
deps: DescribeStackDeps,
roleArn: string,
jobId: string,
externalId: string,
): Promise<Credentials> {
const assumeOut = await deps.sts.send(
new AssumeRoleCommand({
RoleArn: roleArn,
RoleSessionName: `tenkacloud-describe-stack-${jobId.slice(0, 24)}`,
ExternalId: externalId,
DurationSeconds: 900,
}),
);
return assertCompleteCredentials(assumeOut.Credentials);
}

/**
* Issue (regression 調査): Step Functions 経由で渡される `input.detail.jobId`
* が undefined で fail するケースが 1 回観測された (= ジョブ ID は DDB row に
Expand Down Expand Up @@ -230,6 +89,8 @@ export async function describeStackForDeployment(
jobId,
competitorRoleArn: detail.competitorRoleArn,
externalIdParameterName: detail.externalIdParameterName,
sessionNamePrefix: "tenkacloud-describe-stack-",
graceFallbackTraceEvent: "deploy.describe-stack.assume-role.grace-fallback",
});
const cfn = deps.cfnClient({ region, credentials });
const out = await cfn.send(new DescribeStacksCommand({ StackName: stackName }));
Expand Down
Loading
Loading