Initial commit: Spicy CDK automation framework

Jenkins shared library and CDK constructs for AWS infrastructure.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2025-11-18 22:21:00 -08:00
commit 68684df471
51 changed files with 15587 additions and 0 deletions

892
vars/spicyECSService.groovy Normal file
View File

@@ -0,0 +1,892 @@
/**
* Spicy ECS Service Pipeline
*
* Deploys an ECS service using AWS CDK with:
* - Mixed capacity provider strategy (EC2 + Fargate burst)
* - Auto-scaling on CPU/Memory/Requests
* - ALB integration with host/path routing
* - Blue/Green deployments with hostname swaps
* - Optional Route53 DNS records (hostnames are optional - services may not need DNS)
* - Deployment circuit breaker with rollback
* - ECS Exec support for debugging
* - Pipeline hooks for custom behavior
*
* Hostname Support:
* - hostName: Simple hostname (e.g., "api.example.com") - auto-generates active/inactive hostnames
* - activeHostname/inactiveHostname: Explicit hostnames for blue/green
* - Hostnames are OPTIONAL - services without hostnames (pub/sub, workers) don't need DNS
* - For Cloudflare -> AWS DNS delegation: Point *.production.mydomain.com NS records to AWS Route53
*
* Pipeline Hooks (executed in order):
* - buildCommand: Custom build command (replaces default docker build)
* - onPostBuild: After build completes (linting, unit tests)
* - onPreDeploy: Before deployment (setup, integration test prep)
* - blueGreenTest: After inactive stack is up (integration tests against inactive)
* - onPostDeploy: After deployment succeeds (cleanup, notifications)
* - smokeTest: After blue/green swap or rolling deploy (smoke tests)
*
* Usage in Jenkinsfile:
* ```groovy
* @Library(["spicy-automation@main"]) _
*
* spicyECSService(
* jenkinsAwsCredentialsId: "aws-credentials",
* region: "ca-central-1",
* stackName: "my-service-dev",
* serviceName: "my-service",
*
* // Cluster info (VPC details are imported from cluster/VPC stack exports)
* clusterName: "my-ecs-cluster-dev",
*
* // Container config
* image: "nexus.kodeniks.com/docker-hosted/my-app:latest",
* containerPort: 3000,
* cpu: 256,
* memory: 512,
* environment: [NODE_ENV: "production"],
*
* // Blue/Green deployment (works with both individual ALB and cluster ALB)
* blueGreen: true,
* // Option 1: Simple hostName (auto-generates active/inactive hostnames)
* hostName: "api.example.com", // Creates: api.example.com and inactive-api.example.com
* bgHostedZoneId: "Z1234567890", // Required for DNS records
* // Option 2: Explicit hostnames
* // activeHostname: "api.example.com",
* // inactiveHostname: "inactive-api.example.com",
* blueGreenTest: { args, buildInfo ->
* sh "curl -f https://${buildInfo.inactiveHostname}/health"
* },
*
* // Capacity strategy (EC2 base + Fargate burst)
* capacityProviderStrategy: [
* [capacityProvider: "my-ecs-cluster-dev-ec2", base: 2, weight: 3],
* [capacityProvider: "FARGATE_SPOT", weight: 1],
* ],
*
* // Scaling
* desiredCount: 2,
* minCapacity: 2,
* maxCapacity: 10,
* targetCpuUtilization: 70,
*
* // Routing - ALB configuration
* // Pipeline resolves ALB details from cluster or ALB stack based on useClusterAlb
* // Option 1: Use cluster ALB (useClusterAlb=true, default)
* useClusterAlb: true, // Lookup ALB from cluster stack exports
* albScheme: "internet-facing", // or "internal" - determines which cluster ALB to use
* healthCheckPath: "/health",
* hostHeader: "api.example.com", // or pathPatterns: ["/api/*"]
* priority: 100,
*
* // Option 2: Use dedicated ALB stack (useClusterAlb=false)
* // useClusterAlb: false, // Deploy dedicated ALB stack for this service
* // albScheme: "internet-facing", // or "internal" (subnets imported from VPC stack based on scheme)
* // certificateArn: "arn:aws:acm:ca-central-1:123456789:certificate/xxx",
* // clusterLogsBucketName: "my-cluster-logs-bucket",
* // albIdleTimeout: 60,
* // redirectHttpToHttps: true,
* // healthCheckPath: "/health",
* // hostHeader: "api.example.com", // or pathPatterns: ["/api/*"]
* // priority: 100,
*
* // Tags
* ownerTag: "MyTeam",
* productTag: "my-product",
* componentTag: "api",
* environment: "dev",
*
* // Hooks
* onPostBuild: { args, buildInfo ->
* junit 'coverage/junit.xml'
* },
* smokeTest: { args, buildInfo ->
* sh "curl -f https://${buildInfo.activeHostname}/health"
* },
* )
* ```
*/
def call(Map args) {
args = spicyDefaults(args)
// Build info passed to hooks
def buildInfo = [:]
timeout(time: 1, unit: "DAYS") {
timestamps {
node("docker") {
properties(args.pipelineProperties)
ansiColor("xterm") {
stage("Checkout") {
checkout scm
buildInfo.commitSha = gitUtils.getShortSHA()
buildInfo.branch = env.BRANCH_NAME ?: 'main'
giteaUtils.setSuccess("checkout")
}
// Build Docker image if Dockerfile exists
if (fileExists('Dockerfile') && args.buildImage != false) {
spicyUtils.stageWithFailure("Build Image") {
def imageTag = args.imageTag ?: buildInfo.commitSha
def imageName = args.imageName ?: args.serviceName
// Use custom build command if provided
if (args.buildCommand) {
args.buildCommand.call(args, buildInfo)
} else {
def builtImage = dockerUtils.buildAndPush(
imageName: imageName,
imageTag: imageTag,
dockerfile: args.dockerfile ?: 'Dockerfile',
context: args.dockerContext ?: '.',
buildArgs: args.dockerBuildArgs ?: [:],
)
args.image = builtImage
}
buildInfo.image = args.image
buildInfo.imageTag = imageTag
giteaUtils.setSuccess("build-image")
}
// onPostBuild hook
if (args.onPostBuild) {
spicyUtils.stageWithFailure("Post Build") {
args.onPostBuild.call(args, buildInfo)
giteaUtils.setSuccess("post-build")
}
}
}
stage("Setup CDK") {
cdkUtils.install()
giteaUtils.setSuccess("setup")
}
// onPreDeploy hook
if (args.onPreDeploy) {
spicyUtils.stageWithFailure("Pre Deploy") {
args.onPreDeploy.call(args, buildInfo)
giteaUtils.setSuccess("pre-deploy")
}
}
stage("Deploy Service") {
if (gitUtils.isMain()) {
try {
// Determine deployment strategy
if (args.blueGreen) {
deployBlueGreen(args, buildInfo)
} else {
deployRolling(args, buildInfo)
}
} catch (err) {
giteaUtils.setFailed("deploy")
throw err
}
} else {
echo "Skipping deployment - not on main branch"
// Show diff on non-main branches for PR review
if (args.showDiffOnPR != false) {
showDiffForPR(args, buildInfo)
}
}
}
giteaUtils.setSuccess("pipeline")
}
}
}
}
}
/**
* Rolling deployment (single service, in-place update)
*/
def deployRolling(Map args, Map buildInfo) {
// Resolve ALB details (from cluster or ALB stack)
resolveAlbDetails(args)
def context = buildServiceContext(args)
def account = awsUtils.buildAccountConfig(args)
// Show diff first
if (args.showDiff != false) {
echo "Showing CDK diff..."
cdkUtils.diff(
account: account,
stackName: args.stackName,
stackType: "ecs-service",
context: context
)
}
// Manual approval for production
if (args.environment == 'prod' || args.environment == 'production') {
manualApproval(
message: "Deploy service ${args.serviceName} to production?",
submitter: args.approvers ?: ''
)
}
// Deploy the stack
echo "Deploying ECS service stack: ${args.stackName}"
cdkUtils.deploy(
account: account,
stackName: args.stackName,
stackType: "ecs-service",
context: context
)
// Stream logs during deployment stabilization
if (args.streamLogs != false) {
streamDeploymentLogs(args, buildInfo)
}
giteaUtils.setSuccess("deploy")
// onPostDeploy hook
if (args.onPostDeploy) {
spicyUtils.stageWithFailure("Post Deploy") {
// Support hostName parameter
def hostname = args.activeHostname ?: args.hostName ?: args.hostHeader
args.onPostDeploy.call(args, [
stackName: args.stackName,
serviceName: args.serviceName,
activeHostname: hostname
])
}
}
// Smoke test
if (args.smokeTest) {
spicyUtils.stageWithFailure("Smoke Test") {
// Support hostName parameter
buildInfo.activeHostname = args.activeHostname ?: args.hostName ?: args.hostHeader
buildInfo.healthCheckPath = args.healthCheckPath ?: '/health'
args.smokeTest.call(args, buildInfo)
giteaUtils.setSuccess("smoke-test")
}
}
}
/**
* Blue/Green deployment with hostname swapping
*/
def deployBlueGreen(Map args, Map buildInfo) {
def account = awsUtils.buildAccountConfig(args)
// Resolve ALB details (from cluster or ALB stack)
resolveAlbDetails(args)
// Determine current active color
def currentActive = getActiveColor(args)
def targetColor = currentActive == 'blue' ? 'green' : 'blue'
echo "Current active: ${currentActive}, deploying to: ${targetColor}"
buildInfo.currentActive = currentActive
buildInfo.targetColor = targetColor
// Support simple hostName parameter (like old HostName) - auto-generates active/inactive hostnames
// If hostName is provided, use it to generate activeHostname and inactiveHostname
if (args.hostName && !args.activeHostname && !args.inactiveHostname) {
buildInfo.activeHostname = args.hostName
buildInfo.inactiveHostname = "inactive-${args.hostName}"
} else {
// Use explicit hostnames if provided, otherwise fall back to hostHeader
buildInfo.activeHostname = args.activeHostname ?: args.hostName ?: args.hostHeader
buildInfo.inactiveHostname = args.inactiveHostname ?: (args.hostName ? "inactive-${args.hostName}" : "inactive-${args.hostHeader}")
}
// Build context for target (inactive) service
def targetStackName = "${args.stackName}-${targetColor}"
def context = buildServiceContext(args)
context.serviceColor = targetColor
// Set blue/green DNS if bgHostedZoneId is provided (hostnames are optional)
// ALB details are resolved by resolveAlbDetails() (internal-only, not input parameters)
if (args.bgHostedZoneId) {
context.activeHostname = buildInfo.activeHostname
context.inactiveHostname = buildInfo.inactiveHostname
context.isActive = 'false' // This is the inactive service
context.bgHostedZoneId = args.bgHostedZoneId
} else {
// No DNS - use hostHeader and priority for routing
context.hostHeader = buildInfo.inactiveHostname
// Use higher priority for inactive (lower number = higher priority, so inactive gets higher number)
def basePriority = args.priority ?: 100
context.priority = (targetColor == 'blue' ? basePriority : basePriority + 100).toString()
}
// Show diff
if (args.showDiff != false) {
echo "Showing CDK diff for ${targetColor} service..."
try {
cdkUtils.diff(
account: account,
stackName: targetStackName,
stackType: "ecs-service",
context: context
)
} catch (err) {
echo "Diff failed (stack may not exist yet): ${err.message}"
}
}
// Manual approval for production
if (args.environment == 'prod' || args.environment == 'production') {
manualApproval(
message: "Deploy ${args.serviceName} (${targetColor}) to production?",
submitter: args.approvers ?: ''
)
}
// Deploy to inactive stack
echo "Deploying to ${targetColor} service: ${targetStackName}"
cdkUtils.deploy(
account: account,
stackName: targetStackName,
stackType: "ecs-service",
context: context
)
// Stream logs during deployment
if (args.streamLogs != false) {
streamDeploymentLogs(args, buildInfo)
}
giteaUtils.setSuccess("deploy-${targetColor}")
// blueGreenTest hook - test against inactive hostname
if (args.blueGreenTest) {
spicyUtils.stageWithFailure("Blue/Green Test") {
echo "Testing inactive service at: ${buildInfo.inactiveHostname}"
args.blueGreenTest.call(args, buildInfo)
giteaUtils.setSuccess("blue-green-test")
}
}
// Swap hostnames
stage("Swap Hostnames") {
if (args.environment == 'prod' || args.environment == 'production') {
manualApproval(
message: "Swap traffic to ${targetColor}? This will make ${targetColor} active.",
submitter: args.approvers ?: ''
)
}
swapHostnames(args, buildInfo, account)
giteaUtils.setSuccess("swap")
}
// onPostDeploy hook
if (args.onPostDeploy) {
spicyUtils.stageWithFailure("Post Deploy") {
args.onPostDeploy.call(args, buildInfo)
}
}
// Smoke test against new active
if (args.smokeTest) {
spicyUtils.stageWithFailure("Smoke Test") {
echo "Running smoke test against: ${buildInfo.activeHostname}"
args.smokeTest.call(args, buildInfo)
giteaUtils.setSuccess("smoke-test")
}
}
// Schedule cleanup of old stack (keep for rollback window)
def rollbackWindow = args.rollbackWindowHours ?: 2
echo "Old ${currentActive} service will be retained for ${rollbackWindow} hours for rollback"
saveActiveColor(args, targetColor)
}
/**
* Swap hostnames between blue and green services
*/
def swapHostnames(Map args, Map buildInfo, Map account) {
def activeColor = buildInfo.targetColor
def inactiveColor = buildInfo.currentActive
def activeStackName = "${args.stackName}-${activeColor}"
def inactiveStackName = "${args.stackName}-${inactiveColor}"
echo "Swapping hostnames: ${activeColor} -> active, ${inactiveColor} -> inactive"
// Update active service to use active hostname with lower priority
def activeContext = buildServiceContext(args)
activeContext.serviceColor = activeColor
// Set blue/green DNS if bgHostedZoneId is provided
// ALB details are resolved by resolveAlbDetails() (internal-only, not input parameters)
if (args.bgHostedZoneId) {
activeContext.activeHostname = buildInfo.activeHostname
activeContext.inactiveHostname = buildInfo.inactiveHostname
activeContext.isActive = 'true' // This is the active service
activeContext.bgHostedZoneId = args.bgHostedZoneId
} else {
// No DNS - use hostHeader for routing
activeContext.hostHeader = buildInfo.activeHostname
activeContext.priority = (args.priority ?: 100).toString()
}
cdkUtils.deploy(
account: account,
stackName: activeStackName,
stackType: "ecs-service",
context: activeContext
)
// Update inactive service to use inactive hostname with higher priority number
if (stackExists(inactiveStackName, account)) {
def inactiveContext = buildServiceContext(args)
inactiveContext.serviceColor = inactiveColor
// Set blue/green DNS if bgHostedZoneId is provided
// ALB details are resolved by resolveAlbDetails() (internal-only, not input parameters)
if (args.bgHostedZoneId) {
inactiveContext.activeHostname = buildInfo.activeHostname
inactiveContext.inactiveHostname = buildInfo.inactiveHostname
inactiveContext.isActive = 'false' // This is the inactive service
inactiveContext.bgHostedZoneId = args.bgHostedZoneId
} else {
// No DNS - use hostHeader for routing
inactiveContext.hostHeader = buildInfo.inactiveHostname
inactiveContext.priority = ((args.priority ?: 100) + 100).toString()
}
cdkUtils.deploy(
account: account,
stackName: inactiveStackName,
stackType: "ecs-service",
context: inactiveContext
)
}
echo "Hostname swap complete! ${activeColor} is now active."
}
/**
* Get the currently active color from SSM Parameter Store
*/
def getActiveColor(Map args) {
def paramName = "/spicy/${args.serviceName}/active-color"
try {
withCredentials([[$class: 'AmazonWebServicesCredentialsBinding',
credentialsId: args.jenkinsAwsCredentialsId,
accessKeyVariable: 'AWS_ACCESS_KEY_ID',
secretKeyVariable: 'AWS_SECRET_ACCESS_KEY']]) {
def result = sh(
script: "aws ssm get-parameter --name '${paramName}' --region ${args.region} --query 'Parameter.Value' --output text 2>/dev/null || echo 'blue'",
returnStdout: true
).trim()
return result ?: 'blue'
}
} catch (err) {
echo "Could not get active color, defaulting to blue: ${err.message}"
return 'blue'
}
}
/**
* Save the active color to SSM Parameter Store
*/
def saveActiveColor(Map args, String color) {
def paramName = "/spicy/${args.serviceName}/active-color"
withCredentials([[$class: 'AmazonWebServicesCredentialsBinding',
credentialsId: args.jenkinsAwsCredentialsId,
accessKeyVariable: 'AWS_ACCESS_KEY_ID',
secretKeyVariable: 'AWS_SECRET_ACCESS_KEY']]) {
sh """
aws ssm put-parameter \
--name '${paramName}' \
--value '${color}' \
--type String \
--overwrite \
--region ${args.region}
"""
}
}
/**
* Check if a CloudFormation stack exists
*/
def stackExists(String stackName, Map account) {
try {
withCredentials([[$class: 'AmazonWebServicesCredentialsBinding',
credentialsId: account.jenkinsAwsCredentialsId,
accessKeyVariable: 'AWS_ACCESS_KEY_ID',
secretKeyVariable: 'AWS_SECRET_ACCESS_KEY']]) {
def result = sh(
script: "aws cloudformation describe-stacks --stack-name '${stackName}' --region ${account.region} 2>/dev/null",
returnStatus: true
)
return result == 0
}
} catch (err) {
return false
}
}
/**
* Stream ECS deployment logs to Jenkins console
*/
def streamDeploymentLogs(Map args, Map buildInfo) {
try {
withCredentials([[$class: 'AmazonWebServicesCredentialsBinding',
credentialsId: args.jenkinsAwsCredentialsId,
accessKeyVariable: 'AWS_ACCESS_KEY_ID',
secretKeyVariable: 'AWS_SECRET_ACCESS_KEY']]) {
echo "Streaming ECS service events..."
def logGroupName = "/ecs/${args.serviceName}"
// Stream logs for 60 seconds or until deployment stabilizes
sh """
timeout 60 aws logs tail '${logGroupName}' \
--region ${args.region} \
--follow \
--since 5m 2>/dev/null || true
"""
// Show recent ECS service events
echo "Recent ECS service events:"
sh """
aws ecs describe-services \
--cluster ${args.clusterName} \
--services ${args.serviceName} \
--region ${args.region} \
--query 'services[0].events[0:5]' \
--output table 2>/dev/null || true
"""
}
} catch (err) {
echo "Could not stream logs: ${err.message}"
}
}
/**
* Show CDK diff for PR review
*/
def showDiffForPR(Map args, Map buildInfo) {
// Resolve ALB details for accurate diff (may fail if stacks don't exist, but that's ok for PR review)
try {
resolveAlbDetails(args)
} catch (err) {
echo "Could not resolve ALB details for diff (stacks may not exist): ${err.message}"
// Continue without ALB details - diff will show what it can
}
def context = buildServiceContext(args)
def account = awsUtils.buildAccountConfig(args)
echo "Showing CDK diff for PR review..."
try {
cdkUtils.diff(
account: account,
stackName: args.stackName,
stackType: "ecs-service",
context: context
)
} catch (err) {
echo "Diff failed (stack may not exist yet): ${err.message}"
}
}
/**
* Resolve ALB details from either cluster ALB or deployed ALB stack
* Always resolves and sets albLoadBalancerArn, albHttpsListenerArn, albHttpListenerArn in args
* These are internal-only values, never input parameters
*/
def resolveAlbDetails(Map args) {
def account = awsUtils.buildAccountConfig(args)
def clusterStackName = args.clusterStackName ?: args.clusterName
if (!clusterStackName) {
error("clusterStackName or clusterName is required to resolve ALB details")
}
// Default useClusterAlb to true if not specified
def useClusterAlb = args.useClusterAlb != false
// Always resolve ALB details (overwrite any existing values - these are not input parameters)
if (useClusterAlb) {
// Get ALB details from cluster stack exports
def albScheme = args.albScheme ?: 'internet-facing'
def prefix = albScheme == 'internet-facing' ? 'internet-facing' : 'internal'
echo "Resolving ALB details from cluster stack: ${clusterStackName} (${prefix})"
def albArn = awsUtils.getCloudFormationExport(account, "${clusterStackName}-${prefix}-arn")
def httpsListenerArn = awsUtils.getCloudFormationExport(account, "${clusterStackName}-${prefix}-https-listener")
def httpListenerArn = awsUtils.getCloudFormationExport(account, "${clusterStackName}-${prefix}-http-listener")
if (!albArn) {
error("Cluster stack ${clusterStackName} does not have ${prefix} ALB. Export ${clusterStackName}-${prefix}-arn not found.")
}
if (!httpsListenerArn && !httpListenerArn) {
error("Cluster stack ${clusterStackName} does not have ${prefix} ALB listeners. Exports ${clusterStackName}-${prefix}-https-listener and ${clusterStackName}-${prefix}-http-listener not found.")
}
args.albLoadBalancerArn = albArn
args.albHttpsListenerArn = httpsListenerArn
args.albHttpListenerArn = httpListenerArn
echo "Resolved cluster ALB: ${albArn}"
} else {
// Deploy ALB stack and get ALB details from ALB stack exports
deployAlbStack(args)
def baseStackName = args.stackName.replaceAll(/-blue$|-green$/, '')
def albStackName = "${baseStackName}-alb"
def albScheme = args.albScheme ?: 'internet-facing'
def prefix = albScheme == 'internet-facing' ? 'internet-facing' : 'internal'
echo "Resolving ALB details from ALB stack: ${albStackName} (${prefix})"
def albArn = awsUtils.getCloudFormationExport(account, "${albStackName}-${prefix}-arn")
def httpsListenerArn = awsUtils.getCloudFormationExport(account, "${albStackName}-${prefix}-https-listener")
def httpListenerArn = awsUtils.getCloudFormationExport(account, "${albStackName}-${prefix}-http-listener")
if (!albArn) {
error("ALB stack ${albStackName} does not have ${prefix} ALB. Export ${albStackName}-${prefix}-arn not found.")
}
if (!httpsListenerArn && !httpListenerArn) {
error("ALB stack ${albStackName} does not have ${prefix} ALB listeners. Exports ${albStackName}-${prefix}-https-listener and ${albStackName}-${prefix}-http-listener not found.")
}
args.albLoadBalancerArn = albArn
args.albHttpsListenerArn = httpsListenerArn
args.albHttpListenerArn = httpListenerArn
echo "Resolved ALB stack ALB: ${albArn}"
}
}
/**
* Build CDK context map from pipeline arguments
*/
def buildServiceContext(Map args) {
def context = [:]
// Required: Cluster stack name (all VPC details imported from VPC stack via cluster exports)
context.clusterStackName = args.clusterStackName ?: args.clusterName
if (!context.clusterStackName) {
error("clusterStackName or clusterName is required")
}
if (!args.numberOfAzs) {
error("numberOfAzs is required (2-4) and must match the VPC used by the cluster.")
}
context.numberOfAzs = args.numberOfAzs.toString()
// Required: Service config
context.serviceName = args.serviceName
context.image = args.image
context.containerPort = (args.containerPort ?: 3000).toString()
// Tags
context.ownerTag = args.ownerTag
context.productTag = args.productTag
context.componentTag = args.componentTag ?: args.serviceName
context.environment = args.environment ?: 'dev'
context.build = args.build ?: gitUtils.getShortSHA()
// Container config
if (args.cpu) context.cpu = args.cpu.toString()
if (args.memory) context.memory = args.memory.toString()
if (args.desiredCount) context.desiredCount = args.desiredCount.toString()
// Environment variables (as JSON)
if (args.environment_vars) {
context.environment_vars = groovy.json.JsonOutput.toJson(args.environment_vars)
}
// Secrets (as JSON)
if (args.secrets) {
context.secrets = groovy.json.JsonOutput.toJson(args.secrets)
}
// Capacity provider strategy (as JSON)
if (args.capacityProviderStrategy) {
context.capacityProviderStrategy = groovy.json.JsonOutput.toJson(args.capacityProviderStrategy)
}
// Scaling
if (args.minCapacity) context.minCapacity = args.minCapacity.toString()
if (args.maxCapacity) context.maxCapacity = args.maxCapacity.toString()
if (args.targetCpuUtilization) context.targetCpuUtilization = args.targetCpuUtilization.toString()
if (args.targetMemoryUtilization) context.targetMemoryUtilization = args.targetMemoryUtilization.toString()
if (args.targetRequestsPerTarget) context.targetRequestsPerTarget = args.targetRequestsPerTarget.toString()
// Health check
if (args.healthCheckPath) context.healthCheckPath = args.healthCheckPath
// Routing - ALB details (always resolved by resolveAlbDetails() before calling buildServiceContext)
// These are internal-only values, never input parameters
context.albLoadBalancerArn = args.albLoadBalancerArn
context.albHttpsListenerArn = args.albHttpsListenerArn
context.albHttpListenerArn = args.albHttpListenerArn
if (args.albScheme) context.albScheme = args.albScheme // "internet-facing" or "internal"
if (args.useClusterAlb != null) context.useClusterAlb = args.useClusterAlb.toString()
// Blue/Green DNS (for bg-common ALB) - hostnames are optional
// Support simple hostName parameter (like old HostName) - auto-generates active/inactive
if (args.hostName && !args.activeHostname && !args.inactiveHostname) {
context.activeHostname = args.hostName
context.inactiveHostname = "inactive-${args.hostName}"
} else {
if (args.activeHostname) context.activeHostname = args.activeHostname
if (args.inactiveHostname) context.inactiveHostname = args.inactiveHostname
}
if (args.bgHostedZoneId) context.bgHostedZoneId = args.bgHostedZoneId
if (args.isActive != null) context.isActive = args.isActive.toString()
// Routing - Cluster ALB (existing behavior)
if (args.hostHeader) context.hostHeader = args.hostHeader
if (args.pathPatterns) context.pathPatterns = args.pathPatterns
if (args.priority) context.priority = args.priority.toString()
if (args.useExternalALB != null) context.useExternalALB = args.useExternalALB.toString()
if (args.useInternalALB != null) context.useInternalALB = args.useInternalALB.toString()
if (args.stickiness != null) context.stickiness = args.stickiness.toString()
if (args.stickinessDuration) context.stickinessDuration = args.stickinessDuration.toString()
if (args.deregistrationDelay) context.deregistrationDelay = args.deregistrationDelay.toString()
// ALB listeners (for cluster ALB mode)
if (args.externalListenerArn) context.externalListenerArn = args.externalListenerArn
if (args.internalListenerArn) context.internalListenerArn = args.internalListenerArn
// DNS
if (args.hostedZoneId) context.hostedZoneId = args.hostedZoneId
if (args.zoneName) context.zoneName = args.zoneName
if (args.recordName) context.recordName = args.recordName
// Deployment
if (args.circuitBreaker != null) context.circuitBreaker = args.circuitBreaker.toString()
if (args.enableExecuteCommand != null) context.enableExecuteCommand = args.enableExecuteCommand.toString()
return context
}
/**
* Deploy ALB stack (1:1 with service stack)
* ALB stack name is {serviceStackName}-alb (shared between blue/green)
* The ALB stack name is derived from the service stackName base (without -blue/-green suffix)
*/
def deployAlbStack(Map args) {
// ALB stack name is based on base service name (without color suffix)
// This ensures blue and green services share the same ALB
// Example: stackName="my-service-dev-blue" -> albStackName="my-service-dev-alb"
def baseStackName = args.stackName.replaceAll(/-blue$|-green$/, '')
def albStackName = "${baseStackName}-alb"
def account = awsUtils.buildAccountConfig(args)
// Service stack will derive albStackName from stackName and import ALB details from exports
// No need to store it in args
// Check if ALB stack already exists
def albExists = awsUtils.stackExists(
account: account,
stackName: albStackName
)
if (!albExists) {
echo "ALB stack does not exist, creating: ${albStackName}"
// Build ALB context
def albContext = buildAlbContext(args)
// Show diff first
if (args.showDiff != false) {
echo "Showing ALB stack diff..."
cdkUtils.diff(
account: account,
stackName: albStackName,
stackType: "alb",
context: albContext
)
}
// Deploy ALB stack
echo "Deploying ALB stack: ${albStackName}"
cdkUtils.deploy(
account: account,
stackName: albStackName,
stackType: "alb",
context: albContext
)
} else {
echo "ALB stack already exists: ${albStackName}"
}
// Get ALB outputs for service stack (always fetch, even if stack existed)
def albOutputs = awsUtils.getStackOutputs(
account: account,
stackName: albStackName
)
if (!albOutputs || albOutputs.isEmpty()) {
error("Failed to get ALB stack outputs from ${albStackName}")
}
// Service stack will import ALB details from ALB stack exports
// No need to store outputs in args - service stack derives albStackName and imports directly
echo "ALB stack ready: ${albStackName}"
echo "ALB DNS: ${albOutputs.LoadBalancerDNS ?: 'N/A'}"
}
/**
* Build ALB context from pipeline arguments
*/
def buildAlbContext(Map args) {
def context = [:]
// Required: Cluster stack name (all VPC details imported from VPC stack via cluster exports)
context.clusterStackName = args.clusterStackName ?: args.clusterName
if (!context.clusterStackName) {
error("clusterStackName or clusterName is required for ALB stack")
}
if (!args.numberOfAzs) {
error("numberOfAzs is required (2-4) for ALB stack and must match the VPC/cluster.")
}
context.numberOfAzs = args.numberOfAzs.toString()
// ALB scheme
context.scheme = args.albScheme ?: "internal"
// Optional
if (args.certificateArn) context.certificateArn = args.certificateArn
if (args.albIdleTimeout) context.idleTimeout = args.albIdleTimeout.toString()
if (args.clusterLogsBucketName) context.logsBucketName = args.clusterLogsBucketName
if (args.albLogsPrefix) context.logsPrefix = args.albLogsPrefix
if (args.redirectHttpToHttps != null) context.redirectHttpToHttps = args.redirectHttpToHttps.toString()
// Blue/Green DNS (if provided) - hostnames are optional
// Support simple hostName parameter (like old HostName) - auto-generates active/inactive
if (args.hostName && !args.activeHostname && !args.inactiveHostname) {
context.activeHostname = args.hostName
context.inactiveHostname = "inactive-${args.hostName}"
} else {
if (args.activeHostname) context.activeHostname = args.activeHostname
if (args.inactiveHostname) context.inactiveHostname = args.inactiveHostname
}
if (args.bgHostedZoneId) context.bgHostedZoneId = args.bgHostedZoneId
// Tags
context.ownerTag = args.ownerTag
context.productTag = args.productTag
context.componentTag = args.componentTag
context.environmentTag = args.environment ?: "dev"
if (args.buildTag) context.buildTag = args.buildTag
return context
}
return this