/** * Spicy Rollback Pipeline * * Quickly rollback a blue/green deployment by swapping hostnames. * No new deployment needed - just swaps ALB routing rules. * * Usage in Jenkinsfile: * ```groovy * @Library(["spicy-automation@main"]) _ * * spicyRollback( * jenkinsAwsCredentialsId: "aws-credentials", * region: "ca-central-1", * stackName: "my-service-dev", * serviceName: "my-service", * clusterName: "my-ecs-cluster-dev", * vpcId: "vpc-12345678", * vpcCidrBlock: "10.0.0.0/16", * availabilityZones: "ca-central-1a,ca-central-1b,ca-central-1c", * privateSubnetIds: "subnet-aaa,subnet-bbb,subnet-ccc", * activeHostname: "api.example.com", * inactiveHostname: "inactive-api.example.com", * priority: 100, * useExternalALB: true, * externalListenerArn: "arn:aws:elasticloadbalancing:...", * ownerTag: "MyTeam", * productTag: "my-product", * componentTag: "api", * environment: "dev", * ) * ``` */ def call(Map args) { args = spicyDefaults(args) timeout(time: 30, unit: "MINUTES") { timestamps { node("docker") { properties(args.pipelineProperties) ansiColor("xterm") { stage("Checkout") { checkout scm giteaUtils.setSuccess("checkout") } stage("Setup CDK") { cdkUtils.install() } def account = [ region: args.region, jenkinsAwsCredentialsId: args.jenkinsAwsCredentialsId, accountId: args.accountId ?: '' ] stage("Determine Current State") { def currentActive = getActiveColor(args) def targetColor = currentActive == 'blue' ? 'green' : 'blue' echo """ ╔════════════════════════════════════════════════════════════════╗ ║ ROLLBACK CONFIRMATION ║ ╠════════════════════════════════════════════════════════════════╣ ║ Service: ${args.serviceName.padRight(42)}║ ║ Current Active: ${currentActive.toUpperCase().padRight(42)}║ ║ Rolling Back To: ${targetColor.toUpperCase().padRight(41)}║ ║ ║ ║ Active Hostname: ${(args.activeHostname ?: args.hostHeader).padRight(38)}║ ║ Inactive Hostname: ${(args.inactiveHostname ?: 'inactive-' + args.hostHeader).padRight(38)}║ ╚════════════════════════════════════════════════════════════════╝ """ env.CURRENT_ACTIVE = currentActive env.TARGET_COLOR = targetColor } stage("Confirm Rollback") { manualApproval( message: "Rollback ${args.serviceName} from ${env.CURRENT_ACTIVE} to ${env.TARGET_COLOR}?", submitter: args.approvers ?: '' ) } stage("Execute Rollback") { def buildInfo = [ currentActive: env.CURRENT_ACTIVE, targetColor: env.TARGET_COLOR, activeHostname: args.activeHostname ?: args.hostHeader, inactiveHostname: args.inactiveHostname ?: "inactive-${args.hostHeader}" ] echo "Executing rollback: ${env.CURRENT_ACTIVE} -> ${env.TARGET_COLOR}" // Swap hostnames (same logic as deployment, but reversed) swapHostnames(args, buildInfo, account) // Update SSM parameter saveActiveColor(args, env.TARGET_COLOR) echo """ ╔════════════════════════════════════════════════════════════════╗ ║ ROLLBACK COMPLETE ║ ╠════════════════════════════════════════════════════════════════╣ ║ ${env.TARGET_COLOR.toUpperCase()} is now ACTIVE ║ ║ ${env.CURRENT_ACTIVE.toUpperCase()} is now INACTIVE ║ ╚════════════════════════════════════════════════════════════════╝ """ giteaUtils.setSuccess("rollback") } // Smoke test after rollback if (args.smokeTest) { spicyUtils.stageWithFailure("Smoke Test") { def buildInfo = [ activeHostname: args.activeHostname ?: args.hostHeader, healthCheckPath: args.healthCheckPath ?: '/health' ] args.smokeTest.call(args, buildInfo) giteaUtils.setSuccess("smoke-test") } } giteaUtils.setSuccess("pipeline") } } } } } /** * 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 new active service to use active hostname with lower priority def activeContext = buildServiceContext(args) activeContext.serviceColor = activeColor activeContext.hostHeader = buildInfo.activeHostname activeContext.isActive = 'true' activeContext.priority = (args.priority ?: 100).toString() cdkUtils.deploy( account: account, stackName: activeStackName, stackType: "ecs-service", context: activeContext ) // Update old active service to use inactive hostname with higher priority number if (stackExists(inactiveStackName, account)) { def inactiveContext = buildServiceContext(args) inactiveContext.serviceColor = inactiveColor inactiveContext.hostHeader = buildInfo.inactiveHostname inactiveContext.isActive = 'false' 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 } } /** * Build CDK context map from pipeline arguments */ def buildServiceContext(Map args) { def context = [:] // Required: Cluster and VPC context.clusterName = args.clusterName context.vpcId = args.vpcId if (args.vpcCidrBlock) context.vpcCidrBlock = args.vpcCidrBlock context.availabilityZones = args.availabilityZones context.privateSubnetIds = args.privateSubnetIds // Required: Service config context.serviceName = args.serviceName context.image = args.image ?: 'placeholder:latest' // Image doesn't change during rollback 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' // 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() // 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() // Health check if (args.healthCheckPath) context.healthCheckPath = args.healthCheckPath // Routing if (args.pathPatterns) context.pathPatterns = args.pathPatterns 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() // ALB listeners 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 } return this