Jenkins shared library and CDK constructs for AWS infrastructure. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1026 lines
35 KiB
TypeScript
1026 lines
35 KiB
TypeScript
import * as ec2 from 'aws-cdk-lib/aws-ec2';
|
|
import * as ecs from 'aws-cdk-lib/aws-ecs';
|
|
import * as elbv2 from 'aws-cdk-lib/aws-elasticloadbalancingv2';
|
|
import * as iam from 'aws-cdk-lib/aws-iam';
|
|
import * as logs from 'aws-cdk-lib/aws-logs';
|
|
import * as route53 from 'aws-cdk-lib/aws-route53';
|
|
import * as route53Targets from 'aws-cdk-lib/aws-route53-targets';
|
|
import * as secretsmanager from 'aws-cdk-lib/aws-secretsmanager';
|
|
import * as cdk from 'aws-cdk-lib/core';
|
|
import { Construct } from 'constructs';
|
|
|
|
/**
|
|
* Container definition for the service
|
|
*/
|
|
export interface ContainerConfig {
|
|
/** Docker image URI (e.g., nexus.kodeniks.com/docker-hosted/my-app:latest) */
|
|
image: string;
|
|
|
|
/** Container port */
|
|
port: number;
|
|
|
|
/** CPU units (256 = 0.25 vCPU) */
|
|
cpu?: number;
|
|
|
|
/** Memory in MiB */
|
|
memory?: number;
|
|
|
|
/** Memory reservation (soft limit) in MiB */
|
|
memoryReservation?: number;
|
|
|
|
/** Environment variables */
|
|
environment?: Record<string, string>;
|
|
|
|
/** Secrets from Secrets Manager (key = env var name, value = secret ARN or ARN:key) */
|
|
secrets?: Record<string, string>;
|
|
|
|
/** Health check command */
|
|
healthCheck?: {
|
|
command: string[];
|
|
interval?: number;
|
|
timeout?: number;
|
|
retries?: number;
|
|
startPeriod?: number;
|
|
};
|
|
|
|
/** Container command override */
|
|
command?: string[];
|
|
|
|
/** Entry point override */
|
|
entryPoint?: string[];
|
|
}
|
|
|
|
/**
|
|
* Capacity provider strategy for mixed EC2/Fargate deployments
|
|
*/
|
|
export interface CapacityProviderStrategyItem {
|
|
/** Capacity provider name (e.g., 'FARGATE', 'FARGATE_SPOT', or EC2 provider name) */
|
|
capacityProvider: string;
|
|
|
|
/** Base count - minimum tasks on this provider */
|
|
base?: number;
|
|
|
|
/** Weight for distributing tasks above base */
|
|
weight: number;
|
|
}
|
|
|
|
/**
|
|
* Auto-scaling configuration
|
|
*/
|
|
export interface ServiceScalingConfig {
|
|
/** Minimum number of tasks */
|
|
minCapacity: number;
|
|
|
|
/** Maximum number of tasks */
|
|
maxCapacity: number;
|
|
|
|
/** Target CPU utilization percentage (0-100) */
|
|
targetCpuUtilization?: number;
|
|
|
|
/** Target memory utilization percentage (0-100) */
|
|
targetMemoryUtilization?: number;
|
|
|
|
/** Target requests per task per minute */
|
|
targetRequestsPerTarget?: number;
|
|
|
|
/** Scale-in cooldown in seconds */
|
|
scaleInCooldown?: number;
|
|
|
|
/** Scale-out cooldown in seconds */
|
|
scaleOutCooldown?: number;
|
|
}
|
|
|
|
/**
|
|
* Health check configuration for ALB target group
|
|
*/
|
|
export interface HealthCheckConfig {
|
|
/** Health check path */
|
|
path: string;
|
|
|
|
/** Health check interval in seconds */
|
|
interval?: number;
|
|
|
|
/** Health check timeout in seconds */
|
|
timeout?: number;
|
|
|
|
/** Healthy threshold count */
|
|
healthyThresholdCount?: number;
|
|
|
|
/** Unhealthy threshold count */
|
|
unhealthyThresholdCount?: number;
|
|
|
|
/** HTTP codes to consider healthy */
|
|
healthyHttpCodes?: string;
|
|
}
|
|
|
|
/**
|
|
* Blue/Green DNS configuration
|
|
*
|
|
* Note: Both DNS records are created and point to the same ALB.
|
|
* The isActive flag is informational only - routing is controlled by ALB listener rule priorities.
|
|
*/
|
|
export interface BlueGreenDnsConfig {
|
|
/** Active hostname (e.g., api.example.com) */
|
|
activeHostname: string;
|
|
|
|
/** Inactive hostname (e.g., inactive-api.example.com) */
|
|
inactiveHostname: string;
|
|
|
|
/** Route53 hosted zone ID */
|
|
hostedZoneId: string;
|
|
|
|
/**
|
|
* Whether this is the active service (informational only)
|
|
*
|
|
* Note: This flag doesn't affect DNS record creation - both records are always created.
|
|
* Actual routing is controlled by ALB listener rule priorities:
|
|
* - Active service: Lower priority (e.g., 100) for active hostname
|
|
* - Inactive service: Higher priority (e.g., 200) for inactive hostname
|
|
*/
|
|
isActive?: boolean;
|
|
}
|
|
|
|
/**
|
|
* Load balancer routing configuration
|
|
*/
|
|
export interface LoadBalancerRoutingConfig {
|
|
/**
|
|
* Reference the bg-common ALB (from persistent ALB stack)
|
|
*
|
|
* **Recommended approach**: Deploy ALB separately using SpicyAlbStack (or spicyALB pipeline function), then reference it here.
|
|
* This allows the ALB to persist across service stack deletions, keeping DNS stable.
|
|
*
|
|
* Can be provided as:
|
|
* - ALB ARN string (will be imported)
|
|
* - ALB object (from SpicyAlb construct)
|
|
*
|
|
* If not provided, uses cluster ALB (if externalListenerArn/internalListenerArn are set).
|
|
*/
|
|
existingALB?: elbv2.IApplicationLoadBalancer | string;
|
|
|
|
/** Use cluster's external (internet-facing) ALB */
|
|
external?: boolean;
|
|
|
|
/** Use cluster's internal ALB */
|
|
internal?: boolean;
|
|
|
|
/** Host header for routing (e.g., api.example.com) */
|
|
hostHeader?: string;
|
|
|
|
/** Path patterns for routing (e.g., /api/*) */
|
|
pathPatterns?: string[];
|
|
|
|
/** Listener rule priority (lower = higher priority) */
|
|
priority?: number;
|
|
|
|
/** Enable session stickiness (default: true for individual ALB, false for cluster ALB) */
|
|
stickiness?: boolean;
|
|
|
|
/** Stickiness duration in seconds */
|
|
stickinessDuration?: number;
|
|
|
|
/** Deregistration delay in seconds */
|
|
deregistrationDelay?: number;
|
|
|
|
/** Blue/Green DNS configuration (for individual ALB) */
|
|
blueGreenDns?: BlueGreenDnsConfig;
|
|
|
|
/**
|
|
* HTTPS listener ARN for bg-common ALB (required when using existingALB with certificate)
|
|
* Only one listener type is needed since bg-common ALB is either external OR internal (not both)
|
|
*/
|
|
httpsListenerArn?: string;
|
|
|
|
/**
|
|
* HTTP listener ARN for bg-common ALB (required when using existingALB without certificate)
|
|
* Only one listener type is needed since bg-common ALB is either external OR internal (not both)
|
|
*/
|
|
httpListenerArn?: string;
|
|
}
|
|
|
|
/**
|
|
* Route53 DNS configuration
|
|
*/
|
|
export interface DnsConfig {
|
|
/** Hosted zone ID */
|
|
hostedZoneId: string;
|
|
|
|
/** Hosted zone name (e.g., example.com) */
|
|
zoneName: string;
|
|
|
|
/** Record name (e.g., api for api.example.com) */
|
|
recordName: string;
|
|
}
|
|
|
|
/**
|
|
* Deployment configuration
|
|
*/
|
|
export interface DeploymentConfig {
|
|
/** Minimum healthy percent during deployment */
|
|
minHealthyPercent?: number;
|
|
|
|
/** Maximum percent during deployment */
|
|
maxPercent?: number;
|
|
|
|
/** Enable circuit breaker with rollback */
|
|
circuitBreaker?: boolean;
|
|
|
|
/** Enable ECS Exec for debugging */
|
|
enableExecuteCommand?: boolean;
|
|
}
|
|
|
|
/**
|
|
* Tags for the service
|
|
*/
|
|
export interface SpicyEcsServiceTags {
|
|
owner: string;
|
|
product: string;
|
|
component: string;
|
|
environment: string;
|
|
build?: string;
|
|
}
|
|
|
|
/**
|
|
* Properties for SpicyEcsService construct
|
|
*/
|
|
export interface SpicyEcsServiceProps {
|
|
/** ECS Cluster to deploy to */
|
|
readonly cluster: ecs.ICluster;
|
|
|
|
/** VPC for networking */
|
|
readonly vpc: ec2.IVpc;
|
|
|
|
/** Service name */
|
|
readonly serviceName: string;
|
|
|
|
/** Container configuration */
|
|
readonly container: ContainerConfig;
|
|
|
|
/**
|
|
* Capacity provider strategy for mixed deployments
|
|
* If not specified, uses cluster default
|
|
*/
|
|
readonly capacityProviderStrategy?: CapacityProviderStrategyItem[];
|
|
|
|
/**
|
|
* Desired number of tasks
|
|
* @default 2
|
|
*/
|
|
readonly desiredCount?: number;
|
|
|
|
/** Auto-scaling configuration */
|
|
readonly scaling?: ServiceScalingConfig;
|
|
|
|
/** Health check configuration */
|
|
readonly healthCheck?: HealthCheckConfig;
|
|
|
|
/** Load balancer routing */
|
|
readonly routing?: LoadBalancerRoutingConfig;
|
|
|
|
/** External ALB listener ARN (from cluster) */
|
|
readonly externalListenerArn?: string;
|
|
|
|
/** Internal ALB listener ARN (from cluster) */
|
|
readonly internalListenerArn?: string;
|
|
|
|
/** External ALB (for target group VPC) */
|
|
readonly externalLoadBalancer?: elbv2.IApplicationLoadBalancer;
|
|
|
|
/** Internal ALB (for target group VPC) */
|
|
readonly internalLoadBalancer?: elbv2.IApplicationLoadBalancer;
|
|
|
|
/** Route53 DNS configuration */
|
|
readonly dns?: DnsConfig;
|
|
|
|
/** Deployment configuration */
|
|
readonly deployment?: DeploymentConfig;
|
|
|
|
/** Subnets for tasks (for awsvpc network mode) */
|
|
readonly taskSubnets?: ec2.SubnetSelection;
|
|
|
|
/** Security groups for tasks */
|
|
readonly securityGroups?: ec2.ISecurityGroup[];
|
|
|
|
/** Additional IAM policies for task role */
|
|
readonly taskRolePolicies?: iam.PolicyStatement[];
|
|
|
|
/** Required tags */
|
|
readonly tags: SpicyEcsServiceTags;
|
|
}
|
|
|
|
/**
|
|
* SpicyEcsService - ECS Service with:
|
|
* - Mixed capacity provider strategy (EC2 + Fargate burst)
|
|
* - Auto-scaling on CPU/Memory/Requests
|
|
* - ALB integration with host/path routing
|
|
* - Optional Route53 DNS
|
|
* - Deployment circuit breaker
|
|
* - ECS Exec support
|
|
*/
|
|
export class SpicyEcsService extends Construct {
|
|
/** The ECS service */
|
|
public readonly service: ecs.BaseService;
|
|
|
|
/** The task definition */
|
|
public readonly taskDefinition: ecs.TaskDefinition;
|
|
|
|
/** The target group */
|
|
public targetGroup?: elbv2.ApplicationTargetGroup;
|
|
|
|
/** ALB reference (bg-common ALB when using existingALB) */
|
|
public alb?: elbv2.IApplicationLoadBalancer;
|
|
|
|
/** ALB HTTP listener (when using bg-common ALB) */
|
|
public httpListener?: elbv2.ApplicationListener;
|
|
|
|
/** ALB HTTPS listener (when using bg-common ALB) */
|
|
public httpsListener?: elbv2.ApplicationListener;
|
|
|
|
/** CloudWatch log group */
|
|
public readonly logGroup: logs.LogGroup;
|
|
|
|
/** Task role */
|
|
public readonly taskRole: iam.Role;
|
|
|
|
/** Task execution role */
|
|
public readonly executionRole: iam.Role;
|
|
|
|
constructor(scope: Construct, id: string, props: SpicyEcsServiceProps) {
|
|
super(scope, id);
|
|
|
|
const stack = cdk.Stack.of(this);
|
|
|
|
// Defaults
|
|
const desiredCount = props.desiredCount ?? 2;
|
|
const container = props.container;
|
|
const cpu = container.cpu ?? 256;
|
|
const memory = container.memory ?? 512;
|
|
const memoryReservation = container.memoryReservation ?? memory;
|
|
|
|
const deployment = {
|
|
minHealthyPercent: props.deployment?.minHealthyPercent ?? 50,
|
|
maxPercent: props.deployment?.maxPercent ?? 200,
|
|
circuitBreaker: props.deployment?.circuitBreaker ?? true,
|
|
enableExecuteCommand: props.deployment?.enableExecuteCommand ?? true,
|
|
};
|
|
|
|
const healthCheck = {
|
|
path: props.healthCheck?.path ?? '/health',
|
|
interval: props.healthCheck?.interval ?? 15,
|
|
timeout: props.healthCheck?.timeout ?? 14,
|
|
healthyThresholdCount: props.healthCheck?.healthyThresholdCount ?? 2,
|
|
unhealthyThresholdCount: props.healthCheck?.unhealthyThresholdCount ?? 7,
|
|
healthyHttpCodes: props.healthCheck?.healthyHttpCodes ?? '200-299',
|
|
};
|
|
|
|
// Determine if we're using Fargate (for network mode)
|
|
const usesFargate = Boolean(
|
|
props.capacityProviderStrategy?.some(
|
|
(s) => s.capacityProvider === 'FARGATE' || s.capacityProvider === 'FARGATE_SPOT'
|
|
)
|
|
);
|
|
|
|
// CloudWatch Log Group
|
|
this.logGroup = new logs.LogGroup(this, 'LogGroup', {
|
|
logGroupName: `/ecs/${props.serviceName}`,
|
|
retention: logs.RetentionDays.ONE_MONTH,
|
|
removalPolicy: cdk.RemovalPolicy.DESTROY,
|
|
});
|
|
(this.logGroup.node.defaultChild as logs.CfnLogGroup).overrideLogicalId('LogGroup');
|
|
|
|
// Task Execution Role (for pulling images, logging)
|
|
this.executionRole = new iam.Role(this, 'ExecutionRole', {
|
|
assumedBy: new iam.ServicePrincipal('ecs-tasks.amazonaws.com'),
|
|
managedPolicies: [iam.ManagedPolicy.fromAwsManagedPolicyName('service-role/AmazonECSTaskExecutionRolePolicy')],
|
|
});
|
|
(this.executionRole.node.defaultChild as iam.CfnRole).overrideLogicalId('ExecutionRole');
|
|
|
|
// Allow pulling from Nexus (private registry)
|
|
this.executionRole.addToPolicy(
|
|
new iam.PolicyStatement({
|
|
actions: ['ecr:GetAuthorizationToken'],
|
|
resources: ['*'],
|
|
})
|
|
);
|
|
|
|
// Task Role (for application permissions)
|
|
this.taskRole = new iam.Role(this, 'TaskRole', {
|
|
assumedBy: new iam.ServicePrincipal('ecs-tasks.amazonaws.com'),
|
|
});
|
|
(this.taskRole.node.defaultChild as iam.CfnRole).overrideLogicalId('TaskRole');
|
|
|
|
// Add custom policies
|
|
if (props.taskRolePolicies) {
|
|
for (const policy of props.taskRolePolicies) {
|
|
this.taskRole.addToPolicy(policy);
|
|
}
|
|
}
|
|
|
|
// ECS Exec permissions
|
|
if (deployment.enableExecuteCommand) {
|
|
this.taskRole.addToPolicy(
|
|
new iam.PolicyStatement({
|
|
actions: [
|
|
'ssmmessages:CreateControlChannel',
|
|
'ssmmessages:CreateDataChannel',
|
|
'ssmmessages:OpenControlChannel',
|
|
'ssmmessages:OpenDataChannel',
|
|
],
|
|
resources: ['*'],
|
|
})
|
|
);
|
|
}
|
|
|
|
// Task Definition
|
|
// Use EC2 compatibility for mixed strategies, Fargate-only if no EC2
|
|
const compatibility = usesFargate ? ecs.Compatibility.EC2_AND_FARGATE : ecs.Compatibility.EC2;
|
|
|
|
this.taskDefinition = new ecs.TaskDefinition(this, 'TaskDefinition', {
|
|
compatibility,
|
|
networkMode: usesFargate ? ecs.NetworkMode.AWS_VPC : ecs.NetworkMode.BRIDGE,
|
|
cpu: usesFargate ? String(cpu) : undefined,
|
|
memoryMiB: usesFargate ? String(memory) : undefined,
|
|
executionRole: this.executionRole,
|
|
taskRole: this.taskRole,
|
|
family: props.serviceName,
|
|
});
|
|
(this.taskDefinition.node.defaultChild as ecs.CfnTaskDefinition).overrideLogicalId('TaskDefinition');
|
|
|
|
// Build secrets
|
|
const secrets: Record<string, ecs.Secret> = {};
|
|
if (container.secrets) {
|
|
for (const [envName, secretArn] of Object.entries(container.secrets)) {
|
|
// Handle ARN:key format
|
|
const [arn, key] =
|
|
secretArn.includes(':') && !secretArn.startsWith('arn:') ? [secretArn, undefined] : secretArn.split('::');
|
|
|
|
const secret = secretsmanager.Secret.fromSecretCompleteArn(this, `Secret-${envName}`, arn);
|
|
secrets[envName] = key ? ecs.Secret.fromSecretsManager(secret, key) : ecs.Secret.fromSecretsManager(secret);
|
|
|
|
// Grant read access
|
|
secret.grantRead(this.executionRole);
|
|
}
|
|
}
|
|
|
|
// Container Definition
|
|
const containerDef = this.taskDefinition.addContainer('Container', {
|
|
containerName: props.serviceName,
|
|
image: ecs.ContainerImage.fromRegistry(container.image),
|
|
cpu: usesFargate ? undefined : cpu,
|
|
memoryLimitMiB: usesFargate ? undefined : memory,
|
|
memoryReservationMiB: usesFargate ? undefined : memoryReservation,
|
|
environment: container.environment,
|
|
secrets,
|
|
logging: ecs.LogDriver.awsLogs({
|
|
logGroup: this.logGroup,
|
|
streamPrefix: props.serviceName,
|
|
}),
|
|
command: container.command,
|
|
entryPoint: container.entryPoint,
|
|
healthCheck: container.healthCheck
|
|
? {
|
|
command: container.healthCheck.command,
|
|
interval: cdk.Duration.seconds(container.healthCheck.interval ?? 30),
|
|
timeout: cdk.Duration.seconds(container.healthCheck.timeout ?? 5),
|
|
retries: container.healthCheck.retries ?? 3,
|
|
startPeriod: cdk.Duration.seconds(container.healthCheck.startPeriod ?? 60),
|
|
}
|
|
: undefined,
|
|
essential: true,
|
|
});
|
|
|
|
// Port mapping
|
|
containerDef.addPortMappings({
|
|
containerPort: container.port,
|
|
protocol: ecs.Protocol.TCP,
|
|
});
|
|
|
|
// Security Group for tasks (awsvpc mode)
|
|
let taskSecurityGroup: ec2.SecurityGroup | undefined;
|
|
if (usesFargate) {
|
|
taskSecurityGroup = new ec2.SecurityGroup(this, 'TaskSecurityGroup', {
|
|
vpc: props.vpc,
|
|
description: `Security group for ${props.serviceName} tasks`,
|
|
allowAllOutbound: true,
|
|
});
|
|
(taskSecurityGroup.node.defaultChild as ec2.CfnSecurityGroup).overrideLogicalId('TaskSecurityGroup');
|
|
}
|
|
|
|
// Determine if we're using bg-common ALB or cluster ALB
|
|
// bg-common stack is always created, so it's either:
|
|
// 1. Use bg-common ALB (via existingALB)
|
|
// 2. Use cluster ALB
|
|
const existingALB = props.routing?.existingALB;
|
|
|
|
// Use bg-common ALB if provided (persistent ALB from separate stack)
|
|
if (existingALB) {
|
|
// Validate required listener ARN for bg-common ALB
|
|
// bg-common ALB has only one scheme (either external OR internal, not both)
|
|
// But it can have both HTTP and HTTPS listeners (like the old template)
|
|
const httpsListenerArn = props.routing?.httpsListenerArn;
|
|
const httpListenerArn = props.routing?.httpListenerArn;
|
|
|
|
if (!httpsListenerArn && !httpListenerArn) {
|
|
throw new Error(
|
|
'When using bg-common ALB (existingALB), either httpsListenerArn or httpListenerArn must be provided in routing config'
|
|
);
|
|
}
|
|
|
|
// Import ALB if ARN string provided, otherwise use object directly
|
|
const alb =
|
|
typeof existingALB === 'string'
|
|
? elbv2.ApplicationLoadBalancer.fromApplicationLoadBalancerAttributes(this, 'ExistingALB', {
|
|
loadBalancerArn: existingALB,
|
|
loadBalancerDnsName: '', // Not needed for routing
|
|
securityGroupId: 'sg-placeholder', // Placeholder - not used for routing
|
|
})
|
|
: existingALB;
|
|
this.useExistingALB(props, alb, healthCheck, usesFargate, container.port, taskSecurityGroup);
|
|
} else if (props.routing && (props.externalListenerArn || props.internalListenerArn)) {
|
|
// Use cluster ALB
|
|
// Validate that only one listener is used (external OR internal, not both)
|
|
if (props.externalListenerArn && props.internalListenerArn) {
|
|
throw new Error(
|
|
'Cannot use both externalListenerArn and internalListenerArn. Choose either external OR internal, not both'
|
|
);
|
|
}
|
|
if (props.routing.external && props.routing.internal) {
|
|
throw new Error(
|
|
'Cannot use both routing.external and routing.internal. Choose either external OR internal, not both'
|
|
);
|
|
}
|
|
// Validate that routing config matches listener ARNs
|
|
if (props.routing.external && !props.externalListenerArn) {
|
|
throw new Error('routing.external=true requires externalListenerArn to be provided');
|
|
}
|
|
if (props.routing.internal && !props.internalListenerArn) {
|
|
throw new Error('routing.internal=true requires internalListenerArn to be provided');
|
|
}
|
|
|
|
this.createClusterALBRouting(props, healthCheck, usesFargate, container.port, taskSecurityGroup);
|
|
} else if (props.routing) {
|
|
// Routing config provided but no ALB/listener ARNs - this is an error
|
|
throw new Error(
|
|
'Routing configuration provided but no ALB found. Either provide existingALB (for bg-common ALB) or externalListenerArn/internalListenerArn (for cluster ALB)'
|
|
);
|
|
}
|
|
// If no routing config, service runs without ALB (valid for workers, pub/sub, etc.)
|
|
|
|
// Allow traffic from ALB to tasks
|
|
if (taskSecurityGroup) {
|
|
if (existingALB) {
|
|
// Allow from bg-common ALB
|
|
taskSecurityGroup.addIngressRule(
|
|
ec2.Peer.ipv4(props.vpc.vpcCidrBlock),
|
|
ec2.Port.tcp(container.port),
|
|
'Allow traffic from bg-common ALB'
|
|
);
|
|
} else if (props.externalLoadBalancer || props.internalLoadBalancer) {
|
|
// Allow from cluster ALB
|
|
taskSecurityGroup.addIngressRule(
|
|
ec2.Peer.ipv4(props.vpc.vpcCidrBlock),
|
|
ec2.Port.tcp(container.port),
|
|
'Allow traffic from VPC (ALB)'
|
|
);
|
|
}
|
|
}
|
|
|
|
// Create Service
|
|
const serviceProps: ecs.Ec2ServiceProps | ecs.FargateServiceProps = {
|
|
cluster: props.cluster,
|
|
taskDefinition: this.taskDefinition,
|
|
desiredCount,
|
|
serviceName: props.serviceName,
|
|
minHealthyPercent: deployment.minHealthyPercent,
|
|
maxHealthyPercent: deployment.maxPercent,
|
|
circuitBreaker: deployment.circuitBreaker ? { rollback: true } : undefined,
|
|
enableExecuteCommand: deployment.enableExecuteCommand,
|
|
capacityProviderStrategies: props.capacityProviderStrategy?.map((s) => ({
|
|
capacityProvider: s.capacityProvider,
|
|
base: s.base,
|
|
weight: s.weight,
|
|
})),
|
|
};
|
|
|
|
// Create appropriate service type
|
|
if (usesFargate) {
|
|
this.service = new ecs.FargateService(this, 'Service', {
|
|
...serviceProps,
|
|
assignPublicIp: false,
|
|
vpcSubnets: props.taskSubnets ?? { subnetType: ec2.SubnetType.PRIVATE_WITH_EGRESS },
|
|
securityGroups: taskSecurityGroup ? [taskSecurityGroup, ...(props.securityGroups ?? [])] : props.securityGroups,
|
|
platformVersion: ecs.FargatePlatformVersion.LATEST,
|
|
} as ecs.FargateServiceProps);
|
|
} else {
|
|
this.service = new ecs.Ec2Service(this, 'Service', {
|
|
...serviceProps,
|
|
placementStrategies: [
|
|
ecs.PlacementStrategy.spreadAcrossInstances(),
|
|
ecs.PlacementStrategy.spreadAcross(ecs.BuiltInAttributes.AVAILABILITY_ZONE),
|
|
],
|
|
} as ecs.Ec2ServiceProps);
|
|
}
|
|
(this.service.node.defaultChild as ecs.CfnService).overrideLogicalId('Service');
|
|
|
|
// Attach to target group
|
|
if (this.targetGroup) {
|
|
this.service.attachToApplicationTargetGroup(this.targetGroup);
|
|
}
|
|
|
|
// Auto-scaling
|
|
if (props.scaling) {
|
|
this.configureAutoScaling(props.scaling);
|
|
}
|
|
|
|
// Route53 DNS (for cluster ALB mode only - bg-common ALB handles DNS in useExistingALB)
|
|
// Note: For bg-common ALB with blue/green, DNS is configured in useExistingALB() via blueGreenDns
|
|
if (props.dns && !existingALB && (props.externalLoadBalancer || props.internalLoadBalancer)) {
|
|
this.configureDns(props.dns, props.externalLoadBalancer ?? props.internalLoadBalancer!);
|
|
}
|
|
|
|
// Apply tags
|
|
this.applyTags(props.tags);
|
|
|
|
// Outputs
|
|
this.addOutputs(props);
|
|
}
|
|
|
|
/**
|
|
* Use existing ALB (from persistent ALB stack)
|
|
*/
|
|
private useExistingALB(
|
|
props: SpicyEcsServiceProps,
|
|
existingALB: elbv2.IApplicationLoadBalancer,
|
|
healthCheck: {
|
|
path: string;
|
|
interval: number;
|
|
timeout: number;
|
|
healthyThresholdCount: number;
|
|
unhealthyThresholdCount: number;
|
|
healthyHttpCodes: string;
|
|
},
|
|
usesFargate: boolean,
|
|
containerPort: number,
|
|
taskSecurityGroup: ec2.SecurityGroup | undefined
|
|
): void {
|
|
const routing = props.routing!;
|
|
|
|
// Store reference to existing ALB
|
|
this.alb = existingALB;
|
|
|
|
// Create Target Group
|
|
// Default stickiness to true for individual ALB (matches legacy)
|
|
const stickinessEnabled = routing.stickiness ?? true;
|
|
this.targetGroup = new elbv2.ApplicationTargetGroup(this, 'TargetGroup', {
|
|
vpc: props.vpc,
|
|
port: containerPort,
|
|
protocol: elbv2.ApplicationProtocol.HTTP,
|
|
targetType: usesFargate ? elbv2.TargetType.IP : elbv2.TargetType.INSTANCE,
|
|
healthCheck: {
|
|
path: healthCheck.path,
|
|
interval: cdk.Duration.seconds(healthCheck.interval),
|
|
timeout: cdk.Duration.seconds(healthCheck.timeout),
|
|
healthyThresholdCount: healthCheck.healthyThresholdCount,
|
|
unhealthyThresholdCount: healthCheck.unhealthyThresholdCount,
|
|
healthyHttpCodes: healthCheck.healthyHttpCodes,
|
|
},
|
|
deregistrationDelay: cdk.Duration.seconds(routing.deregistrationDelay ?? 60),
|
|
stickinessCookieDuration: stickinessEnabled
|
|
? cdk.Duration.seconds(routing.stickinessDuration ?? 86400)
|
|
: undefined,
|
|
});
|
|
(this.targetGroup.node.defaultChild as elbv2.CfnTargetGroup).overrideLogicalId('TargetGroup');
|
|
|
|
// Get listeners from existing ALB
|
|
// bg-common ALB can have both HTTP and HTTPS listeners (like the old template)
|
|
const httpsListenerArn = routing.httpsListenerArn;
|
|
const httpListenerArn = routing.httpListenerArn;
|
|
|
|
if (!httpsListenerArn && !httpListenerArn) {
|
|
throw new Error(
|
|
'When using existingALB, either httpsListenerArn or httpListenerArn must be provided in routing config'
|
|
);
|
|
}
|
|
|
|
// Build conditions for listener rule
|
|
const conditions: elbv2.ListenerCondition[] = [];
|
|
if (routing.hostHeader) {
|
|
conditions.push(elbv2.ListenerCondition.hostHeaders([routing.hostHeader]));
|
|
}
|
|
if (routing.pathPatterns && routing.pathPatterns.length > 0) {
|
|
conditions.push(elbv2.ListenerCondition.pathPatterns(routing.pathPatterns));
|
|
}
|
|
if (conditions.length === 0) {
|
|
conditions.push(elbv2.ListenerCondition.pathPatterns(['/*']));
|
|
}
|
|
|
|
// Create listener references and add rules (like the old template - rules on both HTTP and HTTPS)
|
|
const placeholderSG = ec2.SecurityGroup.fromSecurityGroupId(this, 'ExistingListenerSG', 'sg-placeholder', {
|
|
allowAllOutbound: true,
|
|
});
|
|
|
|
// HTTPS listener rule (if HTTPS listener provided)
|
|
if (httpsListenerArn) {
|
|
const httpsListener = elbv2.ApplicationListener.fromApplicationListenerAttributes(this, 'ExistingHTTPSListener', {
|
|
listenerArn: httpsListenerArn,
|
|
securityGroup: placeholderSG,
|
|
});
|
|
|
|
new elbv2.ApplicationListenerRule(this, 'HTTPSListenerRule', {
|
|
listener: httpsListener,
|
|
priority: routing.priority ?? 100,
|
|
conditions,
|
|
targetGroups: [this.targetGroup],
|
|
});
|
|
}
|
|
|
|
// HTTP listener rule (if HTTP listener provided)
|
|
if (httpListenerArn) {
|
|
const httpListener = elbv2.ApplicationListener.fromApplicationListenerAttributes(this, 'ExistingHTTPListener', {
|
|
listenerArn: httpListenerArn,
|
|
securityGroup: placeholderSG,
|
|
});
|
|
|
|
new elbv2.ApplicationListenerRule(this, 'HTTPListenerRule', {
|
|
listener: httpListener,
|
|
priority: routing.priority ?? 100,
|
|
conditions,
|
|
targetGroups: [this.targetGroup],
|
|
});
|
|
}
|
|
|
|
// Configure Blue/Green DNS if provided (for bg-common ALB)
|
|
// Note: Both active and inactive DNS records are created and point to the same ALB.
|
|
// Routing is controlled by ALB listener rule priorities, not DNS changes.
|
|
// The isActive flag in BlueGreenDnsConfig is informational - both records are always created.
|
|
// Actual routing is determined by which service stack has the lower priority listener rule.
|
|
//
|
|
// Testing: To verify blue/green DNS:
|
|
// 1. Check both DNS records exist: `dig api.example.com` and `dig inactive-api.example.com`
|
|
// 2. Both should resolve to the same ALB DNS name
|
|
// 3. Verify listener rules: Active service should have priority 100, inactive should have priority 200
|
|
// 4. Test routing: curl -H "Host: api.example.com" <ALB_DNS> should hit active service
|
|
// 5. Test routing: curl -H "Host: inactive-api.example.com" <ALB_DNS> should hit inactive service
|
|
if (routing.blueGreenDns) {
|
|
this.configureBlueGreenDns(routing.blueGreenDns, existingALB);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Create cluster ALB routing (existing behavior)
|
|
*/
|
|
private createClusterALBRouting(
|
|
props: SpicyEcsServiceProps,
|
|
healthCheck: {
|
|
path: string;
|
|
interval: number;
|
|
timeout: number;
|
|
healthyThresholdCount: number;
|
|
unhealthyThresholdCount: number;
|
|
healthyHttpCodes: string;
|
|
},
|
|
usesFargate: boolean,
|
|
containerPort: number,
|
|
taskSecurityGroup: ec2.SecurityGroup | undefined
|
|
): void {
|
|
const routing = props.routing!;
|
|
|
|
// Create Target Group
|
|
// Default stickiness to false for cluster ALB (existing behavior)
|
|
const stickinessEnabled = routing.stickiness ?? false;
|
|
this.targetGroup = new elbv2.ApplicationTargetGroup(this, 'TargetGroup', {
|
|
vpc: props.vpc,
|
|
port: containerPort,
|
|
protocol: elbv2.ApplicationProtocol.HTTP,
|
|
targetType: usesFargate ? elbv2.TargetType.IP : elbv2.TargetType.INSTANCE,
|
|
healthCheck: {
|
|
path: healthCheck.path,
|
|
interval: cdk.Duration.seconds(healthCheck.interval),
|
|
timeout: cdk.Duration.seconds(healthCheck.timeout),
|
|
healthyThresholdCount: healthCheck.healthyThresholdCount,
|
|
unhealthyThresholdCount: healthCheck.unhealthyThresholdCount,
|
|
healthyHttpCodes: healthCheck.healthyHttpCodes,
|
|
},
|
|
deregistrationDelay: cdk.Duration.seconds(routing.deregistrationDelay ?? 60),
|
|
stickinessCookieDuration: stickinessEnabled
|
|
? cdk.Duration.seconds(routing.stickinessDuration ?? 86400)
|
|
: undefined,
|
|
});
|
|
(this.targetGroup.node.defaultChild as elbv2.CfnTargetGroup).overrideLogicalId('TargetGroup');
|
|
|
|
// Add listener rules
|
|
if (props.externalListenerArn && routing.external) {
|
|
this.addListenerRule('External', props.externalListenerArn, routing);
|
|
}
|
|
|
|
if (props.internalListenerArn && routing.internal) {
|
|
this.addListenerRule('Internal', props.internalListenerArn, routing);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Configure Blue/Green DNS (active and inactive hostnames)
|
|
*
|
|
* IMPORTANT: Both DNS records are created and point to the SAME ALB.
|
|
* Traffic routing is controlled by ALB listener rule priorities, NOT by DNS changes.
|
|
*
|
|
* How it works:
|
|
* 1. Both active and inactive hostnames resolve to the same ALB DNS name
|
|
* 2. ALB listener rules use hostname conditions to route traffic
|
|
* 3. The active service has a lower priority rule (e.g., 100) for the active hostname
|
|
* 4. The inactive service has a higher priority rule (e.g., 200) for the inactive hostname
|
|
* 5. When swapping, only the listener rule priorities are updated (via CDK deployment)
|
|
* 6. DNS records never change - they always point to the same ALB
|
|
*
|
|
* The isActive flag in BlueGreenDnsConfig is informational only - both records are always created.
|
|
* The actual routing is determined by which service stack has the lower priority listener rule.
|
|
*/
|
|
private configureBlueGreenDns(dns: BlueGreenDnsConfig, loadBalancer: elbv2.IApplicationLoadBalancer): void {
|
|
// Extract domain from hostname (e.g., "api.example.com" -> "example.com")
|
|
const domainParts = dns.activeHostname.split('.');
|
|
const zoneName = domainParts.slice(-2).join('.');
|
|
const activeRecordName = domainParts.slice(0, -2).join('.') || '@';
|
|
const inactiveRecordName = dns.inactiveHostname.split('.').slice(0, -2).join('.') || '@';
|
|
|
|
const hostedZone = route53.HostedZone.fromHostedZoneAttributes(this, 'HostedZone', {
|
|
hostedZoneId: dns.hostedZoneId,
|
|
zoneName: zoneName,
|
|
});
|
|
|
|
// Active hostname DNS record - always points to the ALB
|
|
// Note: This record is created by both blue and green stacks, but routing is via listener priorities
|
|
new route53.ARecord(this, 'ActiveDnsRecord', {
|
|
zone: hostedZone,
|
|
recordName: activeRecordName === '@' ? undefined : activeRecordName,
|
|
target: route53.RecordTarget.fromAlias(new route53Targets.LoadBalancerTarget(loadBalancer)),
|
|
});
|
|
|
|
// Inactive hostname DNS record - always points to the ALB
|
|
// Note: This record is created by both blue and green stacks, but routing is via listener priorities
|
|
new route53.ARecord(this, 'InactiveDnsRecord', {
|
|
zone: hostedZone,
|
|
recordName: inactiveRecordName === '@' ? undefined : inactiveRecordName,
|
|
target: route53.RecordTarget.fromAlias(new route53Targets.LoadBalancerTarget(loadBalancer)),
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Add listener rule for ALB routing
|
|
*/
|
|
private addListenerRule(name: string, listenerArn: string, routing: LoadBalancerRoutingConfig): void {
|
|
const conditions: elbv2.ListenerCondition[] = [];
|
|
|
|
if (routing.hostHeader) {
|
|
conditions.push(elbv2.ListenerCondition.hostHeaders([routing.hostHeader]));
|
|
}
|
|
|
|
if (routing.pathPatterns && routing.pathPatterns.length > 0) {
|
|
conditions.push(elbv2.ListenerCondition.pathPatterns(routing.pathPatterns));
|
|
}
|
|
|
|
// If no conditions, use a catch-all path
|
|
if (conditions.length === 0) {
|
|
conditions.push(elbv2.ListenerCondition.pathPatterns(['/*']));
|
|
}
|
|
|
|
const placeholderSG = ec2.SecurityGroup.fromSecurityGroupId(this, `${name}ListenerSG`, 'sg-placeholder', {
|
|
allowAllOutbound: true,
|
|
});
|
|
const listener = elbv2.ApplicationListener.fromApplicationListenerAttributes(this, `${name}Listener`, {
|
|
listenerArn,
|
|
securityGroup: placeholderSG,
|
|
});
|
|
|
|
new elbv2.ApplicationListenerRule(this, `${name}ListenerRule`, {
|
|
listener,
|
|
priority: routing.priority ?? 100,
|
|
conditions,
|
|
targetGroups: [this.targetGroup!],
|
|
});
|
|
|
|
// HTTP redirect rule (if external)
|
|
if (name === 'External' && routing.hostHeader) {
|
|
// Note: HTTP redirect should be handled at the ALB level, not per-service
|
|
// The cluster ALB already has a default HTTP→HTTPS redirect
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Configure auto-scaling
|
|
*/
|
|
private configureAutoScaling(scaling: ServiceScalingConfig): void {
|
|
const scalableTarget = this.service.autoScaleTaskCount({
|
|
minCapacity: scaling.minCapacity,
|
|
maxCapacity: scaling.maxCapacity,
|
|
});
|
|
|
|
const cooldown = {
|
|
scaleInCooldown: cdk.Duration.seconds(scaling.scaleInCooldown ?? 300),
|
|
scaleOutCooldown: cdk.Duration.seconds(scaling.scaleOutCooldown ?? 60),
|
|
};
|
|
|
|
// CPU utilization scaling
|
|
if (scaling.targetCpuUtilization) {
|
|
scalableTarget.scaleOnCpuUtilization('CpuScaling', {
|
|
targetUtilizationPercent: scaling.targetCpuUtilization,
|
|
...cooldown,
|
|
});
|
|
}
|
|
|
|
// Memory utilization scaling
|
|
if (scaling.targetMemoryUtilization) {
|
|
scalableTarget.scaleOnMemoryUtilization('MemoryScaling', {
|
|
targetUtilizationPercent: scaling.targetMemoryUtilization,
|
|
...cooldown,
|
|
});
|
|
}
|
|
|
|
// Request count scaling (requires target group)
|
|
if (scaling.targetRequestsPerTarget && this.targetGroup) {
|
|
scalableTarget.scaleOnRequestCount('RequestScaling', {
|
|
requestsPerTarget: scaling.targetRequestsPerTarget,
|
|
targetGroup: this.targetGroup,
|
|
...cooldown,
|
|
});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Configure Route53 DNS
|
|
*/
|
|
private configureDns(dns: DnsConfig, loadBalancer: elbv2.IApplicationLoadBalancer): void {
|
|
const hostedZone = route53.HostedZone.fromHostedZoneAttributes(this, 'HostedZone', {
|
|
hostedZoneId: dns.hostedZoneId,
|
|
zoneName: dns.zoneName,
|
|
});
|
|
|
|
new route53.ARecord(this, 'DnsRecord', {
|
|
zone: hostedZone,
|
|
recordName: dns.recordName,
|
|
target: route53.RecordTarget.fromAlias(new route53Targets.LoadBalancerTarget(loadBalancer)),
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Apply tags
|
|
*/
|
|
private applyTags(tags: SpicyEcsServiceTags): void {
|
|
const stack = cdk.Stack.of(this);
|
|
|
|
cdk.Tags.of(this).add('Name', stack.stackName);
|
|
cdk.Tags.of(this).add('Owner', tags.owner);
|
|
cdk.Tags.of(this).add('Product', tags.product);
|
|
cdk.Tags.of(this).add('Component', tags.component);
|
|
cdk.Tags.of(this).add('Environment', tags.environment);
|
|
if (tags.build) {
|
|
cdk.Tags.of(this).add('Build', tags.build);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Add CloudFormation outputs
|
|
*/
|
|
private addOutputs(props: SpicyEcsServiceProps): void {
|
|
const stack = cdk.Stack.of(this);
|
|
|
|
const serviceNameOutput = new cdk.CfnOutput(this, 'ServiceNameOutput', {
|
|
value: props.serviceName,
|
|
description: 'ECS Service Name',
|
|
exportName: `${stack.stackName}-service-name`,
|
|
});
|
|
serviceNameOutput.overrideLogicalId('ServiceName');
|
|
|
|
const serviceArnOutput = new cdk.CfnOutput(this, 'ServiceArnOutput', {
|
|
value: this.service.serviceArn,
|
|
description: 'ECS Service ARN',
|
|
exportName: `${stack.stackName}-service-arn`,
|
|
});
|
|
serviceArnOutput.overrideLogicalId('ServiceArn');
|
|
|
|
const taskDefOutput = new cdk.CfnOutput(this, 'TaskDefinitionArnOutput', {
|
|
value: this.taskDefinition.taskDefinitionArn,
|
|
description: 'Task Definition ARN',
|
|
exportName: `${stack.stackName}-task-definition-arn`,
|
|
});
|
|
taskDefOutput.overrideLogicalId('TaskDefinitionArn');
|
|
|
|
const logGroupOutput = new cdk.CfnOutput(this, 'LogGroupNameOutput', {
|
|
value: this.logGroup.logGroupName,
|
|
description: 'CloudWatch Log Group',
|
|
exportName: `${stack.stackName}-log-group`,
|
|
});
|
|
logGroupOutput.overrideLogicalId('LogGroupName');
|
|
|
|
if (this.targetGroup) {
|
|
const targetGroupOutput = new cdk.CfnOutput(this, 'TargetGroupArnOutput', {
|
|
value: this.targetGroup.targetGroupArn,
|
|
description: 'Target Group ARN',
|
|
exportName: `${stack.stackName}-target-group-arn`,
|
|
});
|
|
targetGroupOutput.overrideLogicalId('TargetGroupArn');
|
|
}
|
|
|
|
// Note: When using existing ALB (bg-common pattern), outputs come from ALB stack, not service stack
|
|
}
|
|
}
|