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