Jenkins shared library and CDK constructs for AWS infrastructure. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
307 lines
11 KiB
Groovy
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
|
|
|