- Fix HTTP listener in spicy-alb.ts missing default action when no certificate is provided, which would cause CDK synth to fail - Auto-import numberOfAzs from VPC stack exports (NumberOfAZs) in cluster, service, and ALB stacks when not provided via context - Fix CDK_SYNTH_EXAMPLES.md ALB examples using raw vpcId/subnetIds that don't match the actual fromContext() implementation (requires clusterName) - Fix docs overstating "only clusterName required" to list actual required params - Remove package-lock.json and add to .gitignore (project uses pnpm) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
476 lines
19 KiB
TypeScript
476 lines
19 KiB
TypeScript
import * as ec2 from 'aws-cdk-lib/aws-ec2';
|
|
import * as ecs from 'aws-cdk-lib/aws-ecs';
|
|
import * as cdk from 'aws-cdk-lib/core';
|
|
import { Construct } from 'constructs';
|
|
import {
|
|
CapacityProviderStrategyItem,
|
|
ContainerConfig,
|
|
DeploymentConfig,
|
|
DnsConfig,
|
|
HealthCheckConfig,
|
|
LoadBalancerRoutingConfig,
|
|
ServiceScalingConfig,
|
|
SpicyEcsService,
|
|
SpicyEcsServiceTags,
|
|
} from '../constructs/spicy-ecs-service';
|
|
|
|
/**
|
|
* Props for SpicyEcsServiceStack
|
|
*/
|
|
export interface SpicyEcsServiceStackProps extends cdk.StackProps {
|
|
/** ECS Cluster name to import */
|
|
readonly clusterName: string;
|
|
|
|
/** VPC ID */
|
|
readonly vpcId: string;
|
|
|
|
/** VPC CIDR block */
|
|
readonly vpcCidrBlock?: string;
|
|
|
|
/** Number of availability zones (2-4) */
|
|
readonly numberOfAzs: number;
|
|
|
|
/** Availability zones */
|
|
readonly availabilityZones: string[];
|
|
|
|
/** Private subnet IDs */
|
|
readonly privateSubnetIds: string[];
|
|
|
|
/** Service name */
|
|
readonly serviceName: string;
|
|
|
|
/** Docker image URI */
|
|
readonly image: string;
|
|
|
|
/** Container port */
|
|
readonly containerPort: number;
|
|
|
|
/** CPU units */
|
|
readonly cpu?: number;
|
|
|
|
/** Memory in MiB */
|
|
readonly memory?: number;
|
|
|
|
/** Environment variables (JSON string) */
|
|
readonly environment?: string;
|
|
|
|
/** Secrets (JSON string: { "ENV_VAR": "secret-arn" }) */
|
|
readonly secrets?: string;
|
|
|
|
/** Desired task count */
|
|
readonly desiredCount?: number;
|
|
|
|
/** Capacity provider strategy (JSON string) */
|
|
readonly capacityProviderStrategy?: string;
|
|
|
|
/** Scaling configuration */
|
|
readonly scaling?: ServiceScalingConfig;
|
|
|
|
/** Health check path */
|
|
readonly healthCheckPath?: string;
|
|
|
|
/** Routing configuration */
|
|
readonly routing?: LoadBalancerRoutingConfig;
|
|
|
|
/** External ALB listener ARN (for cluster ALB mode) */
|
|
readonly externalListenerArn?: string;
|
|
|
|
/** Internal ALB listener ARN (for cluster ALB mode) */
|
|
readonly internalListenerArn?: string;
|
|
|
|
/** Public subnet IDs (for individual ALB) */
|
|
readonly publicSubnetIds?: string[];
|
|
|
|
/** Cluster logs bucket name (for individual ALB access logs) */
|
|
readonly clusterLogsBucketName?: string;
|
|
|
|
/** DNS configuration */
|
|
readonly dns?: DnsConfig;
|
|
|
|
/** Deployment configuration */
|
|
readonly deployment?: DeploymentConfig;
|
|
|
|
/** Service tags */
|
|
readonly serviceTags: SpicyEcsServiceTags;
|
|
}
|
|
|
|
/**
|
|
* Stack for deploying an ECS Service
|
|
*/
|
|
export class SpicyEcsServiceStack extends cdk.Stack {
|
|
public readonly service: SpicyEcsService;
|
|
|
|
constructor(scope: Construct, id: string, props: SpicyEcsServiceStackProps) {
|
|
super(scope, id, props);
|
|
|
|
// Import VPC
|
|
const vpc = ec2.Vpc.fromVpcAttributes(this, 'ImportedVpc', {
|
|
vpcId: props.vpcId,
|
|
availabilityZones: props.availabilityZones,
|
|
privateSubnetIds: props.privateSubnetIds,
|
|
...(props.publicSubnetIds && { publicSubnetIds: props.publicSubnetIds }),
|
|
});
|
|
|
|
// Import Cluster
|
|
const cluster = ecs.Cluster.fromClusterAttributes(this, 'ImportedCluster', {
|
|
clusterName: props.clusterName,
|
|
vpc,
|
|
securityGroups: [],
|
|
});
|
|
|
|
// Parse JSON configs
|
|
const environment = props.environment ? JSON.parse(props.environment) : undefined;
|
|
const secrets = props.secrets ? JSON.parse(props.secrets) : undefined;
|
|
const capacityProviderStrategy: CapacityProviderStrategyItem[] | undefined = props.capacityProviderStrategy
|
|
? JSON.parse(props.capacityProviderStrategy)
|
|
: undefined;
|
|
|
|
// Build container config
|
|
const container: ContainerConfig = {
|
|
image: props.image,
|
|
port: props.containerPort,
|
|
cpu: props.cpu,
|
|
memory: props.memory,
|
|
environment,
|
|
secrets,
|
|
};
|
|
|
|
// Build health check config
|
|
const healthCheck: HealthCheckConfig | undefined = props.healthCheckPath
|
|
? { path: props.healthCheckPath }
|
|
: undefined;
|
|
|
|
// Create the service
|
|
this.service = new SpicyEcsService(this, 'EcsService', {
|
|
cluster,
|
|
vpc,
|
|
serviceName: props.serviceName,
|
|
container,
|
|
capacityProviderStrategy,
|
|
desiredCount: props.desiredCount,
|
|
scaling: props.scaling,
|
|
healthCheck,
|
|
routing: props.routing,
|
|
externalListenerArn: props.externalListenerArn,
|
|
internalListenerArn: props.internalListenerArn,
|
|
dns: props.dns,
|
|
deployment: props.deployment,
|
|
tags: props.serviceTags,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Create stack from CDK context
|
|
* Only requires clusterStackName - VPC stack name imported from cluster export, then all VPC details imported from VPC stack
|
|
*/
|
|
static fromContext(scope: Construct, id: string, stackProps?: cdk.StackProps): SpicyEcsServiceStack {
|
|
const app = scope.node.root as cdk.App;
|
|
|
|
// Required: clusterStackName (VPC stack name imported from cluster export, then VPC details imported from VPC stack)
|
|
const clusterStackName = app.node.tryGetContext('clusterStackName') || app.node.tryGetContext('clusterName');
|
|
if (!clusterStackName) {
|
|
throw new Error(
|
|
'clusterStackName is required. Provide clusterStackName to import VPC stack name from cluster export, then import VPC details from VPC stack.'
|
|
);
|
|
}
|
|
|
|
// Required service config
|
|
const serviceName = app.node.tryGetContext('serviceName');
|
|
const image = app.node.tryGetContext('image');
|
|
const containerPort = parseInt(app.node.tryGetContext('containerPort') ?? '3000');
|
|
|
|
// Import VPC stack name from cluster stack (cluster exports: ${clusterStackName}-VPCStackName)
|
|
const vpcStackName = cdk.Fn.importValue(`${clusterStackName}-VPCStackName`).toString();
|
|
|
|
// Import VPC ID from cluster stack (cluster exports: ${clusterStackName}-VPC)
|
|
const vpcId = cdk.Fn.importValue(`${clusterStackName}-VPC`);
|
|
|
|
// Import all VPC details from VPC stack exports
|
|
const vpcCidrBlock = cdk.Fn.importValue(`${vpcStackName}-VPCCIDR`).toString();
|
|
|
|
// numberOfAzs: use context value if provided, otherwise auto-import from VPC stack export
|
|
const numberOfAzsRaw = app.node.tryGetContext('numberOfAzs');
|
|
let numberOfAzs: number;
|
|
if (numberOfAzsRaw) {
|
|
numberOfAzs = parseInt(numberOfAzsRaw, 10);
|
|
} else {
|
|
const imported = cdk.Fn.importValue(`${vpcStackName}-NumberOfAZs`).toString();
|
|
numberOfAzs = parseInt(imported, 10);
|
|
}
|
|
if (!numberOfAzs || Number.isNaN(numberOfAzs)) {
|
|
throw new Error('numberOfAzs is required (2-4). Provide via context or ensure VPC stack exports NumberOfAZs.');
|
|
}
|
|
const azs = ['A', 'B', 'C', 'D'].slice(0, Math.min(Math.max(numberOfAzs, 1), 4));
|
|
|
|
// Import private subnet IDs from VPC stack
|
|
const privateSubnetIds = azs.map((az) => cdk.Fn.importValue(`${vpcStackName}-PrivateSubnet${az}1ID`).toString());
|
|
|
|
// Derive availability zones from region
|
|
const region = cdk.Stack.of(scope).region;
|
|
const availabilityZones = azs.map((az) => `${region}${az.toLowerCase()}`);
|
|
|
|
// ALB stack name is derived from service stackName: ${baseStackName}-alb
|
|
// Remove -blue/-green suffix and add -alb
|
|
const serviceStackName = app.node.tryGetContext('stackName') || id;
|
|
const baseStackName = serviceStackName.replace(/-blue$|-green$/, '');
|
|
const albStackName = `${baseStackName}-alb`;
|
|
|
|
// Tags
|
|
const serviceTags: SpicyEcsServiceTags = {
|
|
owner: app.node.tryGetContext('ownerTag') ?? 'Unknown',
|
|
product: app.node.tryGetContext('productTag') ?? 'Unknown',
|
|
component: app.node.tryGetContext('componentTag') ?? serviceName,
|
|
environment: app.node.tryGetContext('environment') ?? 'dev',
|
|
build: app.node.tryGetContext('build'),
|
|
};
|
|
|
|
// Optional container config
|
|
const cpu = app.node.tryGetContext('cpu') ? parseInt(app.node.tryGetContext('cpu')) : undefined;
|
|
const memory = app.node.tryGetContext('memory') ? parseInt(app.node.tryGetContext('memory')) : undefined;
|
|
const environment = app.node.tryGetContext('environment_vars'); // JSON string
|
|
const secrets = app.node.tryGetContext('secrets'); // JSON string
|
|
const desiredCount = app.node.tryGetContext('desiredCount')
|
|
? parseInt(app.node.tryGetContext('desiredCount'))
|
|
: undefined;
|
|
|
|
// Capacity provider strategy (JSON string)
|
|
const capacityProviderStrategy = app.node.tryGetContext('capacityProviderStrategy');
|
|
|
|
// Scaling
|
|
const minCapacity = app.node.tryGetContext('minCapacity')
|
|
? parseInt(app.node.tryGetContext('minCapacity'))
|
|
: undefined;
|
|
const maxCapacity = app.node.tryGetContext('maxCapacity')
|
|
? parseInt(app.node.tryGetContext('maxCapacity'))
|
|
: undefined;
|
|
const targetCpuUtilization = app.node.tryGetContext('targetCpuUtilization')
|
|
? parseInt(app.node.tryGetContext('targetCpuUtilization'))
|
|
: undefined;
|
|
const targetMemoryUtilization = app.node.tryGetContext('targetMemoryUtilization')
|
|
? parseInt(app.node.tryGetContext('targetMemoryUtilization'))
|
|
: undefined;
|
|
const targetRequestsPerTarget = app.node.tryGetContext('targetRequestsPerTarget')
|
|
? parseInt(app.node.tryGetContext('targetRequestsPerTarget'))
|
|
: undefined;
|
|
|
|
const scaling: ServiceScalingConfig | undefined =
|
|
minCapacity && maxCapacity
|
|
? {
|
|
minCapacity,
|
|
maxCapacity,
|
|
targetCpuUtilization,
|
|
targetMemoryUtilization,
|
|
targetRequestsPerTarget,
|
|
}
|
|
: undefined;
|
|
|
|
// Health check
|
|
const healthCheckPath = app.node.tryGetContext('healthCheckPath');
|
|
|
|
// Routing - ALB details (resolved by pipeline from cluster or ALB stack)
|
|
// Pipeline sets: albLoadBalancerArn, albHttpsListenerArn, albHttpListenerArn in context
|
|
// If not provided in context, fall back to importing from exports (backward compatibility)
|
|
const useClusterAlb = app.node.tryGetContext('useClusterAlb') !== 'false'; // default true
|
|
|
|
let existingAlbArn: string | undefined = app.node.tryGetContext('albLoadBalancerArn');
|
|
let existingAlbHttpsListenerArn: string | undefined = app.node.tryGetContext('albHttpsListenerArn');
|
|
let existingAlbHttpListenerArn: string | undefined = app.node.tryGetContext('albHttpListenerArn');
|
|
|
|
// If not provided in context, import from exports (backward compatibility)
|
|
if (!existingAlbArn && !useClusterAlb) {
|
|
const albScheme = app.node.tryGetContext('albScheme') || 'internet-facing';
|
|
const prefix = albScheme === 'internet-facing' ? 'internet-facing' : 'internal';
|
|
existingAlbArn = cdk.Fn.importValue(`${albStackName}-${prefix}-arn`).toString();
|
|
existingAlbHttpsListenerArn = cdk.Fn.importValue(`${albStackName}-${prefix}-https-listener`).toString();
|
|
existingAlbHttpListenerArn = cdk.Fn.importValue(`${albStackName}-${prefix}-http-listener`).toString();
|
|
}
|
|
|
|
// ALB listeners (for cluster ALB mode) - import from cluster stack (backward compatibility)
|
|
// Cluster exports: ${clusterStackName}-internet-facing-https-listener, ${clusterStackName}-internal-https-listener
|
|
let externalListenerArn = app.node.tryGetContext('externalListenerArn');
|
|
let internalListenerArn = app.node.tryGetContext('internalListenerArn');
|
|
|
|
// Auto-import listener ARNs from cluster stack if clusterStackName provided and listeners not explicitly set
|
|
// This matches the old pattern: Fn::ImportValue: !Sub "${ClusterName}-internet-facing-https-listener"
|
|
// Only used if albLoadBalancerArn is not provided (backward compatibility)
|
|
if (!existingAlbArn && clusterStackName && !externalListenerArn && !internalListenerArn) {
|
|
const useExternal = app.node.tryGetContext('useExternalALB') === 'true';
|
|
const useInternal = app.node.tryGetContext('useInternalALB') === 'true';
|
|
const albScheme =
|
|
app.node.tryGetContext('albScheme') ||
|
|
(useExternal ? 'internet-facing' : useInternal ? 'internal' : 'internet-facing');
|
|
|
|
// Try HTTPS listener first (most common), fall back to HTTP if needed
|
|
if (albScheme === 'internet-facing' || useExternal) {
|
|
externalListenerArn = cdk.Fn.importValue(`${clusterStackName}-internet-facing-https-listener`).toString();
|
|
} else if (albScheme === 'internal' || useInternal) {
|
|
internalListenerArn = cdk.Fn.importValue(`${clusterStackName}-internal-https-listener`).toString();
|
|
}
|
|
}
|
|
const hostHeader = app.node.tryGetContext('hostHeader');
|
|
const pathPatterns = app.node.tryGetContext('pathPatterns')?.split(',');
|
|
const priority = app.node.tryGetContext('priority') ? parseInt(app.node.tryGetContext('priority')) : undefined;
|
|
const useExternal = app.node.tryGetContext('useExternalALB') === 'true';
|
|
const useInternal = app.node.tryGetContext('useInternalALB') === 'true';
|
|
|
|
let routing: LoadBalancerRoutingConfig | undefined;
|
|
|
|
// Use unified ALB parameters if provided (from pipeline resolveAlbDetails)
|
|
// Otherwise fall back to cluster ALB mode (backward compatibility)
|
|
const useBgCommonAlb = existingAlbArn != null;
|
|
|
|
if (useBgCommonAlb) {
|
|
// ALB details are provided by pipeline (resolved from cluster or ALB stack)
|
|
// Pipeline sets: albLoadBalancerArn, albHttpsListenerArn, albHttpListenerArn
|
|
if (!hostHeader && (!pathPatterns || pathPatterns.length === 0)) {
|
|
throw new Error('When using ALB, either hostHeader or pathPatterns must be provided for routing');
|
|
}
|
|
// Use unified ALB parameters (from cluster or ALB stack, resolved by pipeline)
|
|
routing = {
|
|
existingALB: existingAlbArn!, // ALB ARN (from cluster or ALB stack)
|
|
httpsListenerArn: existingAlbHttpsListenerArn, // HTTPS listener ARN (if certificate provided)
|
|
httpListenerArn: existingAlbHttpListenerArn, // HTTP listener ARN (if no certificate or for redirect)
|
|
hostHeader,
|
|
pathPatterns,
|
|
priority,
|
|
stickiness: app.node.tryGetContext('stickiness') !== 'false',
|
|
stickinessDuration: app.node.tryGetContext('stickinessDuration')
|
|
? parseInt(app.node.tryGetContext('stickinessDuration'))
|
|
: undefined,
|
|
deregistrationDelay: app.node.tryGetContext('deregistrationDelay')
|
|
? parseInt(app.node.tryGetContext('deregistrationDelay'))
|
|
: undefined,
|
|
};
|
|
|
|
// Blue/Green DNS configuration (for bg-common ALB)
|
|
// Note: Both DNS records are created and point to the same ALB.
|
|
// Routing is controlled by listener rule priorities, not DNS changes.
|
|
// The isActive flag is informational - both records are always created.
|
|
const activeHostname = app.node.tryGetContext('activeHostname');
|
|
const inactiveHostname = app.node.tryGetContext('inactiveHostname');
|
|
const bgHostedZoneId = app.node.tryGetContext('bgHostedZoneId') || app.node.tryGetContext('hostedZoneId');
|
|
const isActive = app.node.tryGetContext('isActive') !== 'false'; // default true
|
|
|
|
if (activeHostname && inactiveHostname && bgHostedZoneId) {
|
|
routing.blueGreenDns = {
|
|
activeHostname,
|
|
inactiveHostname,
|
|
hostedZoneId: bgHostedZoneId,
|
|
isActive, // Informational only - both DNS records are always created
|
|
};
|
|
}
|
|
} else if (hostHeader || pathPatterns) {
|
|
// Cluster ALB mode (existing behavior)
|
|
// Validate required parameters for cluster ALB
|
|
// Note: externalListenerArn/internalListenerArn were declared above and may have been imported if clusterStackName was provided
|
|
|
|
if (!externalListenerArn && !internalListenerArn) {
|
|
throw new Error('When using cluster ALB, either externalListenerArn or internalListenerArn must be provided');
|
|
}
|
|
if (externalListenerArn && internalListenerArn) {
|
|
throw new Error(
|
|
'Cannot use both externalListenerArn and internalListenerArn. Choose either external OR internal, not both'
|
|
);
|
|
}
|
|
if (useExternal && useInternal) {
|
|
throw new Error(
|
|
'Cannot use both useExternalALB and useInternalALB. Choose either external OR internal, not both'
|
|
);
|
|
}
|
|
if (useExternal && !externalListenerArn) {
|
|
throw new Error('useExternalALB=true requires externalListenerArn to be provided');
|
|
}
|
|
if (useInternal && !internalListenerArn) {
|
|
throw new Error('useInternalALB=true requires internalListenerArn to be provided');
|
|
}
|
|
// Auto-detect if not explicitly set
|
|
const detectedExternal = externalListenerArn ? true : false;
|
|
const detectedInternal = internalListenerArn ? true : false;
|
|
|
|
// Use explicit flags if set, otherwise auto-detect from listener ARNs
|
|
routing = {
|
|
external: useExternal || (detectedExternal && !useInternal),
|
|
internal: useInternal || (detectedInternal && !useExternal),
|
|
hostHeader,
|
|
pathPatterns,
|
|
priority: priority ?? 100,
|
|
stickiness: app.node.tryGetContext('stickiness') === 'true',
|
|
stickinessDuration: app.node.tryGetContext('stickinessDuration')
|
|
? parseInt(app.node.tryGetContext('stickinessDuration'))
|
|
: undefined,
|
|
deregistrationDelay: app.node.tryGetContext('deregistrationDelay')
|
|
? parseInt(app.node.tryGetContext('deregistrationDelay'))
|
|
: undefined,
|
|
};
|
|
} else {
|
|
// No routing configured - this is valid for services without ALB (e.g., workers, pub/sub)
|
|
// No validation needed - service can run without ALB
|
|
}
|
|
|
|
// DNS (for cluster ALB mode)
|
|
const hostedZoneId = app.node.tryGetContext('hostedZoneId');
|
|
const zoneName = app.node.tryGetContext('zoneName');
|
|
const recordName = app.node.tryGetContext('recordName');
|
|
|
|
const dns: DnsConfig | undefined =
|
|
hostedZoneId && zoneName && recordName
|
|
? {
|
|
hostedZoneId,
|
|
zoneName,
|
|
recordName,
|
|
}
|
|
: undefined;
|
|
|
|
// Deployment
|
|
const circuitBreaker = app.node.tryGetContext('circuitBreaker') !== 'false';
|
|
const enableExecuteCommand = app.node.tryGetContext('enableExecuteCommand') !== 'false';
|
|
|
|
const deployment: DeploymentConfig = {
|
|
circuitBreaker,
|
|
enableExecuteCommand,
|
|
};
|
|
|
|
// Public subnet IDs (for individual ALB)
|
|
const publicSubnetIds = app.node.tryGetContext('publicSubnetIds')?.split(',');
|
|
|
|
// Auto-import logs bucket from cluster stack if not explicitly provided
|
|
// This matches the old pattern: Fn::ImportValue: !Sub "${ClusterName}-logs-s3-bucket"
|
|
// Cluster exports: ${clusterStackName}-logs-s3-bucket
|
|
let clusterLogsBucketName = app.node.tryGetContext('clusterLogsBucketName');
|
|
if (!clusterLogsBucketName && clusterStackName) {
|
|
clusterLogsBucketName = cdk.Fn.importValue(`${clusterStackName}-logs-s3-bucket`).toString();
|
|
}
|
|
// Note: clusterLogsBucketName is optional (only needed for bg-common ALB access logs)
|
|
// We don't fail if it's missing - it's only used if provided
|
|
|
|
return new SpicyEcsServiceStack(scope, id, {
|
|
...stackProps,
|
|
clusterName: clusterStackName,
|
|
vpcId: vpcId.toString(),
|
|
vpcCidrBlock,
|
|
numberOfAzs,
|
|
availabilityZones,
|
|
privateSubnetIds,
|
|
serviceName,
|
|
image,
|
|
containerPort,
|
|
cpu,
|
|
memory,
|
|
environment,
|
|
secrets,
|
|
desiredCount,
|
|
capacityProviderStrategy,
|
|
scaling,
|
|
healthCheckPath,
|
|
routing,
|
|
externalListenerArn,
|
|
internalListenerArn,
|
|
publicSubnetIds,
|
|
clusterLogsBucketName,
|
|
dns,
|
|
deployment,
|
|
serviceTags,
|
|
});
|
|
}
|
|
}
|