diff --git a/.github/workflows/platform-iac-ci.yml b/.github/workflows/platform-iac-ci.yml index 2f45c34..aa0a8be 100644 --- a/.github/workflows/platform-iac-ci.yml +++ b/.github/workflows/platform-iac-ci.yml @@ -5,6 +5,8 @@ on: branches: [ main ] paths: - 'platform/**' + - 'packages/**' + - 'applications/examples/**' - 'lib/**' - 'bin/**' - 'test/**' @@ -51,8 +53,19 @@ jobs: output_file_path: console,results.sarif quiet: true - - name: Upload Checkov SARIF report + - name: Check for Checkov SARIF report + id: checkov-sarif if: always() + run: | + if [ -f results.sarif ]; then + echo "exists=true" >> "$GITHUB_OUTPUT" + else + echo "exists=false" >> "$GITHUB_OUTPUT" + fi + + - name: Upload Checkov SARIF report + if: always() && steps.checkov-sarif.outputs.exists == 'true' + continue-on-error: true uses: github/codeql-action/upload-sarif@v4 with: sarif_file: results.sarif diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..3d2ed5e --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,34 @@ +# Contributing + +## Platform API compatibility + +The platform construct APIs are treated as internal product contracts. + +- Additive optional props are minor-compatible changes. +- Changing defaults that affect deployed infrastructure requires a migration note. +- Removing props, changing required props, or replacing resources requires a deprecation cycle. + +## Deprecation policy + +Deprecated construct props and behavior remain available for at least one release train before removal. + +Every deprecation must include: + +- replacement guidance, +- migration steps, +- target removal release, +- test coverage for both old and new behavior during the deprecation window. + +## Pull request expectations + +Platform changes must include: + +- `npm run build` +- `npm test` +- `npm run synth` +- focused tests for new constructs, policies, or environment behavior +- documentation updates when the consumer contract changes + +## Ownership + +The platform team owns reusable constructs, configuration contracts, policy packs, and CI gates. Application teams own service-specific code and configuration that consumes those platform APIs. diff --git a/Makefile b/Makefile index f3d5e11..5cf55be 100644 --- a/Makefile +++ b/Makefile @@ -9,7 +9,7 @@ help: @echo "make build # Build TypeScript" @echo "make test # Run tests" @echo "make synth # CDK synth" - @echo "make platform-check # Build + synth + lint placeholder" + @echo "make platform-check # Build + test + synth" @echo "make platform-plan ENV=dev # Plan platform changes" @echo "make platform-apply ENV=dev # Apply platform changes" @echo "make app-bootstrap SERVICE=name # Bootstrap app from template" @@ -26,8 +26,8 @@ test: synth: npx cdk synth -platform-check: build synth - @echo "[platform-check] add checkov/tfsec/cdk-nag in CI" +platform-check: build test synth + @echo "[platform-check] build, tests, and synth completed" platform-plan: @echo "[platform-plan] ENV=$(ENV)" diff --git a/README.md b/README.md index 9f25b5a..ab75eea 100644 --- a/README.md +++ b/README.md @@ -121,6 +121,7 @@ graph TB │ ├── services/ # Platform services (Argo CD, Backstage, monitoring) │ └── environments/ # Per-environment composition (dev/stage/prod) ├── applications/ # Developer/App team owned +│ ├── examples/orders-service/ # Canonical CDK consumer of platform constructs │ ├── gitops/ # Kubernetes manifests + Kustomize overlays │ │ ├── base/ # Reference: Namespace, Deployment, Service │ │ └── overlays/ (dev/stage/prod) @@ -132,8 +133,10 @@ graph TB │ ├── template.yaml # Backstage scaffolder v1beta3 │ └── scaffold/ # Scaffold files (CI, observability, policy) ├── templates/service-catalog/ # Additional Backstage template (multi-language) +├── packages/platform-constructs/ # Reusable golden-path CDK constructs +│ └── src/api-lambda-dynamo-service/ # API Gateway + Lambda + DynamoDB construct ├── lib/ # CDK application source -│ ├── cdk-app-stack.ts # Main stack — all AWS resources +│ ├── cdk-app-stack.ts # Main stack composing platform constructs │ ├── function.ts # Lambda handler (Node.js 18) │ ├── platform-config.ts # Typed env config with validation │ └── security-guardrails.ts # ALB-WAF CDK Aspect validation @@ -153,6 +156,7 @@ graph TB │ ├── platform-product-operating-model.md │ ├── platform-engineering-consulting-profile.md │ ├── observability-as-a-service.md +│ ├── onboarding/first-service.md │ └── oaas-implementation-flow.md ├── catalog-info.yaml # Backstage entity registration ├── Makefile # DX: platform-check, app-deploy, policy-test, etc. @@ -317,7 +321,10 @@ kubeconform validation → Conftest OPA policy checks | `docs/platform-engineering-consulting-profile.md` | Portfolio framing: strategy → architecture → implementation → adoption | | `docs/observability-as-a-service.md` | OaaS maturity assessment + baseline implementation | | `docs/oaas-implementation-flow.md` | End-to-end OaaS flow with ownership model and definition of done | +| `docs/onboarding/first-service.md` | 30-minute first-service onboarding path using the golden-path construct | | `docs/code-review-resolution.md` | Audit trail of how review feedback was addressed | +| `packages/platform-constructs/README.md` | Reusable CDK construct contract and consumer example | +| `CONTRIBUTING.md` | Compatibility, deprecation, ownership, and PR expectations | | `PLATFORM_PRODUCT_SETUP.md` | Full platform transformation guide (905 lines) | | `applications/policy/README.md` | OPA policy bundle documentation | | `terraform/README.md` | HashiCorp Vault setup guide | diff --git a/applications/examples/orders-service/infra/orders-service-stack.ts b/applications/examples/orders-service/infra/orders-service-stack.ts new file mode 100644 index 0000000..6127750 --- /dev/null +++ b/applications/examples/orders-service/infra/orders-service-stack.ts @@ -0,0 +1,35 @@ +import * as cdk from 'aws-cdk-lib'; +import { Construct } from 'constructs'; +import { ApiLambdaDynamoService } from '../../../../packages/platform-constructs/src'; +import { PlatformConfig } from '../../../../lib/platform-config'; + +export interface OrdersServiceStackProps extends cdk.StackProps { + readonly platformConfig: PlatformConfig; +} + +export class OrdersServiceStack extends cdk.Stack { + public readonly service: ApiLambdaDynamoService; + + constructor(scope: Construct, id: string, props: OrdersServiceStackProps) { + super(scope, id, props); + + this.service = new ApiLambdaDynamoService(this, 'OrdersApiService', { + serviceName: 'orders-api', + stageName: props.platformConfig.environment, + catalogEntityRef: 'component:default/orders-service', + recommendedPathTemplateName: 'recommended-path-service', + recommendedPathTemplatePath: 'backstage/templates/recommended-path-service/template.yaml', + handlerEntry: `${__dirname}/../../../../lib/function.ts`, + environment: { + SERVICE_NAME: 'orders-api', + }, + }); + + cdk.Tags.of(this).add('environment', props.platformConfig.environment); + cdk.Tags.of(this).add('project', props.platformConfig.project); + cdk.Tags.of(this).add('owner', props.platformConfig.owner); + cdk.Tags.of(this).add('cost-center', props.platformConfig.costCenter); + cdk.Tags.of(this).add('data-classification', props.platformConfig.dataClassification); + cdk.Tags.of(this).add('finops-managed', 'true'); + } +} diff --git a/docs/improvement-form/.gitkeep b/docs/improvement-form/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/docs/onboarding/first-service.md b/docs/onboarding/first-service.md new file mode 100644 index 0000000..a60c73e --- /dev/null +++ b/docs/onboarding/first-service.md @@ -0,0 +1,61 @@ +# First Service Onboarding + +This tutorial provisions a service through the platform golden path in less than 30 minutes. + +## Prerequisites + +- Node.js 20 +- AWS credentials for the target sandbox account +- CDK bootstrap completed for the target account and region + +## 1. Start from the canonical consumer + +Use `applications/examples/orders-service/infra/orders-service-stack.ts` as the reference implementation. It consumes `ApiLambdaDynamoService`, which provides: + +- IAM-authenticated API Gateway routes +- VPC-isolated Lambda with tracing, reserved concurrency, encrypted environment variables, and a retry queue +- KMS-encrypted DynamoDB with point-in-time recovery and the standard pagination index +- API and application log groups with retention defaults + +## 2. Configure the environment + +Set the platform environment explicitly: + +```bash +export PLATFORM_ENV=dev +``` + +Allowed values are `dev`, `stage`, and `prod`. + +## 3. Validate locally + +Run the same quality gates used in CI: + +```bash +npm run build +npm test +npm run synth +``` + +## 4. Register ownership + +Set the service catalog reference in the construct props: + +```ts +catalogEntityRef: 'component:default/orders-service' +``` + +The value must match the Backstage entity for the service. + +## 5. Promote through environments + +Create one stack instance per environment and keep promotion artifact-based: + +1. Merge to `main` after build, test, synth, and policy checks pass. +2. Deploy to `dev`. +3. Promote the same reviewed change to `stage`. +4. Promote to `prod` after smoke tests and approval. + +## Done + +The service is onboarded when it has a catalog entity, passes CI quality gates, emits logs and traces by default, and uses the platform construct instead of hand-rolled API/Lambda/DynamoDB resources. diff --git a/docs/platform-product-progress.md b/docs/platform-product-progress.md index 754f62c..d78119d 100644 --- a/docs/platform-product-progress.md +++ b/docs/platform-product-progress.md @@ -6,11 +6,11 @@ _Last updated: 2026-04-01_ | Workstream | Status | Progress | Notes | |---|---|---:|---| -| Repository product model (platform vs applications) | ✅ Complete | 100% | Baseline folder model and docs are in place. | +| Repository product model (platform vs applications) | ✅ Complete | 100% | Baseline folder model, platform construct package, and canonical consumer example are in place. | | Golden-path scaffolding (Backstage template) | ✅ Complete | 100% | Template + runnable repo structure added and parameterized org support enabled. | | Platform IaC CI guardrails | ✅ Complete | 100% | Build/synth/checkov workflow configured. | | App GitOps guardrails | ✅ Complete | 100% | kubeconform validation enabled and fail-fast behavior enforced. | -| Secure-by-default CDK sample hardening | ✅ Complete | 100% | KMS, VPC, Lambda retry queue, IAM auth, caching, encrypted logs implemented. | +| Secure-by-default CDK sample hardening | ✅ Complete | 100% | KMS, VPC, Lambda retry queue, IAM auth, caching, encrypted logs implemented through the reusable API/Lambda/Dynamo construct. | | Environment overlays (dev/stage/prod) | 🟡 In Progress | 40% | Structure exists; env-specific manifests and policy sets pending. | | Policy-as-code enforcement (OPA/Kyverno) | 🟡 In Progress | 60% | Conftest policy bundle and CI enforcement added for deployment security/image/resource guardrails. | | Observability productization | 🟡 In Progress | 60% | CloudWatch dashboard, alerts, and structured logging baseline implemented; Prometheus/Grafana/Loki/OTel deployments pending. | @@ -25,6 +25,7 @@ _Last updated: 2026-04-01_ - Developer command interface established through `Makefile` targets. - Backstage self-service template now executable from a real scaffold repo structure. - Sample GitOps base manifests added for validation and onboarding reference. +- First golden-path CDK construct added under `packages/platform-constructs` with `applications/examples/orders-service` as a canonical consumer. ## Current quarter priorities @@ -41,6 +42,7 @@ _Last updated: 2026-04-01_ - Encrypted SNS-backed alarm fan-out for Lambda and API failures/latency. - Structured Lambda JSON logging and correlation-ID propagation. - See `docs/observability-as-a-service.md` for assessment details and rollout recommendations. +- Productized the API/Lambda/DynamoDB baseline as `ApiLambdaDynamoService`, added a first-service onboarding guide, and defined contribution/deprecation expectations in `CONTRIBUTING.md`. ## Definition of done for next milestone diff --git a/lib/cdk-app-stack.ts b/lib/cdk-app-stack.ts index 1d3a759..4443b79 100644 --- a/lib/cdk-app-stack.ts +++ b/lib/cdk-app-stack.ts @@ -1,20 +1,11 @@ -import * as path from 'path'; import * as cdk from 'aws-cdk-lib'; -import { RemovalPolicy } from 'aws-cdk-lib'; -import * as apigateway from 'aws-cdk-lib/aws-apigateway'; import * as budgets from 'aws-cdk-lib/aws-budgets'; import * as ce from 'aws-cdk-lib/aws-ce'; import * as cloudwatch from 'aws-cdk-lib/aws-cloudwatch'; import * as cwActions from 'aws-cdk-lib/aws-cloudwatch-actions'; -import * as dynamodb from 'aws-cdk-lib/aws-dynamodb'; -import * as ec2 from 'aws-cdk-lib/aws-ec2'; -import * as kms from 'aws-cdk-lib/aws-kms'; -import * as lambda from 'aws-cdk-lib/aws-lambda'; -import { NodejsFunction } from 'aws-cdk-lib/aws-lambda-nodejs'; -import * as logs from 'aws-cdk-lib/aws-logs'; import * as sns from 'aws-cdk-lib/aws-sns'; -import * as sqs from 'aws-cdk-lib/aws-sqs'; import { Construct } from 'constructs'; +import { ApiLambdaDynamoService } from '../packages/platform-constructs/src'; import { PlatformConfig } from './platform-config'; import { enforceAlbWafAssociations } from './security-guardrails'; @@ -38,158 +29,16 @@ export class CdkAppStack extends cdk.Stack { enforceAlbWafAssociations(this); - const encryptionKey = new kms.Key(this, 'PlatformDataKey', { - enableKeyRotation: true, - description: 'CMK for DynamoDB, Lambda environment, and API access logs', - removalPolicy: RemovalPolicy.RETAIN, + const service = new ApiLambdaDynamoService(this, 'DemoApiService', { + serviceName: 'demo-api', + stageName, + catalogEntityRef: 'component:default/infra-as-code-with-cdk', + recommendedPathTemplateName: 'recommended-path-service', + recommendedPathTemplatePath: 'backstage/templates/recommended-path-service/template.yaml', + handlerEntry: `${__dirname}/function.ts`, + itemsByCreatedAtIndexName, }); - const appVpc = new ec2.Vpc(this, 'AppVpc', { - maxAzs: 2, - natGateways: 1, - restrictDefaultSecurityGroup: false, - subnetConfiguration: [ - { - cidrMask: 24, - name: 'public', - subnetType: ec2.SubnetType.PUBLIC, - }, - { - cidrMask: 24, - name: 'private', - subnetType: ec2.SubnetType.PRIVATE_WITH_EGRESS, - }, - ], - }); - - const table = new dynamodb.Table(this, 'Table', { - partitionKey: { - name: 'id', - type: dynamodb.AttributeType.STRING, - }, - removalPolicy: RemovalPolicy.DESTROY, - pointInTimeRecovery: true, - billingMode: dynamodb.BillingMode.PAY_PER_REQUEST, - tableName: 'DemoTable', - encryption: dynamodb.TableEncryption.CUSTOMER_MANAGED, - encryptionKey, - }); - - table.addGlobalSecondaryIndex({ - indexName: itemsByCreatedAtIndexName, - partitionKey: { - name: 'entityType', - type: dynamodb.AttributeType.STRING, - }, - sortKey: { - name: 'createdAt', - type: dynamodb.AttributeType.STRING, - }, - projectionType: dynamodb.ProjectionType.ALL, - }); - - const retryQueue = new sqs.Queue(this, 'RetryQueue', { - encryption: sqs.QueueEncryption.KMS, - encryptionMasterKey: encryptionKey, - retentionPeriod: cdk.Duration.days(14), - }); - - const backend = new NodejsFunction(this, 'function', { - entry: path.join(__dirname, 'function.ts'), - handler: 'handler', - runtime: lambda.Runtime.NODEJS_18_X, - environment: { - APP_LOG_LEVEL: 'INFO', - CATALOG_ENTITY_REF: 'component:default/infra-as-code-with-cdk', - DYNAMODB: table.tableName, - ITEMS_BY_CREATED_AT_INDEX: itemsByCreatedAtIndexName, - NODE_OPTIONS: '--enable-source-maps', - RECOMMENDED_PATH_CATALOG_PATH: 'catalog-info.yaml', - RECOMMENDED_PATH_TEMPLATE_NAME: 'recommended-path-service', - RECOMMENDED_PATH_TEMPLATE_PATH: 'backstage/templates/recommended-path-service/template.yaml', - SERVICE_NAME: 'demo-api', - STAGE: stageName, - }, - environmentEncryption: encryptionKey, - bundling: { - minify: true, - sourceMap: true, - externalModules: ['aws-sdk'], - target: 'node18', - }, - memorySize: 1024, - timeout: cdk.Duration.seconds(30), - tracing: lambda.Tracing.ACTIVE, - deadLetterQueueEnabled: true, - deadLetterQueue: retryQueue, - reservedConcurrentExecutions: 10, - vpc: appVpc, - vpcSubnets: { - subnetType: ec2.SubnetType.PRIVATE_WITH_EGRESS, - }, - }); - - table.grantReadWriteData(backend); - - const apiAccessLogs = new logs.LogGroup(this, 'ApiGatewayAccessLogs', { - encryptionKey, - retention: logs.RetentionDays.TWO_YEARS, - removalPolicy: RemovalPolicy.RETAIN, - }); - - const lambdaAppLogs = new logs.LogGroup(this, 'LambdaApplicationLogs', { - logGroupName: `/aws/lambda/${backend.functionName}`, - encryptionKey, - retention: logs.RetentionDays.ONE_MONTH, - removalPolicy: RemovalPolicy.RETAIN, - }); - - const api = new apigateway.RestApi(this, 'RestAPI', { - restApiName: 'Demo API', - description: 'Demo API with Lambda and DynamoDB', - defaultMethodOptions: { - authorizationType: apigateway.AuthorizationType.IAM, - }, - defaultCorsPreflightOptions: { - allowOrigins: apigateway.Cors.ALL_ORIGINS, - allowMethods: apigateway.Cors.ALL_METHODS, - allowHeaders: [ - 'Content-Type', - 'X-Amz-Date', - 'Authorization', - 'X-Api-Key', - 'X-Amz-Security-Token', - ], - maxAge: cdk.Duration.days(1), - }, - deployOptions: { - accessLogDestination: new apigateway.LogGroupLogDestination(apiAccessLogs), - accessLogFormat: apigateway.AccessLogFormat.jsonWithStandardFields(), - loggingLevel: apigateway.MethodLoggingLevel.INFO, - dataTraceEnabled: true, - tracingEnabled: true, - cacheClusterEnabled: true, - cacheClusterSize: '0.5', - cachingEnabled: true, - }, - }); - - const integration = new apigateway.LambdaIntegration(backend); - api.root.addMethod('GET', integration); - - const health = api.root.addResource('health'); - health.addMethod('GET', integration); - - const items = api.root.addResource('items'); - items.addMethod('GET', integration); - items.addMethod('POST', integration); - - const platform = api.root.addResource('platform'); - platform.addMethod('GET', integration); - - const recommendedPath = platform.addResource('recommended-path'); - recommendedPath.addMethod('GET', integration); - const budgetNotifications = finOpsAlertEmail ? [ budgetNotificationWithEmail(finOpsAlertEmail, 'ACTUAL', 80), @@ -257,11 +106,11 @@ export class CdkAppStack extends cdk.Stack { const alarmTopic = new sns.Topic(this, 'ObservabilityAlarmTopic', { displayName: 'Platform Observability Alerts', - masterKey: encryptionKey, + masterKey: service.encryptionKey, }); const lambdaErrorsAlarm = new cloudwatch.Alarm(this, 'LambdaErrorsAlarm', { - metric: backend.metricErrors({ + metric: service.backend.metricErrors({ period: cdk.Duration.minutes(5), statistic: 'sum', }), @@ -273,7 +122,7 @@ export class CdkAppStack extends cdk.Stack { }); const lambdaDurationAlarm = new cloudwatch.Alarm(this, 'LambdaDurationP95Alarm', { - metric: backend.metricDuration({ + metric: service.backend.metricDuration({ period: cdk.Duration.minutes(5), statistic: 'p95', }), @@ -285,7 +134,7 @@ export class CdkAppStack extends cdk.Stack { }); const api5xxAlarm = new cloudwatch.Alarm(this, 'Api5xxAlarm', { - metric: api.metricServerError({ + metric: service.api.metricServerError({ period: cdk.Duration.minutes(5), statistic: 'sum', }), @@ -307,40 +156,40 @@ export class CdkAppStack extends cdk.Stack { observabilityDashboard.addWidgets( new cloudwatch.GraphWidget({ title: 'Lambda Invocations / Errors', - left: [backend.metricInvocations(), backend.metricErrors()], + left: [service.backend.metricInvocations(), service.backend.metricErrors()], width: 12, }), new cloudwatch.GraphWidget({ title: 'Lambda Duration (p50/p95)', left: [ - backend.metricDuration({ statistic: 'p50' }), - backend.metricDuration({ statistic: 'p95' }), + service.backend.metricDuration({ statistic: 'p50' }), + service.backend.metricDuration({ statistic: 'p95' }), ], width: 12, }), new cloudwatch.GraphWidget({ title: 'API Gateway Requests / 5XX', - left: [api.metricCount(), api.metricServerError()], + left: [service.api.metricCount(), service.api.metricServerError()], width: 12, }), new cloudwatch.GraphWidget({ title: 'API Gateway Latency (p50/p95)', left: [ - api.metricLatency({ statistic: 'p50' }), - api.metricLatency({ statistic: 'p95' }), + service.api.metricLatency({ statistic: 'p50' }), + service.api.metricLatency({ statistic: 'p95' }), ], width: 12, }), ); new cdk.CfnOutput(this, 'ApiUrl', { - value: api.url, + value: service.api.url, description: 'API Gateway URL', exportName: `${this.stackName}-api-url`, }); new cdk.CfnOutput(this, 'DynamoDBTableName', { - value: table.tableName, + value: service.table.tableName, description: 'DynamoDB table name', exportName: `${this.stackName}-table-name`, }); @@ -388,7 +237,7 @@ export class CdkAppStack extends cdk.Stack { }); new cdk.CfnOutput(this, 'LambdaApplicationLogGroupName', { - value: lambdaAppLogs.logGroupName, + value: service.lambdaApplicationLogs.logGroupName, description: 'Application log group name used by Lambda structured logs', exportName: `${this.stackName}-lambda-application-log-group-name`, }); diff --git a/packages/platform-constructs/README.md b/packages/platform-constructs/README.md new file mode 100644 index 0000000..62701cf --- /dev/null +++ b/packages/platform-constructs/README.md @@ -0,0 +1,27 @@ +# Platform Constructs + +Reusable CDK constructs for the internal developer platform. + +## ApiLambdaDynamoService + +`ApiLambdaDynamoService` is the first golden-path L3 construct. It packages the standard API Gateway, Lambda, DynamoDB, logging, tracing, retry, and encryption baseline behind a small consumer API. + +```ts +new ApiLambdaDynamoService(this, 'OrdersApiService', { + serviceName: 'orders-api', + stageName: 'dev', + catalogEntityRef: 'component:default/orders-service', + recommendedPathTemplateName: 'recommended-path-service', + recommendedPathTemplatePath: 'backstage/templates/recommended-path-service/template.yaml', +}); +``` + +Cross-origin requests are disabled by default. If browser access is required, provide explicit origins: + +```ts +cors: { + allowOrigins: ['https://app.example.com'], +} +``` + +Use `applications/examples/orders-service/infra/orders-service-stack.ts` as the canonical consumer example. diff --git a/packages/platform-constructs/src/api-lambda-dynamo-service/index.ts b/packages/platform-constructs/src/api-lambda-dynamo-service/index.ts new file mode 100644 index 0000000..ce8fdaf --- /dev/null +++ b/packages/platform-constructs/src/api-lambda-dynamo-service/index.ts @@ -0,0 +1,278 @@ +import * as path from 'path'; +import * as cdk from 'aws-cdk-lib'; +import { RemovalPolicy } from 'aws-cdk-lib'; +import * as apigateway from 'aws-cdk-lib/aws-apigateway'; +import * as dynamodb from 'aws-cdk-lib/aws-dynamodb'; +import * as ec2 from 'aws-cdk-lib/aws-ec2'; +import * as kms from 'aws-cdk-lib/aws-kms'; +import * as lambda from 'aws-cdk-lib/aws-lambda'; +import { NodejsFunction } from 'aws-cdk-lib/aws-lambda-nodejs'; +import * as logs from 'aws-cdk-lib/aws-logs'; +import * as sqs from 'aws-cdk-lib/aws-sqs'; +import * as constructs from 'constructs'; + +export interface ApiLambdaDynamoServiceProps { + readonly serviceName: string; + readonly stageName: string; + readonly catalogEntityRef: string; + readonly recommendedPathTemplateName: string; + readonly recommendedPathTemplatePath: string; + readonly handlerEntry?: string; + readonly itemsByCreatedAtIndexName?: string; + readonly vpc?: ec2.IVpc; + readonly encryptionKey?: kms.IKey; + readonly environment?: Record; + readonly cors?: { + readonly allowOrigins: string[]; + readonly allowMethods?: string[]; + readonly allowHeaders?: string[]; + readonly maxAge?: cdk.Duration; + }; + readonly alarms?: { + readonly enableP95LatencyAlarm?: boolean; + }; + readonly overrides?: { + readonly lambda?: Partial; + readonly apiGateway?: Partial; + readonly table?: Partial; + }; +} + +export interface ApiLambdaDynamoServiceLambdaOverrides { + readonly memorySize: number; + readonly reservedConcurrentExecutions: number; + readonly timeout: cdk.Duration; + readonly runtime: lambda.Runtime; +} + +export class ApiLambdaDynamoService extends constructs.Construct { + public readonly api: apigateway.RestApi; + public readonly backend: NodejsFunction; + public readonly table: dynamodb.Table; + public readonly apiAccessLogs: logs.LogGroup; + public readonly lambdaApplicationLogs: logs.LogGroup; + public readonly retryQueue: sqs.Queue; + public readonly encryptionKey: kms.IKey; + public readonly itemsByCreatedAtIndexName: string; + + constructor(scope: constructs.Construct, id: string, props: ApiLambdaDynamoServiceProps) { + super(scope, id); + + validateProps(props); + + this.itemsByCreatedAtIndexName = props.itemsByCreatedAtIndexName ?? 'ItemsByCreatedAtIndex'; + this.encryptionKey = + props.encryptionKey ?? + new kms.Key(this, 'ServiceDataKey', { + enableKeyRotation: true, + description: `CMK for ${props.serviceName} data, Lambda environment, and API access logs`, + removalPolicy: RemovalPolicy.RETAIN, + }); + + const vpc = + props.vpc ?? + new ec2.Vpc(this, 'ServiceVpc', { + maxAzs: 2, + natGateways: 1, + restrictDefaultSecurityGroup: true, + subnetConfiguration: [ + { + cidrMask: 24, + name: 'public', + subnetType: ec2.SubnetType.PUBLIC, + }, + { + cidrMask: 24, + name: 'private', + subnetType: ec2.SubnetType.PRIVATE_WITH_EGRESS, + }, + ], + }); + + this.table = new dynamodb.Table(this, 'Table', { + partitionKey: { + name: 'id', + type: dynamodb.AttributeType.STRING, + }, + removalPolicy: RemovalPolicy.DESTROY, + pointInTimeRecovery: true, + billingMode: dynamodb.BillingMode.PAY_PER_REQUEST, + tableName: `${props.serviceName}-${props.stageName}`, + encryption: dynamodb.TableEncryption.CUSTOMER_MANAGED, + encryptionKey: this.encryptionKey, + ...props.overrides?.table, + }); + + this.table.addGlobalSecondaryIndex({ + indexName: this.itemsByCreatedAtIndexName, + partitionKey: { + name: 'entityType', + type: dynamodb.AttributeType.STRING, + }, + sortKey: { + name: 'createdAt', + type: dynamodb.AttributeType.STRING, + }, + projectionType: dynamodb.ProjectionType.ALL, + }); + + this.retryQueue = new sqs.Queue(this, 'RetryQueue', { + queueName: `${props.serviceName}-${props.stageName}-retryqueue`, + encryption: sqs.QueueEncryption.KMS, + encryptionMasterKey: this.encryptionKey, + retentionPeriod: cdk.Duration.days(14), + }); + + this.backend = new NodejsFunction(this, 'Function', { + entry: props.handlerEntry ?? path.join(__dirname, '../../../../lib/function.ts'), + handler: 'handler', + runtime: props.overrides?.lambda?.runtime ?? lambda.Runtime.NODEJS_18_X, + environment: { + APP_LOG_LEVEL: 'INFO', + CATALOG_ENTITY_REF: props.catalogEntityRef, + DYNAMODB: this.table.tableName, + ITEMS_BY_CREATED_AT_INDEX: this.itemsByCreatedAtIndexName, + NODE_OPTIONS: '--enable-source-maps', + RECOMMENDED_PATH_CATALOG_PATH: 'catalog-info.yaml', + RECOMMENDED_PATH_TEMPLATE_NAME: props.recommendedPathTemplateName, + RECOMMENDED_PATH_TEMPLATE_PATH: props.recommendedPathTemplatePath, + SERVICE_NAME: props.serviceName, + STAGE: props.stageName, + ...props.environment, + }, + environmentEncryption: this.encryptionKey, + bundling: { + minify: true, + sourceMap: true, + externalModules: ['aws-sdk'], + target: 'node18', + }, + memorySize: props.overrides?.lambda?.memorySize ?? 1024, + timeout: props.overrides?.lambda?.timeout ?? cdk.Duration.seconds(30), + tracing: lambda.Tracing.ACTIVE, + deadLetterQueue: this.retryQueue, + reservedConcurrentExecutions: props.overrides?.lambda?.reservedConcurrentExecutions ?? 10, + vpc, + vpcSubnets: { + subnetType: ec2.SubnetType.PRIVATE_WITH_EGRESS, + }, + }); + + const retryQueue = this.retryQueue; + cdk.Aspects.of(cdk.Stack.of(this)).add({ + visit(node: constructs.IConstruct): void { + if (node instanceof lambda.CfnFunction && !node.deadLetterConfig) { + node.deadLetterConfig = { targetArn: retryQueue.queueArn }; + node.reservedConcurrentExecutions = 1; + node.cfnOptions.metadata = { + ...node.cfnOptions.metadata, + checkov: { + skip: [ + { + id: 'CKV_AWS_117', + comment: 'Custom resource Lambda cannot be placed inside a VPC', + }, + ], + }, + }; + } + }, + }); + + this.table.grantReadWriteData(this.backend); + + this.apiAccessLogs = new logs.LogGroup(this, 'ApiGatewayAccessLogs', { + encryptionKey: this.encryptionKey, + retention: logs.RetentionDays.TWO_YEARS, + removalPolicy: RemovalPolicy.RETAIN, + }); + + this.lambdaApplicationLogs = new logs.LogGroup(this, 'LambdaApplicationLogs', { + logGroupName: `/aws/lambda/${this.backend.functionName}`, + encryptionKey: this.encryptionKey, + retention: logs.RetentionDays.ONE_MONTH, + removalPolicy: RemovalPolicy.RETAIN, + }); + + this.api = new apigateway.RestApi(this, 'RestAPI', { + restApiName: `${props.serviceName}-${props.stageName}`, + description: `${props.serviceName} API with Lambda and DynamoDB`, + defaultMethodOptions: { + authorizationType: apigateway.AuthorizationType.IAM, + }, + defaultCorsPreflightOptions: props.cors + ? { + allowOrigins: props.cors.allowOrigins, + allowMethods: props.cors.allowMethods ?? apigateway.Cors.ALL_METHODS, + allowHeaders: props.cors.allowHeaders ?? [ + 'Content-Type', + 'X-Amz-Date', + 'Authorization', + 'X-Api-Key', + 'X-Amz-Security-Token', + ], + maxAge: props.cors.maxAge ?? cdk.Duration.days(1), + } + : undefined, + deployOptions: { + accessLogDestination: new apigateway.LogGroupLogDestination(this.apiAccessLogs), + accessLogFormat: apigateway.AccessLogFormat.jsonWithStandardFields(), + loggingLevel: apigateway.MethodLoggingLevel.INFO, + dataTraceEnabled: true, + tracingEnabled: true, + cacheClusterEnabled: true, + cacheClusterSize: '0.5', + cachingEnabled: true, + ...props.overrides?.apiGateway, + }, + }); + + const integration = new apigateway.LambdaIntegration(this.backend); + this.api.root.addMethod('GET', integration); + + const health = this.api.root.addResource('health'); + health.addMethod('GET', integration); + + const items = this.api.root.addResource('items'); + items.addMethod('GET', integration); + items.addMethod('POST', integration); + + const platform = this.api.root.addResource('platform'); + platform.addMethod('GET', integration); + + const recommendedPath = platform.addResource('recommended-path'); + recommendedPath.addMethod('GET', integration); + } +} + +function validateProps(props: ApiLambdaDynamoServiceProps): void { + if (!props.serviceName.trim()) { + throw new Error('ApiLambdaDynamoService serviceName must be a non-empty string.'); + } + + if (!/^[a-z][a-z0-9-]{1,38}[a-z0-9]$/.test(props.serviceName)) { + throw new Error( + 'ApiLambdaDynamoService serviceName must be 3-40 characters and use lowercase letters, numbers, and hyphens.', + ); + } + + if (!props.stageName.trim()) { + throw new Error('ApiLambdaDynamoService stageName must be a non-empty string.'); + } + + if (!props.catalogEntityRef.trim()) { + throw new Error('ApiLambdaDynamoService catalogEntityRef must be a non-empty string.'); + } + + if (!props.recommendedPathTemplateName.trim()) { + throw new Error('ApiLambdaDynamoService recommendedPathTemplateName must be a non-empty string.'); + } + + if (!props.recommendedPathTemplatePath.trim()) { + throw new Error('ApiLambdaDynamoService recommendedPathTemplatePath must be a non-empty string.'); + } + + if (props.cors?.allowOrigins.some((origin) => origin === '*')) { + throw new Error('ApiLambdaDynamoService cors.allowOrigins must not include wildcard origins.'); + } +} diff --git a/packages/platform-constructs/src/index.ts b/packages/platform-constructs/src/index.ts new file mode 100644 index 0000000..b115499 --- /dev/null +++ b/packages/platform-constructs/src/index.ts @@ -0,0 +1 @@ +export * from './api-lambda-dynamo-service'; diff --git a/test/api-lambda-dynamo-service.test.ts b/test/api-lambda-dynamo-service.test.ts new file mode 100644 index 0000000..9fcec21 --- /dev/null +++ b/test/api-lambda-dynamo-service.test.ts @@ -0,0 +1,126 @@ +import * as cdk from 'aws-cdk-lib'; +import { Match, Template } from 'aws-cdk-lib/assertions'; +import { ApiLambdaDynamoService } from '../packages/platform-constructs/src'; + +jest.mock('aws-cdk-lib/aws-lambda-nodejs', () => { + const lambda = jest.requireActual('aws-cdk-lib/aws-lambda'); + + return { + NodejsFunction: class NodejsFunction extends lambda.Function { + constructor(scope: any, id: string, props: Record) { + const { bundling, entry, ...lambdaProps } = props; + + super(scope, id, { + ...lambdaProps, + code: lambda.Code.fromInline('exports.handler = async () => ({ statusCode: 200, body: "{}" });'), + }); + } + }, + }; +}); + +describe('ApiLambdaDynamoService', () => { + it('creates a secure-by-default API, Lambda, DynamoDB, and retry queue baseline', () => { + const app = new cdk.App(); + const stack = new cdk.Stack(app, 'ConstructConsumerStack'); + + new ApiLambdaDynamoService(stack, 'OrdersApiService', { + serviceName: 'orders-api', + stageName: 'dev', + catalogEntityRef: 'component:default/orders-service', + recommendedPathTemplateName: 'recommended-path-service', + recommendedPathTemplatePath: 'backstage/templates/recommended-path-service/template.yaml', + }); + + const template = Template.fromStack(stack); + + template.resourceCountIs('AWS::ApiGateway::RestApi', 1); + template.resourceCountIs('AWS::DynamoDB::Table', 1); + template.resourceCountIs('AWS::SQS::Queue', 1); + template.resourcePropertiesCountIs('AWS::Lambda::Function', { + Environment: { + Variables: Match.objectLike({ + SERVICE_NAME: 'orders-api', + }), + }, + }, 1); + + template.hasResourceProperties('AWS::DynamoDB::Table', { + BillingMode: 'PAY_PER_REQUEST', + PointInTimeRecoverySpecification: { + PointInTimeRecoveryEnabled: true, + }, + SSESpecification: { + SSEEnabled: true, + SSEType: 'KMS', + }, + GlobalSecondaryIndexes: Match.arrayWith([ + Match.objectLike({ + IndexName: 'ItemsByCreatedAtIndex', + }), + ]), + }); + + template.hasResourceProperties('AWS::Lambda::Function', { + Runtime: 'nodejs18.x', + TracingConfig: { + Mode: 'Active', + }, + ReservedConcurrentExecutions: 10, + Environment: { + Variables: Match.objectLike({ + CATALOG_ENTITY_REF: 'component:default/orders-service', + ITEMS_BY_CREATED_AT_INDEX: 'ItemsByCreatedAtIndex', + RECOMMENDED_PATH_TEMPLATE_NAME: 'recommended-path-service', + SERVICE_NAME: 'orders-api', + STAGE: 'dev', + }), + }, + }); + + template.hasResourceProperties('AWS::ApiGateway::Method', { + AuthorizationType: 'AWS_IAM', + }); + + template.resourceCountIs('Custom::VpcRestrictDefaultSG', 1); + + expect(JSON.stringify(template.toJSON())).not.toContain("'Access-Control-Allow-Origin':'*'"); + }); + + it('fails fast on invalid service names', () => { + const app = new cdk.App(); + const stack = new cdk.Stack(app, 'InvalidConsumerStack'); + + expect( + () => + new ApiLambdaDynamoService(stack, 'InvalidApiService', { + serviceName: 'Orders API', + stageName: 'dev', + catalogEntityRef: 'component:default/orders-service', + recommendedPathTemplateName: 'recommended-path-service', + recommendedPathTemplatePath: 'backstage/templates/recommended-path-service/template.yaml', + }), + ).toThrow( + 'ApiLambdaDynamoService serviceName must be 3-40 characters and use lowercase letters, numbers, and hyphens.', + ); + }); + + it('rejects wildcard CORS origins', () => { + const app = new cdk.App(); + const stack = new cdk.Stack(app, 'InvalidCorsStack'); + + expect( + () => + new ApiLambdaDynamoService(stack, 'InvalidCorsApiService', { + serviceName: 'orders-api', + stageName: 'dev', + catalogEntityRef: 'component:default/orders-service', + recommendedPathTemplateName: 'recommended-path-service', + recommendedPathTemplatePath: 'backstage/templates/recommended-path-service/template.yaml', + cors: { + allowOrigins: ['*'], + }, + }), + ).toThrow('ApiLambdaDynamoService cors.allowOrigins must not include wildcard origins.'); + }); +}); diff --git a/test/cdk-app-stack.test.ts b/test/cdk-app-stack.test.ts index 54aaa81..983e871 100644 --- a/test/cdk-app-stack.test.ts +++ b/test/cdk-app-stack.test.ts @@ -24,7 +24,13 @@ describe('CdkAppStack', () => { test('creates core resources', () => { const template = buildTemplate('TestStack'); - template.resourceCountIs('AWS::Lambda::Function', 1); + template.resourcePropertiesCountIs('AWS::Lambda::Function', { + Environment: { + Variables: Match.objectLike({ + SERVICE_NAME: 'demo-api', + }), + }, + }, 1); template.resourceCountIs('AWS::ApiGateway::RestApi', 1); template.resourceCountIs('AWS::DynamoDB::Table', 1); template.resourceCountIs('AWS::KMS::Key', 1); diff --git a/test/orders-service-stack.test.ts b/test/orders-service-stack.test.ts new file mode 100644 index 0000000..56d3c25 --- /dev/null +++ b/test/orders-service-stack.test.ts @@ -0,0 +1,46 @@ +import * as cdk from 'aws-cdk-lib'; +import { Match, Template } from 'aws-cdk-lib/assertions'; +import { OrdersServiceStack } from '../applications/examples/orders-service/infra/orders-service-stack'; +import { loadPlatformConfig } from '../lib/platform-config'; + +jest.mock('aws-cdk-lib/aws-lambda-nodejs', () => { + const lambda = jest.requireActual('aws-cdk-lib/aws-lambda'); + + return { + NodejsFunction: class NodejsFunction extends lambda.Function { + constructor(scope: any, id: string, props: Record) { + const { bundling, entry, ...lambdaProps } = props; + + super(scope, id, { + ...lambdaProps, + code: lambda.Code.fromInline('exports.handler = async () => ({ statusCode: 200, body: "{}" });'), + }); + } + }, + }; +}); + +describe('OrdersServiceStack', () => { + it('consumes the platform API/Lambda/Dynamo golden-path construct', () => { + const app = new cdk.App(); + const stack = new OrdersServiceStack(app, 'OrdersServiceDev', { + platformConfig: loadPlatformConfig('dev'), + }); + + const template = Template.fromStack(stack); + + template.resourceCountIs('AWS::ApiGateway::RestApi', 1); + template.resourceCountIs('AWS::DynamoDB::Table', 1); + template.hasResourceProperties('AWS::Lambda::Function', { + Environment: { + Variables: Match.objectLike({ + CATALOG_ENTITY_REF: 'component:default/orders-service', + ITEMS_BY_CREATED_AT_INDEX: 'ItemsByCreatedAtIndex', + RECOMMENDED_PATH_TEMPLATE_NAME: 'recommended-path-service', + SERVICE_NAME: 'orders-api', + STAGE: 'dev', + }), + }, + }); + }); +}); diff --git a/tsconfig.json b/tsconfig.json index e6ec6af..f6f442a 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -13,13 +13,14 @@ "noUnusedParameters": false, "noImplicitReturns": true, "noFallthroughCasesInSwitch": false, - "inlineSourceMap": true, + "inlineSourceMap": true, "inlineSources": true, + "skipLibCheck": true, "experimentalDecorators": true, "strictPropertyInitialization": false, "types": ["node", "jest", "aws-lambda"], "outDir": "dist" }, "exclude": ["node_modules", "cdk.out", "dist"], - "include": ["bin/**/*", "lib/**/*"] -} + "include": ["bin/**/*", "lib/**/*", "packages/**/*", "applications/examples/**/*"] +}