Implement P4 AWS scanner: ECS/Lambda/RDS discovery with tag-based ownership

- ECS: list clusters → list services → describe → extract tags, capture task def + counts
- Lambda: paginated list functions → list tags, capture runtime/memory/timeout
- RDS: describe instances → list tags, capture engine/class/storage/multi-AZ
- Owner resolution from aws tags (owner/team/Owner/Team)
- Partial failure handling preserved (per-service try/catch)
This commit is contained in:
2026-03-01 03:19:56 +00:00
parent 5ee869b9d8
commit eec1df4c69

View File

@@ -1,15 +1,32 @@
import pino from 'pino'; import pino from 'pino';
import {
ECSClient,
ListClustersCommand,
ListServicesCommand,
DescribeServicesCommand,
ListTagsForResourceCommand,
} from '@aws-sdk/client-ecs';
import {
LambdaClient,
ListFunctionsCommand,
ListTagsCommand,
} from '@aws-sdk/client-lambda';
import {
RDSClient,
DescribeDBInstancesCommand,
ListTagsForResourceCommand as RDSListTagsCommand,
} from '@aws-sdk/client-rds';
const logger = pino({ name: 'discovery-aws' }); const logger = pino({ name: 'discovery-aws' });
export interface DiscoveredService { export interface DiscoveredService {
name: string; name: string;
type: string; // 'ecs-service', 'lambda', 'rds', 'ec2', etc. type: string;
arn: string; arn: string;
region: string; region: string;
account: string; account: string;
tags: Record<string, string>; tags: Record<string, string>;
owner?: string; // From tags or CODEOWNERS owner?: string;
ownerSource?: 'aws-tag' | 'codeowners' | 'config' | 'heuristic'; ownerSource?: 'aws-tag' | 'codeowners' | 'config' | 'heuristic';
metadata: Record<string, any>; metadata: Record<string, any>;
discoveredAt: Date; discoveredAt: Date;
@@ -22,51 +39,46 @@ export interface ScanResult {
services: DiscoveredService[]; services: DiscoveredService[];
} }
/**
* AWS Discovery Scanner.
* Scans ECS services, Lambda functions, RDS instances.
* Partial failures preserve existing catalog (BMad must-have).
*/
export class AwsDiscoveryScanner { export class AwsDiscoveryScanner {
private ecsClient: any; private ecsClient: ECSClient;
private lambdaClient: any; private lambdaClient: LambdaClient;
private rdsClient: any; private rdsClient: RDSClient;
private region: string;
constructor(clients: { ecs: any; lambda: any; rds: any }) { constructor(region: string, credentials?: any) {
this.ecsClient = clients.ecs; const config = { region, ...(credentials ? { credentials } : {}) };
this.lambdaClient = clients.lambda; this.ecsClient = new ECSClient(config);
this.rdsClient = clients.rds; this.lambdaClient = new LambdaClient(config);
this.rdsClient = new RDSClient(config);
this.region = region;
} }
async scan(region: string, account: string): Promise<ScanResult> { async scan(account: string): Promise<ScanResult> {
const services: DiscoveredService[] = []; const services: DiscoveredService[] = [];
const errors: string[] = []; const errors: string[] = [];
// Scan ECS
try { try {
const ecsServices = await this.scanEcs(region, account); const ecsServices = await this.scanEcs(account);
services.push(...ecsServices); services.push(...ecsServices);
} catch (err) { } catch (err) {
errors.push(`ECS scan failed: ${(err as Error).message}`); errors.push(`ECS scan failed: ${(err as Error).message}`);
logger.warn({ region, error: (err as Error).message }, 'ECS scan failed'); logger.warn({ region: this.region, error: (err as Error).message }, 'ECS scan failed');
} }
// Scan Lambda
try { try {
const lambdaFns = await this.scanLambda(region, account); const lambdaFns = await this.scanLambda(account);
services.push(...lambdaFns); services.push(...lambdaFns);
} catch (err) { } catch (err) {
errors.push(`Lambda scan failed: ${(err as Error).message}`); errors.push(`Lambda scan failed: ${(err as Error).message}`);
logger.warn({ region, error: (err as Error).message }, 'Lambda scan failed'); logger.warn({ region: this.region, error: (err as Error).message }, 'Lambda scan failed');
} }
// Scan RDS
try { try {
const rdsInstances = await this.scanRds(region, account); const rdsInstances = await this.scanRds(account);
services.push(...rdsInstances); services.push(...rdsInstances);
} catch (err) { } catch (err) {
errors.push(`RDS scan failed: ${(err as Error).message}`); errors.push(`RDS scan failed: ${(err as Error).message}`);
logger.warn({ region, error: (err as Error).message }, 'RDS scan failed'); logger.warn({ region: this.region, error: (err as Error).message }, 'RDS scan failed');
} }
const status = errors.length === 0 const status = errors.length === 0
@@ -78,18 +90,138 @@ export class AwsDiscoveryScanner {
return { status, discovered: services.length, errors, services }; return { status, discovered: services.length, errors, services };
} }
private async scanEcs(region: string, account: string): Promise<DiscoveredService[]> { private async scanEcs(account: string): Promise<DiscoveredService[]> {
// TODO: List clusters → list services → describe services → extract tags const results: DiscoveredService[] = [];
return [];
const clusters = await this.ecsClient.send(new ListClustersCommand({}));
for (const clusterArn of clusters.clusterArns ?? []) {
const svcList = await this.ecsClient.send(new ListServicesCommand({ cluster: clusterArn }));
if (!svcList.serviceArns?.length) continue;
const described = await this.ecsClient.send(new DescribeServicesCommand({
cluster: clusterArn,
services: svcList.serviceArns,
}));
for (const svc of described.services ?? []) {
const tags = await this.getEcsTags(svc.serviceArn!);
const owner = tags['owner'] ?? tags['team'] ?? tags['Owner'] ?? tags['Team'];
results.push({
name: svc.serviceName!,
type: 'ecs-service',
arn: svc.serviceArn!,
region: this.region,
account,
tags,
owner,
ownerSource: owner ? 'aws-tag' : undefined,
metadata: {
cluster: clusterArn.split('/').pop(),
desiredCount: svc.desiredCount,
runningCount: svc.runningCount,
launchType: svc.launchType,
taskDefinition: svc.taskDefinition,
},
discoveredAt: new Date(),
});
}
} }
private async scanLambda(region: string, account: string): Promise<DiscoveredService[]> { return results;
// TODO: List functions → get tags → map to DiscoveredService
return [];
} }
private async scanRds(region: string, account: string): Promise<DiscoveredService[]> { private async scanLambda(account: string): Promise<DiscoveredService[]> {
// TODO: Describe DB instances → extract tags const results: DiscoveredService[] = [];
return []; let marker: string | undefined;
do {
const response = await this.lambdaClient.send(new ListFunctionsCommand({ Marker: marker }));
for (const fn of response.Functions ?? []) {
let tags: Record<string, string> = {};
try {
const tagResponse = await this.lambdaClient.send(new ListTagsCommand({ Resource: fn.FunctionArn }));
tags = (tagResponse.Tags ?? {}) as Record<string, string>;
} catch {
// Some functions may not allow tag listing
}
const owner = tags['owner'] ?? tags['team'] ?? tags['Owner'] ?? tags['Team'];
results.push({
name: fn.FunctionName!,
type: 'lambda',
arn: fn.FunctionArn!,
region: this.region,
account,
tags,
owner,
ownerSource: owner ? 'aws-tag' : undefined,
metadata: {
runtime: fn.Runtime,
memorySize: fn.MemorySize,
timeout: fn.Timeout,
lastModified: fn.LastModified,
handler: fn.Handler,
},
discoveredAt: new Date(),
});
}
marker = response.NextMarker;
} while (marker);
return results;
}
private async scanRds(account: string): Promise<DiscoveredService[]> {
const results: DiscoveredService[] = [];
const response = await this.rdsClient.send(new DescribeDBInstancesCommand({}));
for (const db of response.DBInstances ?? []) {
let tags: Record<string, string> = {};
try {
const tagResponse = await this.rdsClient.send(new RDSListTagsCommand({ ResourceName: db.DBInstanceArn }));
tags = Object.fromEntries((tagResponse.TagList ?? []).map(t => [t.Key!, t.Value!]));
} catch {
// Tag listing may fail
}
const owner = tags['owner'] ?? tags['team'] ?? tags['Owner'] ?? tags['Team'];
results.push({
name: db.DBInstanceIdentifier!,
type: `rds/${db.DBInstanceClass}`,
arn: db.DBInstanceArn!,
region: this.region,
account,
tags,
owner,
ownerSource: owner ? 'aws-tag' : undefined,
metadata: {
engine: db.Engine,
engineVersion: db.EngineVersion,
instanceClass: db.DBInstanceClass,
storageType: db.StorageType,
allocatedStorage: db.AllocatedStorage,
multiAZ: db.MultiAZ,
status: db.DBInstanceStatus,
},
discoveredAt: new Date(),
});
}
return results;
}
private async getEcsTags(arn: string): Promise<Record<string, string>> {
try {
const response = await this.ecsClient.send(new ListTagsForResourceCommand({ resourceArn: arn }));
return Object.fromEntries((response.tags ?? []).map(t => [t.key!, t.value!]));
} catch {
return {};
}
} }
} }