/** * 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