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