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:
@@ -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 {};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user