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(); const numberOfAzsRaw = app.node.tryGetContext('numberOfAzs'); const numberOfAzs = numberOfAzsRaw ? parseInt(numberOfAzsRaw, 10) : NaN; if (!numberOfAzs || Number.isNaN(numberOfAzs)) { throw new Error('numberOfAzs is required in context (2-4) to import VPC subnets.'); } 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, }); } }