Files
spicy-automation/vars/spicyRollback.groovy
Ryan Wilson 68684df471 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>
2025-11-18 22:21:00 -08:00

307 lines
11 KiB
Groovy

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