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:
306
vars/spicyRollback.groovy
Normal file
306
vars/spicyRollback.groovy
Normal file
@@ -0,0 +1,306 @@
|
||||
/**
|
||||
* 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
|
||||
|
||||
Reference in New Issue
Block a user