From eec1df4c69f53217431fa46faa6013212b4f2170 Mon Sep 17 00:00:00 2001 From: Max Mayfield Date: Sun, 1 Mar 2026 03:19:56 +0000 Subject: [PATCH] Implement P4 AWS scanner: ECS/Lambda/RDS discovery with tag-based ownership MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- .../src/discovery/aws-scanner.ts | 198 +++++++++++++++--- 1 file changed, 165 insertions(+), 33 deletions(-) diff --git a/products/04-lightweight-idp/src/discovery/aws-scanner.ts b/products/04-lightweight-idp/src/discovery/aws-scanner.ts index fb83f75..6ab5d69 100644 --- a/products/04-lightweight-idp/src/discovery/aws-scanner.ts +++ b/products/04-lightweight-idp/src/discovery/aws-scanner.ts @@ -1,15 +1,32 @@ 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' }); export interface DiscoveredService { name: string; - type: string; // 'ecs-service', 'lambda', 'rds', 'ec2', etc. + type: string; arn: string; region: string; account: string; tags: Record; - owner?: string; // From tags or CODEOWNERS + owner?: string; ownerSource?: 'aws-tag' | 'codeowners' | 'config' | 'heuristic'; metadata: Record; discoveredAt: Date; @@ -22,51 +39,46 @@ export interface ScanResult { services: DiscoveredService[]; } -/** - * AWS Discovery Scanner. - * Scans ECS services, Lambda functions, RDS instances. - * Partial failures preserve existing catalog (BMad must-have). - */ export class AwsDiscoveryScanner { - private ecsClient: any; - private lambdaClient: any; - private rdsClient: any; + private ecsClient: ECSClient; + private lambdaClient: LambdaClient; + private rdsClient: RDSClient; + private region: string; - constructor(clients: { ecs: any; lambda: any; rds: any }) { - this.ecsClient = clients.ecs; - this.lambdaClient = clients.lambda; - this.rdsClient = clients.rds; + constructor(region: string, credentials?: any) { + const config = { region, ...(credentials ? { credentials } : {}) }; + this.ecsClient = new ECSClient(config); + this.lambdaClient = new LambdaClient(config); + this.rdsClient = new RDSClient(config); + this.region = region; } - async scan(region: string, account: string): Promise { + async scan(account: string): Promise { const services: DiscoveredService[] = []; const errors: string[] = []; - // Scan ECS try { - const ecsServices = await this.scanEcs(region, account); + const ecsServices = await this.scanEcs(account); services.push(...ecsServices); } catch (err) { 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 { - const lambdaFns = await this.scanLambda(region, account); + const lambdaFns = await this.scanLambda(account); services.push(...lambdaFns); } catch (err) { 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 { - const rdsInstances = await this.scanRds(region, account); + const rdsInstances = await this.scanRds(account); services.push(...rdsInstances); } catch (err) { 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 @@ -78,18 +90,138 @@ export class AwsDiscoveryScanner { return { status, discovered: services.length, errors, services }; } - private async scanEcs(region: string, account: string): Promise { - // TODO: List clusters → list services → describe services → extract tags - return []; + private async scanEcs(account: string): Promise { + const results: DiscoveredService[] = []; + + 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(), + }); + } + } + + return results; } - private async scanLambda(region: string, account: string): Promise { - // TODO: List functions → get tags → map to DiscoveredService - return []; + private async scanLambda(account: string): Promise { + const results: DiscoveredService[] = []; + let marker: string | undefined; + + do { + const response = await this.lambdaClient.send(new ListFunctionsCommand({ Marker: marker })); + + for (const fn of response.Functions ?? []) { + let tags: Record = {}; + try { + const tagResponse = await this.lambdaClient.send(new ListTagsCommand({ Resource: fn.FunctionArn })); + tags = (tagResponse.Tags ?? {}) as Record; + } 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(region: string, account: string): Promise { - // TODO: Describe DB instances → extract tags - return []; + private async scanRds(account: string): Promise { + const results: DiscoveredService[] = []; + + const response = await this.rdsClient.send(new DescribeDBInstancesCommand({})); + + for (const db of response.DBInstances ?? []) { + let tags: Record = {}; + 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> { + try { + const response = await this.ecsClient.send(new ListTagsForResourceCommand({ resourceArn: arn })); + return Object.fromEntries((response.tags ?? []).map(t => [t.key!, t.value!])); + } catch { + return {}; + } } }