feat(portal): add CloudFormation/APIGateway scanners, analytics endpoints, search caching
- CloudFormation scanner: discovers stacks and maps resources to services - API Gateway scanner: discovers REST/HTTP APIs and routes - Analytics API: ownership coverage, health scorecards, tech debt indicators - Redis prefix cache for Cmd+K search (60s TTL) - 005_analytics.sql migration for aggregation helpers
This commit is contained in:
203
products/04-lightweight-idp/src/discovery/apigateway-scanner.ts
Normal file
203
products/04-lightweight-idp/src/discovery/apigateway-scanner.ts
Normal file
@@ -0,0 +1,203 @@
|
||||
import pino from 'pino';
|
||||
import {
|
||||
APIGatewayClient,
|
||||
GetRestApisCommand,
|
||||
GetResourcesCommand,
|
||||
} from '@aws-sdk/client-api-gateway';
|
||||
import {
|
||||
ApiGatewayV2Client,
|
||||
GetApisCommand,
|
||||
GetRoutesCommand,
|
||||
} from '@aws-sdk/client-apigatewayv2';
|
||||
import type { DiscoveredService, ScanResult } from './aws-scanner.js';
|
||||
|
||||
const logger = pino({ name: 'discovery-apigateway' });
|
||||
|
||||
export class ApiGatewayDiscoveryScanner {
|
||||
private apigwClient: APIGatewayClient;
|
||||
private apigwV2Client: ApiGatewayV2Client;
|
||||
private region: string;
|
||||
|
||||
constructor(region: string, credentials?: any) {
|
||||
const config = { region, ...(credentials ? { credentials } : {}) };
|
||||
this.apigwClient = new APIGatewayClient(config);
|
||||
this.apigwV2Client = new ApiGatewayV2Client(config);
|
||||
this.region = region;
|
||||
}
|
||||
|
||||
async scan(account: string): Promise<ScanResult> {
|
||||
const services: DiscoveredService[] = [];
|
||||
const errors: string[] = [];
|
||||
|
||||
try {
|
||||
const restApis = await this.scanRestApis(account);
|
||||
services.push(...restApis);
|
||||
} catch (err) {
|
||||
errors.push(`REST API scan failed: ${(err as Error).message}`);
|
||||
logger.warn({ region: this.region, error: (err as Error).message }, 'REST API scan failed');
|
||||
}
|
||||
|
||||
try {
|
||||
const httpApis = await this.scanHttpApis(account);
|
||||
services.push(...httpApis);
|
||||
} catch (err) {
|
||||
errors.push(`HTTP API scan failed: ${(err as Error).message}`);
|
||||
logger.warn({ region: this.region, error: (err as Error).message }, 'HTTP API scan failed');
|
||||
}
|
||||
|
||||
const status = errors.length === 0
|
||||
? 'success'
|
||||
: services.length > 0
|
||||
? 'partial_failure'
|
||||
: 'failed';
|
||||
|
||||
return { status, discovered: services.length, errors, services };
|
||||
}
|
||||
|
||||
private async scanRestApis(account: string): Promise<DiscoveredService[]> {
|
||||
const results: DiscoveredService[] = [];
|
||||
let position: string | undefined;
|
||||
|
||||
do {
|
||||
const response = await this.apigwClient.send(new GetRestApisCommand({
|
||||
position,
|
||||
limit: 500,
|
||||
}));
|
||||
|
||||
for (const api of response.items ?? []) {
|
||||
const tags = (api.tags ?? {}) as Record<string, string>;
|
||||
const owner = tags['owner'] ?? tags['team'] ?? tags['Owner'] ?? tags['Team'];
|
||||
|
||||
let routes: string[] = [];
|
||||
try {
|
||||
routes = await this.getRestApiRoutes(api.id!);
|
||||
} catch (err) {
|
||||
logger.warn({ apiId: api.id, error: (err as Error).message }, 'Failed to list REST API resources');
|
||||
}
|
||||
|
||||
const arn = `arn:aws:apigateway:${this.region}::/restapis/${api.id}`;
|
||||
|
||||
results.push({
|
||||
name: api.name!,
|
||||
type: 'apigateway-rest',
|
||||
arn,
|
||||
region: this.region,
|
||||
account,
|
||||
tags,
|
||||
owner,
|
||||
ownerSource: owner ? 'aws-tag' : undefined,
|
||||
metadata: {
|
||||
apiId: api.id,
|
||||
description: api.description,
|
||||
createdDate: api.createdDate,
|
||||
endpointConfiguration: api.endpointConfiguration?.types,
|
||||
routeCount: routes.length,
|
||||
routes: routes.slice(0, 100),
|
||||
},
|
||||
discoveredAt: new Date(),
|
||||
});
|
||||
}
|
||||
|
||||
position = response.position;
|
||||
} while (position);
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
private async getRestApiRoutes(restApiId: string): Promise<string[]> {
|
||||
const routes: string[] = [];
|
||||
let position: string | undefined;
|
||||
|
||||
do {
|
||||
const response = await this.apigwClient.send(new GetResourcesCommand({
|
||||
restApiId,
|
||||
position,
|
||||
limit: 500,
|
||||
}));
|
||||
|
||||
for (const resource of response.items ?? []) {
|
||||
if (resource.resourceMethods) {
|
||||
for (const method of Object.keys(resource.resourceMethods)) {
|
||||
routes.push(`${method} ${resource.path}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
position = response.position;
|
||||
} while (position);
|
||||
|
||||
return routes;
|
||||
}
|
||||
|
||||
private async scanHttpApis(account: string): Promise<DiscoveredService[]> {
|
||||
const results: DiscoveredService[] = [];
|
||||
let nextToken: string | undefined;
|
||||
|
||||
do {
|
||||
const response = await this.apigwV2Client.send(new GetApisCommand({
|
||||
NextToken: nextToken,
|
||||
MaxResults: '100',
|
||||
}));
|
||||
|
||||
for (const api of response.Items ?? []) {
|
||||
const tags = (api.Tags ?? {}) as Record<string, string>;
|
||||
const owner = tags['owner'] ?? tags['team'] ?? tags['Owner'] ?? tags['Team'];
|
||||
|
||||
let routes: string[] = [];
|
||||
try {
|
||||
routes = await this.getHttpApiRoutes(api.ApiId!);
|
||||
} catch (err) {
|
||||
logger.warn({ apiId: api.ApiId, error: (err as Error).message }, 'Failed to list HTTP API routes');
|
||||
}
|
||||
|
||||
const arn = `arn:aws:apigateway:${this.region}::/apis/${api.ApiId}`;
|
||||
|
||||
results.push({
|
||||
name: api.Name!,
|
||||
type: `apigateway-${(api.ProtocolType ?? 'HTTP').toLowerCase()}`,
|
||||
arn,
|
||||
region: this.region,
|
||||
account,
|
||||
tags,
|
||||
owner,
|
||||
ownerSource: owner ? 'aws-tag' : undefined,
|
||||
metadata: {
|
||||
apiId: api.ApiId,
|
||||
description: api.Description,
|
||||
protocolType: api.ProtocolType,
|
||||
apiEndpoint: api.ApiEndpoint,
|
||||
createdDate: api.CreatedDate,
|
||||
routeCount: routes.length,
|
||||
routes: routes.slice(0, 100),
|
||||
},
|
||||
discoveredAt: new Date(),
|
||||
});
|
||||
}
|
||||
|
||||
nextToken = response.NextToken;
|
||||
} while (nextToken);
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
private async getHttpApiRoutes(apiId: string): Promise<string[]> {
|
||||
const routes: string[] = [];
|
||||
let nextToken: string | undefined;
|
||||
|
||||
do {
|
||||
const response = await this.apigwV2Client.send(new GetRoutesCommand({
|
||||
ApiId: apiId,
|
||||
NextToken: nextToken,
|
||||
MaxResults: '100',
|
||||
}));
|
||||
|
||||
for (const route of response.Items ?? []) {
|
||||
routes.push(route.RouteKey ?? 'unknown');
|
||||
}
|
||||
|
||||
nextToken = response.NextToken;
|
||||
} while (nextToken);
|
||||
|
||||
return routes;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user