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:
100
vars/accounts.groovy
Normal file
100
vars/accounts.groovy
Normal file
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* Account configuration for Spicy CDK pipelines
|
||||
*
|
||||
* This file defines AWS account configurations that can be referenced
|
||||
* in Jenkinsfiles. Customize this for your organization.
|
||||
*
|
||||
* Usage:
|
||||
* def myAccount = accounts.get().SPICY_CA_CENTRAL_1_DEV
|
||||
*/
|
||||
|
||||
def get() {
|
||||
return getAccountsWithEnvironments()
|
||||
}
|
||||
|
||||
def getAccountsWithEnvironments() {
|
||||
return getAccounts() + getDevelopment() + getSandbox() + getStaging() + getProduction()
|
||||
}
|
||||
|
||||
/**
|
||||
* Base account configurations (without environment-specific settings)
|
||||
*/
|
||||
def getAccounts() {
|
||||
def accounts = [:]
|
||||
|
||||
// Example: Spicy AWS Account in ca-central-1
|
||||
accounts.put("SPICY_CA_CENTRAL_1", [
|
||||
accountId: env.AWS_ACCOUNT_ID ?: "123456789012",
|
||||
region: "ca-central-1",
|
||||
jenkinsAwsCredentialsId: "aws-credentials"
|
||||
])
|
||||
|
||||
return accounts
|
||||
}
|
||||
|
||||
/**
|
||||
* Development environment configuration
|
||||
*/
|
||||
def getDevelopment() {
|
||||
def accounts = [:]
|
||||
def base = getAccounts().SPICY_CA_CENTRAL_1
|
||||
|
||||
accounts.put("SPICY_CA_CENTRAL_1_DEV", base + [
|
||||
environmentName: "development",
|
||||
vpcStackName: "vpc-dev",
|
||||
ecsClusterName: "cluster-dev",
|
||||
])
|
||||
|
||||
return accounts
|
||||
}
|
||||
|
||||
/**
|
||||
* Sandbox environment configuration
|
||||
*/
|
||||
def getSandbox() {
|
||||
def accounts = [:]
|
||||
def base = getAccounts().SPICY_CA_CENTRAL_1
|
||||
|
||||
accounts.put("SPICY_CA_CENTRAL_1_SANDBOX", base + [
|
||||
environmentName: "sandbox",
|
||||
vpcStackName: "vpc-sandbox",
|
||||
ecsClusterName: "cluster-sandbox",
|
||||
])
|
||||
|
||||
return accounts
|
||||
}
|
||||
|
||||
/**
|
||||
* Staging environment configuration
|
||||
*/
|
||||
def getStaging() {
|
||||
def accounts = [:]
|
||||
def base = getAccounts().SPICY_CA_CENTRAL_1
|
||||
|
||||
accounts.put("SPICY_CA_CENTRAL_1_STAGING", base + [
|
||||
environmentName: "staging",
|
||||
vpcStackName: "vpc-staging",
|
||||
ecsClusterName: "cluster-staging",
|
||||
])
|
||||
|
||||
return accounts
|
||||
}
|
||||
|
||||
/**
|
||||
* Production environment configuration
|
||||
*/
|
||||
def getProduction() {
|
||||
def accounts = [:]
|
||||
def base = getAccounts().SPICY_CA_CENTRAL_1
|
||||
|
||||
accounts.put("SPICY_CA_CENTRAL_1_PROD", base + [
|
||||
environmentName: "production",
|
||||
vpcStackName: "vpc-prod",
|
||||
ecsClusterName: "cluster-prod",
|
||||
])
|
||||
|
||||
return accounts
|
||||
}
|
||||
|
||||
return this
|
||||
|
||||
96
vars/awsUtils.groovy
Normal file
96
vars/awsUtils.groovy
Normal file
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* AWS utilities for Spicy CDK pipelines
|
||||
*/
|
||||
|
||||
def runCLI(Map args) {
|
||||
withCredentials([[$class: 'AmazonWebServicesCredentialsBinding',
|
||||
credentialsId: args.account.jenkinsAwsCredentialsId,
|
||||
accessKeyVariable: 'AWS_ACCESS_KEY_ID',
|
||||
secretKeyVariable: 'AWS_SECRET_ACCESS_KEY']]) {
|
||||
return sh(
|
||||
script: """#!/bin/bash +x
|
||||
set -e
|
||||
export AWS_DEFAULT_REGION=${args.account.region}
|
||||
set -x
|
||||
${args.command}
|
||||
set +x
|
||||
""",
|
||||
returnStdout: true
|
||||
).trim()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current AWS account ID
|
||||
*/
|
||||
def getAccountId(Map args) {
|
||||
return runCLI(
|
||||
account: args.account,
|
||||
command: "aws sts get-caller-identity --query Account --output text"
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a CloudFormation stack exists
|
||||
*/
|
||||
def stackExists(Map args) {
|
||||
try {
|
||||
runCLI(
|
||||
account: args.account,
|
||||
command: "aws cloudformation describe-stacks --stack-name ${args.stackName}"
|
||||
)
|
||||
return true
|
||||
} catch (err) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get CloudFormation stack outputs as a map
|
||||
*/
|
||||
def getStackOutputs(Map args) {
|
||||
def outputs = [:]
|
||||
try {
|
||||
def result = runCLI(
|
||||
account: args.account,
|
||||
command: "aws cloudformation describe-stacks --stack-name ${args.stackName} --query 'Stacks[0].Outputs' --output json"
|
||||
)
|
||||
def parsed = readJSON text: result
|
||||
parsed.each { output ->
|
||||
outputs[output.OutputKey] = output.OutputValue
|
||||
}
|
||||
} catch (err) {
|
||||
echo "Could not get stack outputs: ${err}"
|
||||
}
|
||||
return outputs
|
||||
}
|
||||
|
||||
/**
|
||||
* Get CloudFormation export value by export name
|
||||
*/
|
||||
def getCloudFormationExport(Map account, String exportName) {
|
||||
try {
|
||||
def result = runCLI(
|
||||
account: account,
|
||||
command: "aws cloudformation list-exports --query \"Exports[?Name=='${exportName}'].Value\" --output text"
|
||||
)
|
||||
return result ?: null
|
||||
} catch (err) {
|
||||
echo "Could not get CloudFormation export ${exportName}: ${err.message}"
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build account configuration from pipeline arguments
|
||||
*/
|
||||
def buildAccountConfig(Map args) {
|
||||
return [
|
||||
region: args.region,
|
||||
jenkinsAwsCredentialsId: args.jenkinsAwsCredentialsId,
|
||||
accountId: args.accountId ?: ''
|
||||
]
|
||||
}
|
||||
|
||||
return this
|
||||
|
||||
150
vars/buildAndPushDockerImage.groovy
Normal file
150
vars/buildAndPushDockerImage.groovy
Normal file
@@ -0,0 +1,150 @@
|
||||
/**
|
||||
* Build and Push Docker Image Pipeline
|
||||
*
|
||||
* A simple pipeline for building and pushing Docker images to Nexus.
|
||||
*
|
||||
* Usage in Jenkinsfile:
|
||||
* ```groovy
|
||||
* @Library(["spicy-automation@main"]) _
|
||||
*
|
||||
* buildAndPushDockerImage(
|
||||
* imageName: 'my-service',
|
||||
* )
|
||||
* ```
|
||||
*
|
||||
* With all options:
|
||||
* ```groovy
|
||||
* @Library(["spicy-automation@main"]) _
|
||||
*
|
||||
* buildAndPushDockerImage(
|
||||
* imageName: 'my-service',
|
||||
* dockerfile: 'Dockerfile',
|
||||
* context: '.',
|
||||
* buildArgs: [NODE_ENV: 'production'],
|
||||
* registry: 'nexus.kodeniks.com',
|
||||
* repository: 'docker-hosted',
|
||||
* credentialsId: 'kodeniks-nexus-repository',
|
||||
* tagLatest: true,
|
||||
* compareWithLatest: true,
|
||||
* pruneAfter: false, // Default false to preserve build cache
|
||||
* pruneCache: false, // Whether to prune build cache when pruning
|
||||
* )
|
||||
* ```
|
||||
*/
|
||||
|
||||
def call(Map args = [:]) {
|
||||
def config = [
|
||||
imageName: args.imageName ?: args.name ?: env.JOB_NAME?.tokenize('/')?.last() ?: 'application',
|
||||
dockerfile: args.dockerfile ?: 'Dockerfile',
|
||||
context: args.context ?: '.',
|
||||
buildArgs: args.buildArgs ?: [:],
|
||||
registry: args.registry ?: 'nexus.kodeniks.com',
|
||||
repository: args.repository ?: 'docker-hosted',
|
||||
credentialsId: args.credentialsId ?: 'kodeniks-nexus-repository',
|
||||
imageTag: args.imageTag ?: null, // null means use git SHA
|
||||
tagLatest: args.tagLatest != null ? args.tagLatest : true,
|
||||
compareWithLatest: args.compareWithLatest != null ? args.compareWithLatest : true,
|
||||
pruneAfter: args.pruneAfter != null ? args.pruneAfter : false, // Default to false to preserve build cache
|
||||
pruneCache: args.pruneCache != null ? args.pruneCache : false, // Whether to prune build cache when pruning
|
||||
agentLabel: args.agentLabel ?: 'docker',
|
||||
]
|
||||
|
||||
pipeline {
|
||||
agent {
|
||||
label config.agentLabel
|
||||
}
|
||||
|
||||
stages {
|
||||
stage('Checkout') {
|
||||
steps {
|
||||
checkout scm
|
||||
script {
|
||||
giteaUtils.setSuccess("checkout")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
stage('Build Docker Image') {
|
||||
steps {
|
||||
script {
|
||||
// Use custom tag if provided, otherwise dockerUtils will use git SHA
|
||||
def buildArgs = [
|
||||
imageName: config.imageName,
|
||||
dockerfile: config.dockerfile,
|
||||
context: config.context,
|
||||
buildArgs: config.buildArgs,
|
||||
registry: config.registry,
|
||||
repository: config.repository,
|
||||
credentialsId: config.credentialsId,
|
||||
]
|
||||
|
||||
if (config.imageTag) {
|
||||
buildArgs.imageTag = config.imageTag
|
||||
}
|
||||
|
||||
dockerUtils.buildImage(buildArgs)
|
||||
dockerUtils.tagImage(buildArgs + [tagLatest: config.tagLatest])
|
||||
giteaUtils.setSuccess("build")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
stage('Push Docker Image') {
|
||||
steps {
|
||||
script {
|
||||
def pushArgs = [
|
||||
imageName: config.imageName,
|
||||
registry: config.registry,
|
||||
repository: config.repository,
|
||||
credentialsId: config.credentialsId,
|
||||
tagLatest: config.tagLatest,
|
||||
compareWithLatest: config.compareWithLatest,
|
||||
]
|
||||
|
||||
if (config.imageTag) {
|
||||
pushArgs.imageTag = config.imageTag
|
||||
}
|
||||
|
||||
def pushed = dockerUtils.pushImage(pushArgs)
|
||||
|
||||
if (pushed) {
|
||||
echo "Successfully pushed: ${dockerUtils.getImageRefSHA(pushArgs)}"
|
||||
if (config.tagLatest) {
|
||||
echo "Also pushed: ${dockerUtils.getImageRefLatest(pushArgs)}"
|
||||
}
|
||||
} else {
|
||||
echo "Image unchanged - push skipped"
|
||||
}
|
||||
|
||||
giteaUtils.setSuccess("push")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
post {
|
||||
always {
|
||||
script {
|
||||
if (config.pruneAfter) {
|
||||
dockerUtils.prune([pruneCache: config.pruneCache])
|
||||
}
|
||||
}
|
||||
}
|
||||
success {
|
||||
script {
|
||||
giteaUtils.setSuccess("pipeline")
|
||||
echo "Docker image build and push completed successfully!"
|
||||
}
|
||||
}
|
||||
failure {
|
||||
script {
|
||||
giteaUtils.setFailed("pipeline")
|
||||
echo "Build failed!"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return this
|
||||
|
||||
197
vars/cdkUtils.groovy
Normal file
197
vars/cdkUtils.groovy
Normal file
@@ -0,0 +1,197 @@
|
||||
/**
|
||||
* CDK Utilities for Spicy CDK pipelines
|
||||
*
|
||||
* Provides functions to run AWS CDK commands from Jenkins pipelines.
|
||||
* CDK runs directly on the Jenkins worker (no Docker needed for CDK itself).
|
||||
*/
|
||||
|
||||
/**
|
||||
* Run a CDK command
|
||||
*
|
||||
* @param args.account Account configuration with region and jenkinsAwsCredentialsId
|
||||
* @param args.command The CDK command to run (e.g., "deploy", "diff", "synth")
|
||||
* @param args.stackName Name of the stack to deploy
|
||||
* @param args.context Map of context values to pass to CDK (-c key=value)
|
||||
* @param args.workDir Working directory (default: current directory)
|
||||
* @param args.requireApproval CDK approval level (default: "never" for CI/CD)
|
||||
*/
|
||||
def runCdk(Map args) {
|
||||
def account = args.account
|
||||
def command = args.command ?: "deploy"
|
||||
def stackName = args.stackName
|
||||
def context = args.context ?: [:]
|
||||
def workDir = args.workDir ?: "cdk-source"
|
||||
def requireApproval = args.requireApproval ?: "never"
|
||||
|
||||
// Build context arguments
|
||||
def contextArgs = context.collect { k, v ->
|
||||
if (v != null && v != '') {
|
||||
"-c ${k}='${v}'"
|
||||
}
|
||||
}.findAll { it != null }.join(' ')
|
||||
|
||||
// Build the full CDK command
|
||||
def cdkCommand = "npx cdk ${command}"
|
||||
|
||||
if (stackName) {
|
||||
cdkCommand += " ${stackName}"
|
||||
}
|
||||
|
||||
if (contextArgs) {
|
||||
cdkCommand += " ${contextArgs}"
|
||||
}
|
||||
|
||||
if (command == "deploy") {
|
||||
cdkCommand += " --require-approval ${requireApproval}"
|
||||
}
|
||||
|
||||
if (command == "destroy") {
|
||||
cdkCommand += " --force"
|
||||
}
|
||||
|
||||
// Run with AWS credentials
|
||||
withCredentials([[$class: 'AmazonWebServicesCredentialsBinding',
|
||||
credentialsId: account.jenkinsAwsCredentialsId]]) {
|
||||
dir(workDir) {
|
||||
sh """
|
||||
export AWS_DEFAULT_REGION=${account.region}
|
||||
export CDK_DEFAULT_ACCOUNT=${account.accountId ?: ''}
|
||||
export CDK_DEFAULT_REGION=${account.region}
|
||||
${cdkCommand}
|
||||
"""
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deploy a CDK stack
|
||||
*
|
||||
* @param args.account Account configuration
|
||||
* @param args.stackName Name of the stack
|
||||
* @param args.stackType Type of stack (vpc, ecs-cluster, ecs-service)
|
||||
* @param args.context Additional context values
|
||||
*/
|
||||
def deploy(Map args) {
|
||||
def context = args.context ?: [:]
|
||||
|
||||
// Add stackType and stackName to context
|
||||
context.stackType = args.stackType ?: "vpc"
|
||||
context.stackName = args.stackName
|
||||
context.region = args.account.region
|
||||
|
||||
runCdk(
|
||||
account: args.account,
|
||||
command: "deploy",
|
||||
stackName: args.stackName,
|
||||
context: context,
|
||||
workDir: args.workDir ?: "cdk-source"
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Show diff for a CDK stack
|
||||
*/
|
||||
def diff(Map args) {
|
||||
def context = args.context ?: [:]
|
||||
context.stackType = args.stackType ?: "vpc"
|
||||
context.stackName = args.stackName
|
||||
context.region = args.account.region
|
||||
|
||||
runCdk(
|
||||
account: args.account,
|
||||
command: "diff",
|
||||
stackName: args.stackName,
|
||||
context: context,
|
||||
workDir: args.workDir ?: "cdk-source"
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Synthesize CloudFormation template for a CDK stack
|
||||
*/
|
||||
def synth(Map args) {
|
||||
def context = args.context ?: [:]
|
||||
context.stackType = args.stackType ?: "vpc"
|
||||
context.stackName = args.stackName
|
||||
context.region = args.account.region
|
||||
|
||||
runCdk(
|
||||
account: args.account,
|
||||
command: "synth",
|
||||
stackName: args.stackName,
|
||||
context: context,
|
||||
workDir: args.workDir ?: "cdk-source"
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Destroy a CDK stack
|
||||
*/
|
||||
def destroy(Map args) {
|
||||
def context = args.context ?: [:]
|
||||
context.stackType = args.stackType ?: "vpc"
|
||||
context.stackName = args.stackName
|
||||
context.region = args.account.region
|
||||
|
||||
runCdk(
|
||||
account: args.account,
|
||||
command: "destroy",
|
||||
stackName: args.stackName,
|
||||
context: context,
|
||||
workDir: args.workDir ?: "cdk-source"
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Install CDK workspace files from library resources.
|
||||
* Assumes CDK/TypeScript toolchain is available globally on the agent.
|
||||
*/
|
||||
def install(Map args = [:]) {
|
||||
def workDir = args.workDir ?: "cdk-source"
|
||||
dir(workDir) {
|
||||
// Ensure expected directory layout exists before copying files from resources
|
||||
sh "mkdir -pv bin lib lib/constructs lib/stacks"
|
||||
|
||||
// Copy CDK project files from shared library resources into the workspace
|
||||
[
|
||||
[source: "package.json", destination: "package.json"],
|
||||
[source: "pnpm-lock.yaml", destination: "pnpm-lock.yaml"],
|
||||
[source: "tsconfig.json", destination: "tsconfig.json"],
|
||||
[source: "cdk.json", destination: "cdk.json"],
|
||||
[source: "cdk.context.json", destination: "cdk.context.json"],
|
||||
[source: "bin/spicy-cdk.ts", destination: "bin/spicy-cdk.ts"],
|
||||
[source: "lib/index.ts", destination: "lib/index.ts"],
|
||||
[source: "lib/constructs/index.ts", destination: "lib/constructs/index.ts"],
|
||||
[source: "lib/constructs/spicy-alb.ts", destination: "lib/constructs/spicy-alb.ts"],
|
||||
[source: "lib/constructs/spicy-ecs-cluster.ts", destination: "lib/constructs/spicy-ecs-cluster.ts"],
|
||||
[source: "lib/constructs/spicy-ecs-service.ts", destination: "lib/constructs/spicy-ecs-service.ts"],
|
||||
[source: "lib/constructs/spicy-vpc.ts", destination: "lib/constructs/spicy-vpc.ts"],
|
||||
[source: "lib/stacks/index.ts", destination: "lib/stacks/index.ts"],
|
||||
[source: "lib/stacks/spicy-alb-stack.ts", destination: "lib/stacks/spicy-alb-stack.ts"],
|
||||
[source: "lib/stacks/spicy-ecs-cluster-stack.ts", destination: "lib/stacks/spicy-ecs-cluster-stack.ts"],
|
||||
[source: "lib/stacks/spicy-ecs-service-stack.ts", destination: "lib/stacks/spicy-ecs-service-stack.ts"],
|
||||
[source: "lib/stacks/spicy-vpc-stack.ts", destination: "lib/stacks/spicy-vpc-stack.ts"],
|
||||
].each { entry ->
|
||||
resources.copyResourceFile(
|
||||
source: entry.source,
|
||||
destination: entry.destination,
|
||||
overwrite: true
|
||||
)
|
||||
}
|
||||
|
||||
sh "pnpm install --frozen-lockfile"
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run CDK tests
|
||||
*/
|
||||
def test(Map args = [:]) {
|
||||
def workDir = args.workDir ?: "."
|
||||
dir(workDir) {
|
||||
sh "pnpm run test"
|
||||
}
|
||||
}
|
||||
|
||||
return this
|
||||
|
||||
219
vars/dockerUtils.groovy
Normal file
219
vars/dockerUtils.groovy
Normal file
@@ -0,0 +1,219 @@
|
||||
/**
|
||||
* Docker utilities for Spicy CDK pipelines
|
||||
* Pushes images to Nexus repository (Sonatype)
|
||||
*/
|
||||
|
||||
// Default Nexus configuration
|
||||
def getDefaultConfig() {
|
||||
return [
|
||||
registry: 'nexus.kodeniks.com',
|
||||
repository: 'docker-hosted',
|
||||
credentialsId: 'kodeniks-nexus-repository'
|
||||
]
|
||||
}
|
||||
|
||||
def getImageRef(Map args, String tag) {
|
||||
def config = getDefaultConfig() + args
|
||||
return "${config.registry}/${config.repository}/${args.imageName}:${tag}"
|
||||
}
|
||||
|
||||
def getImageTag(Map args) {
|
||||
return args.imageTag ?: gitUtils.getShortSHA()
|
||||
}
|
||||
|
||||
def getImageRefSHA(Map args) {
|
||||
return getImageRef(args, getImageTag(args))
|
||||
}
|
||||
|
||||
def getImageRefLatest(Map args) {
|
||||
return getImageRef(args, 'latest')
|
||||
}
|
||||
|
||||
def copyFromImage(Map args) {
|
||||
sh(
|
||||
script: """#!/bin/bash +x
|
||||
set -e
|
||||
IMG_ID=\$(dd if=/dev/urandom bs=1k count=1 2> /dev/null | LC_CTYPE=C tr -cd "a-z0-9" | cut -c 1-22)
|
||||
docker create --name \${IMG_ID} ${args.imageID}
|
||||
docker cp \${IMG_ID}:${args.dockerPath} ${args.jenkinsPath}
|
||||
docker rm \${IMG_ID}
|
||||
"""
|
||||
)
|
||||
}
|
||||
|
||||
def getContainerName(Map args) {
|
||||
return "${args.imageName}-${gitUtils.getShortSHA()}"
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a Docker image
|
||||
* @param args.imageName - Name of the image
|
||||
* @param args.dockerfile - Path to Dockerfile (default: 'Dockerfile')
|
||||
* @param args.context - Build context (default: '.')
|
||||
* @param args.buildArgs - Map of build arguments (optional)
|
||||
*/
|
||||
def buildImage(Map args) {
|
||||
def containerName = getContainerName(args)
|
||||
def dockerfile = args.dockerfile ?: 'Dockerfile'
|
||||
def context = args.context ?: '.'
|
||||
|
||||
def buildArgsString = ''
|
||||
if (args.buildArgs) {
|
||||
buildArgsString = args.buildArgs.collect { k, v -> "--build-arg ${k}=${v}" }.join(' ')
|
||||
}
|
||||
|
||||
sh("docker build -t ${containerName} -f ${dockerfile} ${buildArgsString} ${context}")
|
||||
return containerName
|
||||
}
|
||||
|
||||
/**
|
||||
* Tag image for Nexus registry
|
||||
* @param args.imageName - Name of the image
|
||||
* @param args.tagLatest - Whether to also tag as 'latest' (default: true on main branch)
|
||||
*/
|
||||
def tagImage(Map args) {
|
||||
def containerName = getContainerName(args)
|
||||
def tagLatest = args.tagLatest != null ? args.tagLatest : gitUtils.isMain()
|
||||
|
||||
def shaImageRef = getImageRefSHA(args)
|
||||
sh("docker tag ${containerName} ${shaImageRef}")
|
||||
if (tagLatest) {
|
||||
// Tag latest from the SHA-tagged image to ensure they point to the same image
|
||||
sh("docker tag ${shaImageRef} ${getImageRefLatest(args)}")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Push image to Nexus registry
|
||||
* Optionally compares with remote 'latest' to skip push if unchanged
|
||||
* @param args.imageName - Name of the image
|
||||
* @param args.credentialsId - Jenkins credentials ID for Nexus (default: 'kodeniks-nexus-repository')
|
||||
* @param args.registry - Nexus registry URL (default: 'nexus.kodeniks.com')
|
||||
* @param args.repository - Nexus repository name (default: 'docker-hosted')
|
||||
* @param args.tagLatest - Whether to also push 'latest' tag (default: true on main branch)
|
||||
* @param args.compareWithLatest - Skip push if image matches remote 'latest' (default: true)
|
||||
*/
|
||||
def pushImage(Map args) {
|
||||
def config = getDefaultConfig() + args
|
||||
def tagLatest = args.tagLatest != null ? args.tagLatest : gitUtils.isMain()
|
||||
def compareWithLatest = args.compareWithLatest != null ? args.compareWithLatest : true
|
||||
|
||||
def localImageId = sh(
|
||||
script: "docker inspect -f '{{.Id}}' ${getImageRefSHA(args)}",
|
||||
returnStdout: true
|
||||
).trim()
|
||||
echo "Local image ID: ${localImageId}"
|
||||
|
||||
def remoteImageId = ""
|
||||
if (compareWithLatest && tagLatest) {
|
||||
withCredentials([usernamePassword(
|
||||
credentialsId: config.credentialsId,
|
||||
usernameVariable: 'NEXUS_USER',
|
||||
passwordVariable: 'NEXUS_PASS'
|
||||
)]) {
|
||||
try {
|
||||
sh "echo \$NEXUS_PASS | docker login ${config.registry} -u \$NEXUS_USER --password-stdin"
|
||||
sh "docker pull ${getImageRefLatest(args)}"
|
||||
remoteImageId = sh(
|
||||
script: "docker inspect -f '{{.Id}}' ${getImageRefLatest(args)}",
|
||||
returnStdout: true
|
||||
).trim()
|
||||
echo "Remote (latest) image ID: ${remoteImageId}"
|
||||
} catch (Exception e) {
|
||||
echo "Could not pull remote 'latest' image (might be the first build): ${e.getMessage()}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (remoteImageId && localImageId == remoteImageId) {
|
||||
echo "No changes detected (new image is identical to remote 'latest'). Skipping push."
|
||||
return false
|
||||
}
|
||||
|
||||
// Re-tag latest from SHA image right before pushing
|
||||
// This ensures latest points to the new image even if we pulled the old remote latest for comparison
|
||||
if (tagLatest) {
|
||||
sh("docker tag ${getImageRefSHA(args)} ${getImageRefLatest(args)}")
|
||||
}
|
||||
|
||||
withCredentials([usernamePassword(
|
||||
credentialsId: config.credentialsId,
|
||||
usernameVariable: 'NEXUS_USER',
|
||||
passwordVariable: 'NEXUS_PASS'
|
||||
)]) {
|
||||
sh "echo \$NEXUS_PASS | docker login ${config.registry} -u \$NEXUS_USER --password-stdin"
|
||||
sh "docker push ${getImageRefSHA(args)}"
|
||||
if (tagLatest) {
|
||||
sh "docker push ${getImageRefLatest(args)}"
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Build and push a Docker image to Nexus
|
||||
* @param args.imageName - Name of the image (required)
|
||||
* @param args.dockerfile - Path to Dockerfile (default: 'Dockerfile')
|
||||
* @param args.context - Build context (default: '.')
|
||||
* @param args.buildArgs - Map of build arguments (optional)
|
||||
* @param args.credentialsId - Jenkins credentials ID for Nexus (default: 'kodeniks-nexus-repository')
|
||||
* @param args.registry - Nexus registry URL (default: 'nexus.kodeniks.com')
|
||||
* @param args.repository - Nexus repository name (default: 'docker-hosted')
|
||||
* @param args.tagLatest - Whether to also tag/push 'latest' (default: true on main branch)
|
||||
* @param args.compareWithLatest - Skip push if image matches remote 'latest' (default: true)
|
||||
* @return The full image reference (registry/repo/name:tag)
|
||||
*/
|
||||
def buildAndPush(Map args) {
|
||||
buildImage(args)
|
||||
tagImage(args)
|
||||
pushImage(args)
|
||||
|
||||
return getImageRefSHA(args)
|
||||
}
|
||||
|
||||
/**
|
||||
* Prune dangling Docker images
|
||||
* @param args.pruneCache - Whether to also prune build cache (default: false)
|
||||
*/
|
||||
def prune(Map args = [:]) {
|
||||
def pruneCache = args.pruneCache ?: false
|
||||
echo "Cleaning up dangling Docker images..."
|
||||
if (pruneCache) {
|
||||
sh 'docker system prune -f'
|
||||
} else {
|
||||
// Only prune dangling images, not build cache
|
||||
sh 'docker image prune -f'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Login to Nexus registry
|
||||
*/
|
||||
def login(Map args = [:]) {
|
||||
def config = getDefaultConfig() + args
|
||||
|
||||
withCredentials([usernamePassword(
|
||||
credentialsId: config.credentialsId,
|
||||
usernameVariable: 'NEXUS_USER',
|
||||
passwordVariable: 'NEXUS_PASS'
|
||||
)]) {
|
||||
sh "echo \$NEXUS_PASS | docker login ${config.registry} -u \$NEXUS_USER --password-stdin"
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pull an image from Nexus
|
||||
*/
|
||||
def pullImage(Map args) {
|
||||
def config = getDefaultConfig() + args
|
||||
def tag = args.tag ?: 'latest'
|
||||
def imageRef = getImageRef(args, tag)
|
||||
|
||||
login(config)
|
||||
sh "docker pull ${imageRef}"
|
||||
|
||||
return imageRef
|
||||
}
|
||||
|
||||
return this
|
||||
35
vars/gitUtils.groovy
Normal file
35
vars/gitUtils.groovy
Normal file
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* Git utilities for Spicy CDK pipelines
|
||||
*/
|
||||
|
||||
def getRemoteURL() {
|
||||
return sh(
|
||||
script: 'git config --get remote.origin.url',
|
||||
returnStdout: true
|
||||
).trim()
|
||||
}
|
||||
|
||||
def getSHA() {
|
||||
return sh(
|
||||
script: 'git rev-parse HEAD',
|
||||
returnStdout: true
|
||||
).trim()
|
||||
}
|
||||
|
||||
def getShortSHA() {
|
||||
return sh(
|
||||
script: 'git rev-parse --short HEAD',
|
||||
returnStdout: true
|
||||
).trim()
|
||||
}
|
||||
|
||||
def isMain() {
|
||||
return "${BRANCH_NAME}" == "main" || "${BRANCH_NAME}" == "master"
|
||||
}
|
||||
|
||||
def getBranchName() {
|
||||
return env.CHANGE_BRANCH ?: env.BRANCH_NAME
|
||||
}
|
||||
|
||||
return this
|
||||
|
||||
72
vars/giteaUtils.groovy
Normal file
72
vars/giteaUtils.groovy
Normal file
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* Gitea commit status utilities for Spicy Automation pipelines
|
||||
*
|
||||
* Uses the same credential as the Gitea plugin (Gitea Personal Access Token type).
|
||||
*/
|
||||
|
||||
def updateStatus(Map args) {
|
||||
try {
|
||||
// For Gitea, we use the HTTP API to update commit status
|
||||
// This requires a Gitea API token stored in Jenkins credentials
|
||||
def giteaUrl = args.giteaUrl ?: env.GITEA_URL ?: 'https://git.kodeniks.com'
|
||||
def credentialsId = args.credentialsId ?: 'kodeniks-gitea-token'
|
||||
|
||||
// Gitea Personal Access Token credentials store token in the password field
|
||||
withCredentials([usernamePassword(credentialsId: credentialsId, usernameVariable: 'GITEA_USER', passwordVariable: 'GITEA_TOKEN')]) {
|
||||
def repoPath = gitUtils.getRemoteURL()
|
||||
.replaceAll('.*[:/]([^/]+/[^/]+)\\.git$', '$1')
|
||||
|
||||
def sha = gitUtils.getSHA()
|
||||
def apiUrl = "${giteaUrl}/api/v1/repos/${repoPath}/statuses/${sha}"
|
||||
|
||||
def payload = [
|
||||
state: args.state,
|
||||
target_url: env.BUILD_URL ?: '',
|
||||
description: args.message ?: '',
|
||||
context: "ci/jenkins/${args.context}"
|
||||
]
|
||||
|
||||
def payloadJson = groovy.json.JsonOutput.toJson(payload)
|
||||
|
||||
// Use single quotes for shell, double quotes for curl args
|
||||
// $GITEA_TOKEN is a shell variable set by withCredentials
|
||||
sh "curl -s -X POST '${apiUrl}' -H 'Authorization: token '\"\$GITEA_TOKEN\"'' -H 'Content-Type: application/json' -d '${payloadJson}'"
|
||||
}
|
||||
} catch (err) {
|
||||
print "Error updating commit status, proceeding without updating: ${err.getClass().getSimpleName()}"
|
||||
}
|
||||
}
|
||||
|
||||
def setSuccess(context) {
|
||||
updateStatus(
|
||||
context: context,
|
||||
state: 'success',
|
||||
message: 'Completed'
|
||||
)
|
||||
}
|
||||
|
||||
def setPending(context) {
|
||||
updateStatus(
|
||||
context: context,
|
||||
state: 'pending',
|
||||
message: 'Pending'
|
||||
)
|
||||
}
|
||||
|
||||
def setFailed(context) {
|
||||
updateStatus(
|
||||
context: context,
|
||||
state: 'failure',
|
||||
message: 'Failed'
|
||||
)
|
||||
}
|
||||
|
||||
def setError(context) {
|
||||
updateStatus(
|
||||
context: context,
|
||||
state: 'error',
|
||||
message: 'Error'
|
||||
)
|
||||
}
|
||||
|
||||
return this
|
||||
61
vars/manualApproval.groovy
Normal file
61
vars/manualApproval.groovy
Normal file
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* Manual approval gate for Spicy CDK pipelines
|
||||
*
|
||||
* Usage:
|
||||
* manualApproval(
|
||||
* time: 4,
|
||||
* timeUnit: "HOURS",
|
||||
* message: "Deploy to Production"
|
||||
* ) {
|
||||
* // deployment code
|
||||
* }
|
||||
*/
|
||||
|
||||
def call(Map args, Closure body) {
|
||||
def userInput = null
|
||||
def aborted = false
|
||||
def allowConcurrentBuildsDuringInput = args.containsKey('allowConcurrentBuildsDuringInput') ? args.allowConcurrentBuildsDuringInput : true
|
||||
|
||||
stage("${args.message} - Approval") {
|
||||
if (allowConcurrentBuildsDuringInput) {
|
||||
spicyUtils.setPipelineProperties(args.pipelineProperties, [disableConcurrentBuilds()])
|
||||
}
|
||||
|
||||
try {
|
||||
timeout(time: args.time, unit: args.timeUnit) {
|
||||
input(
|
||||
message: "${args.message}?",
|
||||
parameters: (args.parameters ?: [])
|
||||
)
|
||||
}
|
||||
} catch (err) {
|
||||
try {
|
||||
def user = err.getCauses()[0].getUser()
|
||||
if (user.toString() == 'SYSTEM') {
|
||||
aborted = !args.runCommandsOnTimeout
|
||||
} else {
|
||||
aborted = true
|
||||
}
|
||||
} catch (ugh) {
|
||||
aborted = true
|
||||
}
|
||||
}
|
||||
|
||||
spicyUtils.setPipelineProperties(args.pipelineProperties, null)
|
||||
|
||||
if (aborted) {
|
||||
return -1
|
||||
}
|
||||
|
||||
catchError {
|
||||
if (args.passUserInputToBody) {
|
||||
body(userInput)
|
||||
} else {
|
||||
body()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return this
|
||||
|
||||
349
vars/npmUtils.groovy
Normal file
349
vars/npmUtils.groovy
Normal file
@@ -0,0 +1,349 @@
|
||||
/**
|
||||
* NPM utilities for Spicy CDK pipelines
|
||||
* Publishes packages to Nexus NPM repository
|
||||
*
|
||||
* TODO: Future enhancement - Add support for running builds in Docker containers
|
||||
* with configurable Node.js and pnpm versions via pipeline parameters:
|
||||
* - nodeVersion: '20', '22', '24', etc.
|
||||
* - pnpmVersion: '8', '9', '10', etc.
|
||||
* - useContainer: true/false to opt-in to containerized builds
|
||||
*/
|
||||
|
||||
// Default Nexus configuration
|
||||
def getDefaultConfig() {
|
||||
return [
|
||||
registry: 'nexus.kodeniks.com',
|
||||
repository: 'npm-hosted',
|
||||
credentialsId: 'kodeniks-nexus-repository'
|
||||
]
|
||||
}
|
||||
|
||||
/**
|
||||
* Retry an operation with exponential backoff
|
||||
* @param maxRetries - Maximum number of retry attempts (default: 3)
|
||||
* @param operation - Closure to execute
|
||||
* @return Result of the operation
|
||||
*/
|
||||
def retryWithBackoff(int maxRetries = 3, Closure operation) {
|
||||
def lastError
|
||||
for (int i = 0; i < maxRetries; i++) {
|
||||
try {
|
||||
return operation()
|
||||
} catch (Exception e) {
|
||||
lastError = e
|
||||
if (i < maxRetries - 1) {
|
||||
def delay = (i + 1) * 2 // 2s, 4s, 6s
|
||||
echo "Attempt ${i + 1}/${maxRetries} failed: ${e.message}"
|
||||
echo "Retrying in ${delay} seconds..."
|
||||
sleep(delay)
|
||||
}
|
||||
}
|
||||
}
|
||||
throw new Exception("Operation failed after ${maxRetries} attempts: ${lastError.message}", lastError)
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a field from package.json using Node.js
|
||||
* @param field - Field name to read (e.g., 'name', 'version')
|
||||
* @return Field value as string
|
||||
*/
|
||||
def readPackageJsonField(String field) {
|
||||
if (!fileExists('package.json')) {
|
||||
error("package.json not found")
|
||||
}
|
||||
return sh(
|
||||
script: "node -pe \"require('./package.json').${field}\"",
|
||||
returnStdout: true
|
||||
).trim()
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate package.json exists and has required fields
|
||||
* @throws error if validation fails
|
||||
*/
|
||||
def validatePackageJson() {
|
||||
if (!fileExists('package.json')) {
|
||||
error("package.json not found in workspace")
|
||||
}
|
||||
|
||||
try {
|
||||
// Read fields using Node.js
|
||||
def pkgName = readPackageJsonField('name')
|
||||
def pkgVersion = readPackageJsonField('version')
|
||||
|
||||
if (!pkgName || pkgName.isEmpty() || pkgName == 'undefined') {
|
||||
error("package.json missing required 'name' field")
|
||||
}
|
||||
|
||||
if (!pkgVersion || pkgVersion.isEmpty() || pkgVersion == 'undefined') {
|
||||
error("package.json missing required 'version' field")
|
||||
}
|
||||
|
||||
// Validate version format (semver: x.y.z or x.y.z-prerelease)
|
||||
if (!pkgVersion.matches(/^\d+\.\d+\.\d+(-.*)?$/)) {
|
||||
error("Invalid version format in package.json: ${pkgVersion}. Expected semver format (e.g., 1.0.0)")
|
||||
}
|
||||
|
||||
echo "✓ package.json validation passed: ${pkgName}@${pkgVersion}"
|
||||
return [name: pkgName, version: pkgVersion]
|
||||
} catch (Exception e) {
|
||||
error("Failed to parse package.json: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify that version was correctly updated in package.json
|
||||
* @param expectedVersion - The version that should be in package.json
|
||||
* @throws error if version doesn't match
|
||||
*/
|
||||
def verifyVersionUpdate(String expectedVersion) {
|
||||
def actualVersion = npmPackageVersion()
|
||||
|
||||
if (actualVersion != expectedVersion) {
|
||||
error("Version mismatch: expected ${expectedVersion}, but package.json has ${actualVersion}")
|
||||
}
|
||||
|
||||
// Also verify by reading version directly from package.json using Node.js
|
||||
try {
|
||||
def actualVersionFromFile = readPackageJsonField('version')
|
||||
if (actualVersionFromFile != expectedVersion) {
|
||||
error("Version in package.json (${actualVersionFromFile}) doesn't match expected (${expectedVersion})")
|
||||
}
|
||||
} catch (Exception e) {
|
||||
echo "WARNING: Could not verify version via package.json read: ${e.message}"
|
||||
}
|
||||
|
||||
echo "✓ Version verification passed: ${expectedVersion}"
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the full registry URL for npm
|
||||
* @param args.registry - Nexus registry URL (default: 'nexus.kodeniks.com')
|
||||
* @param args.repository - Nexus repository name (default: 'npm-hosted')
|
||||
*/
|
||||
def getRegistryUrl(Map args = [:]) {
|
||||
def config = getDefaultConfig() + args
|
||||
return "https://${config.registry}/repository/${config.repository}/"
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract scope from package name (e.g., '@kodeniks/package' -> '@kodeniks')
|
||||
* @param packageName - Package name (may include scope)
|
||||
*/
|
||||
def extractScope(String packageName) {
|
||||
if (packageName && packageName.startsWith('@')) {
|
||||
def parts = packageName.split('/')
|
||||
return parts.length > 0 ? parts[0] : null
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Get package name from package.json
|
||||
* @return Package name
|
||||
*/
|
||||
def npmPackageName() {
|
||||
if (!fileExists('package.json')) {
|
||||
error("package.json not found")
|
||||
}
|
||||
return sh(
|
||||
script: 'node -pe "require(\'./package.json\').name"',
|
||||
returnStdout: true
|
||||
).trim()
|
||||
}
|
||||
|
||||
/**
|
||||
* Get package version from package.json
|
||||
* @return Package version
|
||||
*/
|
||||
def npmPackageVersion() {
|
||||
if (!fileExists('package.json')) {
|
||||
error("package.json not found")
|
||||
}
|
||||
return sh(
|
||||
script: 'node -pe "require(\'./package.json\').version"',
|
||||
returnStdout: true
|
||||
).trim()
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure version is ready for publishing
|
||||
* Checks if current version is published on Nexus, auto-bumps patch if needed
|
||||
* Includes retry logic for network operations
|
||||
* @param args.packageName - Package name (required)
|
||||
* @param args.registry - Nexus registry URL (default: 'nexus.kodeniks.com')
|
||||
* @param args.repository - Nexus repository name (default: 'npm-hosted')
|
||||
* @param args.credentialsId - Jenkins credentials ID for Nexus (default: 'kodeniks-nexus-repository')
|
||||
* @param args.maxRetries - Maximum retry attempts for network calls (default: 3)
|
||||
* @return The version to publish
|
||||
*/
|
||||
def ensureVersion(Map args = [:]) {
|
||||
def config = getDefaultConfig() + args
|
||||
def packageName = args.packageName
|
||||
def maxRetries = args.maxRetries ?: 3
|
||||
|
||||
if (!packageName) {
|
||||
error("packageName is required for ensureVersion")
|
||||
}
|
||||
|
||||
// Validate package.json first
|
||||
validatePackageJson()
|
||||
|
||||
def registryUrl = getRegistryUrl(config)
|
||||
def currentVersion = npmPackageVersion()
|
||||
|
||||
echo "=========================================="
|
||||
echo "Version Check for: ${packageName}"
|
||||
echo "Current version: ${currentVersion}"
|
||||
echo "Registry: ${registryUrl}"
|
||||
echo "=========================================="
|
||||
|
||||
def versionOutput
|
||||
withCredentials([usernamePassword(
|
||||
credentialsId: config.credentialsId,
|
||||
usernameVariable: 'NEXUS_USER',
|
||||
passwordVariable: 'NEXUS_PASS'
|
||||
)]) {
|
||||
// Build package path for API URL (scoped: @scope/package -> @scope%2Fpackage)
|
||||
def packagePath = packageName.startsWith('@')
|
||||
? packageName.replace('/', '%2F')
|
||||
: packageName
|
||||
def apiUrl = "${registryUrl}${packagePath}"
|
||||
|
||||
echo "Fetching from: ${apiUrl}"
|
||||
|
||||
// Run version check with retry logic using curl + jq
|
||||
versionOutput = retryWithBackoff(maxRetries) {
|
||||
def output = sh(
|
||||
script: """
|
||||
# Get current version from package.json
|
||||
CURRENT_VERSION=\$(jq -r '.version' package.json)
|
||||
|
||||
# Fetch package metadata from Nexus
|
||||
RESPONSE=\$(curl -s -w "\\n%{http_code}" \\
|
||||
-H "Accept: application/json" \\
|
||||
-u "\${NEXUS_USER}:\${NEXUS_PASS}" \\
|
||||
"${apiUrl}")
|
||||
|
||||
HTTP_CODE=\$(echo "\$RESPONSE" | tail -n1)
|
||||
BODY=\$(echo "\$RESPONSE" | sed '\$d')
|
||||
|
||||
if [ "\$HTTP_CODE" = "200" ]; then
|
||||
# Extract published version (prefer dist-tags.latest, fallback to highest version)
|
||||
PUBLISHED_VERSION=\$(echo "\$BODY" | jq -r 'if ."dist-tags".latest then ."dist-tags".latest else (.versions | keys | sort_by(. | split(".") | map(tonumber)) | reverse | .[0]) end')
|
||||
|
||||
if [ "\$PUBLISHED_VERSION" = "null" ] || [ -z "\$PUBLISHED_VERSION" ]; then
|
||||
PUBLISHED_VERSION=""
|
||||
fi
|
||||
|
||||
echo "Published version found: \${PUBLISHED_VERSION}, current: \${CURRENT_VERSION}" >&2
|
||||
|
||||
if [ -n "\$PUBLISHED_VERSION" ]; then
|
||||
# Compare versions using semver
|
||||
# If current == published, or current < published, we need to bump from published
|
||||
# If current > published, we can use current as-is
|
||||
|
||||
# Check if versions are equal (exact string match)
|
||||
if [ "\$CURRENT_VERSION" = "\$PUBLISHED_VERSION" ]; then
|
||||
# Equal - bump from published
|
||||
BASE_VERSION="\${PUBLISHED_VERSION}"
|
||||
NEW_VERSION=\$(semver -i patch "\$BASE_VERSION")
|
||||
else
|
||||
# Compare versions using semver-aware sorting
|
||||
# sort -V handles version sorting correctly (1.0.1 < 1.0.11 < 1.1.0)
|
||||
# Get both versions, sort them, highest comes last
|
||||
SORTED=\$(printf "%s\\n%s" "\$CURRENT_VERSION" "\$PUBLISHED_VERSION" | sort -V)
|
||||
HIGHEST=\$(echo "\$SORTED" | tail -n1)
|
||||
LOWEST=\$(echo "\$SORTED" | head -n1)
|
||||
|
||||
# Verify sort worked correctly (should have exactly 2 versions)
|
||||
if [ "\$(echo "\$SORTED" | wc -l)" != "2" ]; then
|
||||
echo "ERROR: Version sorting failed" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ "\$HIGHEST" = "\$CURRENT_VERSION" ] && [ "\$LOWEST" = "\$PUBLISHED_VERSION" ]; then
|
||||
# Current > Published - no bump needed, use current
|
||||
BASE_VERSION="\${CURRENT_VERSION}"
|
||||
NEW_VERSION="\${CURRENT_VERSION}"
|
||||
elif [ "\$HIGHEST" = "\$PUBLISHED_VERSION" ] && [ "\$LOWEST" = "\$CURRENT_VERSION" ]; then
|
||||
# Published > Current - bump from published
|
||||
BASE_VERSION="\${PUBLISHED_VERSION}"
|
||||
NEW_VERSION=\$(semver -i patch "\$BASE_VERSION")
|
||||
else
|
||||
# Should not happen, but fallback to bumping from published
|
||||
echo "WARNING: Unexpected version comparison result" >&2
|
||||
BASE_VERSION="\${PUBLISHED_VERSION}"
|
||||
NEW_VERSION=\$(semver -i patch "\$BASE_VERSION")
|
||||
fi
|
||||
fi
|
||||
|
||||
# Update package.json if version changed
|
||||
if [ "\$NEW_VERSION" != "\$CURRENT_VERSION" ]; then
|
||||
jq --arg v "\$NEW_VERSION" '.version = \$v' package.json > package.json.tmp && mv package.json.tmp package.json
|
||||
echo "Bumped version from \${CURRENT_VERSION} to \${NEW_VERSION}" >&2
|
||||
fi
|
||||
|
||||
echo "\$NEW_VERSION"
|
||||
|
||||
# Update package.json
|
||||
jq --arg v "\$NEW_VERSION" '.version = \$v' package.json > package.json.tmp && mv package.json.tmp package.json
|
||||
|
||||
echo "Bumped version from \${BASE_VERSION} to \${NEW_VERSION}" >&2
|
||||
echo "\$NEW_VERSION"
|
||||
else
|
||||
# No published version, use current
|
||||
echo "\$CURRENT_VERSION"
|
||||
fi
|
||||
elif [ "\$HTTP_CODE" = "404" ]; then
|
||||
echo "Package not found (404), using current version" >&2
|
||||
echo "\$CURRENT_VERSION"
|
||||
else
|
||||
echo "Error: HTTP \$HTTP_CODE" >&2
|
||||
echo "\$BODY" >&2
|
||||
exit 1
|
||||
fi
|
||||
""",
|
||||
returnStdout: true
|
||||
).trim()
|
||||
|
||||
if (!output || output.isEmpty()) {
|
||||
throw new Exception("Version check returned empty output")
|
||||
}
|
||||
|
||||
return output
|
||||
}
|
||||
|
||||
// Verify the version was actually updated in package.json
|
||||
def actualVersion = npmPackageVersion()
|
||||
if (versionOutput != actualVersion) {
|
||||
echo "WARNING: Version mismatch detected!"
|
||||
echo " Script returned: ${versionOutput}"
|
||||
echo " package.json has: ${actualVersion}"
|
||||
echo " Attempting to use package.json version..."
|
||||
|
||||
// If script said to bump but package.json wasn't updated, try to sync
|
||||
if (versionOutput != currentVersion && actualVersion == currentVersion) {
|
||||
echo "Version bump didn't persist. This may indicate a file system issue."
|
||||
error("Version update failed: script returned ${versionOutput} but package.json still has ${actualVersion}")
|
||||
}
|
||||
|
||||
versionOutput = actualVersion
|
||||
}
|
||||
}
|
||||
|
||||
if (!versionOutput || versionOutput.isEmpty()) {
|
||||
error("Failed to get version. The version check returned empty.")
|
||||
}
|
||||
|
||||
// Final verification
|
||||
verifyVersionUpdate(versionOutput)
|
||||
|
||||
echo "=========================================="
|
||||
echo "✓ Version to publish: ${versionOutput}"
|
||||
echo "=========================================="
|
||||
|
||||
return versionOutput
|
||||
}
|
||||
|
||||
return this
|
||||
517
vars/publishNpmPackage.groovy
Normal file
517
vars/publishNpmPackage.groovy
Normal file
@@ -0,0 +1,517 @@
|
||||
/**
|
||||
* Publish NPM Package Pipeline
|
||||
*
|
||||
* A resilient pipeline for building and publishing npm packages to Nexus.
|
||||
*
|
||||
* TODO: Future enhancement - Add support for running builds in Docker containers
|
||||
* with configurable Node.js and pnpm versions via pipeline parameters:
|
||||
* - nodeVersion: '20', '22', '24', etc. (default: use agent's Node version)
|
||||
* - pnpmVersion: '8', '9', '10', etc. (default: use agent's pnpm version)
|
||||
* - useContainer: true/false to opt-in to containerized builds
|
||||
* This would allow projects to specify exact Node/pnpm versions for reproducible builds.
|
||||
*
|
||||
* Usage in Jenkinsfile:
|
||||
* ```groovy
|
||||
* @Library(["spicy-automation@main"]) _
|
||||
*
|
||||
* publishNpmPackage()
|
||||
* ```
|
||||
*
|
||||
* With scoped package:
|
||||
* ```groovy
|
||||
* @Library(["spicy-automation@main"]) _
|
||||
*
|
||||
* publishNpmPackage(
|
||||
* packageName: '@kodeniks/my-package',
|
||||
* )
|
||||
* ```
|
||||
*
|
||||
* With all options:
|
||||
* ```groovy
|
||||
* @Library(["spicy-automation@main"]) _
|
||||
*
|
||||
* publishNpmPackage(
|
||||
* packageName: '@kodeniks/my-package', // Scoped packages supported
|
||||
* registry: 'nexus.kodeniks.com',
|
||||
* repository: 'npm-hosted',
|
||||
* credentialsId: 'kodeniks-nexus-repository',
|
||||
* gitCredentialsId: 'kodeniks-gitea-token',
|
||||
* agentLabel: 'docker', // Agent must have Node.js installed
|
||||
* )
|
||||
* ```
|
||||
*/
|
||||
|
||||
def call(Map args = [:]) {
|
||||
def config = [
|
||||
packageName: args.packageName ?: args.name,
|
||||
registry: args.registry ?: 'nexus.kodeniks.com',
|
||||
repository: args.repository ?: 'npm-hosted',
|
||||
credentialsId: args.credentialsId ?: 'kodeniks-nexus-repository',
|
||||
gitCredentialsId: args.gitCredentialsId ?: 'kodeniks-gitea-token',
|
||||
agentLabel: args.agentLabel ?: 'docker',
|
||||
gitPushRetries: args.gitPushRetries ?: 3,
|
||||
]
|
||||
|
||||
// Store original git remote URL for cleanup
|
||||
def originalGitRemote = null
|
||||
|
||||
pipeline {
|
||||
agent {
|
||||
label config.agentLabel
|
||||
}
|
||||
|
||||
stages {
|
||||
stage('Checkout') {
|
||||
steps {
|
||||
checkout scm
|
||||
script {
|
||||
// Validate package name
|
||||
if (!config.packageName) {
|
||||
// Try to read from package.json
|
||||
try {
|
||||
npmUtils.validatePackageJson()
|
||||
config.packageName = npmUtils.npmPackageName()
|
||||
echo "Auto-detected package name: ${config.packageName}"
|
||||
} catch (Exception e) {
|
||||
error("Package name not found. Please provide packageName in the arguments or ensure package.json exists with a 'name' field.")
|
||||
}
|
||||
}
|
||||
|
||||
echo "=========================================="
|
||||
echo "Package: ${config.packageName}"
|
||||
echo "=========================================="
|
||||
|
||||
// Store original git remote for cleanup
|
||||
originalGitRemote = sh(
|
||||
script: 'git config --get remote.origin.url 2>/dev/null || echo ""',
|
||||
returnStdout: true
|
||||
).trim()
|
||||
env.GIT_ORIGINAL_REMOTE_URL = originalGitRemote
|
||||
|
||||
giteaUtils.setSuccess("checkout")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
stage('Get Version') {
|
||||
steps {
|
||||
script {
|
||||
echo "=========================================="
|
||||
echo "Stage: Get Version"
|
||||
echo "=========================================="
|
||||
|
||||
// Check if this build was triggered by a tag push
|
||||
// This prevents infinite loops where tag push → new build → version bump → tag push
|
||||
def isTagBuild = false
|
||||
def currentTag = null
|
||||
|
||||
// Check if HEAD is already a tag
|
||||
try {
|
||||
currentTag = sh(
|
||||
script: 'git describe --exact-match --tags HEAD 2>/dev/null || echo ""',
|
||||
returnStdout: true
|
||||
).trim()
|
||||
if (currentTag && !currentTag.isEmpty()) {
|
||||
isTagBuild = true
|
||||
echo "Detected tag build: HEAD is tagged as ${currentTag}"
|
||||
}
|
||||
} catch (Exception e) {
|
||||
// Not a tag, continue
|
||||
}
|
||||
|
||||
// Also check if BRANCH_NAME indicates a tag (Jenkins sets this for tag builds)
|
||||
if (!isTagBuild && env.BRANCH_NAME && env.BRANCH_NAME.startsWith('tags/')) {
|
||||
isTagBuild = true
|
||||
echo "Detected tag build: BRANCH_NAME is ${env.BRANCH_NAME}"
|
||||
}
|
||||
|
||||
// Check if commit message indicates this is a version bump commit
|
||||
if (!isTagBuild) {
|
||||
try {
|
||||
def commitMsg = sh(
|
||||
script: 'git log -1 --pretty=%B',
|
||||
returnStdout: true
|
||||
).trim()
|
||||
if (commitMsg.contains('chore: bump version') || commitMsg.contains('bump version')) {
|
||||
isTagBuild = true
|
||||
echo "Detected tag build: commit message indicates version bump: ${commitMsg}"
|
||||
}
|
||||
} catch (Exception e) {
|
||||
// Continue if we can't check commit message
|
||||
}
|
||||
}
|
||||
|
||||
if (isTagBuild) {
|
||||
echo "=========================================="
|
||||
echo "⚠️ Skipping publish: Build triggered by tag push"
|
||||
echo "This prevents infinite version bump loops"
|
||||
echo "=========================================="
|
||||
env.SKIP_PUBLISH = 'true'
|
||||
// Still set a version for consistency, but we won't publish
|
||||
def currentVersion = npmUtils.npmPackageVersion()
|
||||
env.PACKAGE_VERSION = currentVersion
|
||||
echo "Current package version: ${currentVersion}"
|
||||
giteaUtils.setSuccess("version")
|
||||
return
|
||||
}
|
||||
|
||||
// Validate package.json before version check
|
||||
npmUtils.validatePackageJson()
|
||||
|
||||
// Get version using npmUtils - checks Nexus and auto-bumps if needed
|
||||
def versionOutput = npmUtils.ensureVersion([
|
||||
packageName: config.packageName,
|
||||
registry: config.registry,
|
||||
repository: config.repository,
|
||||
credentialsId: config.credentialsId
|
||||
])
|
||||
|
||||
env.PACKAGE_VERSION = versionOutput
|
||||
echo "✓ Version determined: ${versionOutput}"
|
||||
giteaUtils.setSuccess("version")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
stage('Install Dependencies') {
|
||||
when {
|
||||
expression { env.SKIP_PUBLISH != 'true' }
|
||||
}
|
||||
steps {
|
||||
script {
|
||||
echo "=========================================="
|
||||
echo "Stage: Install Dependencies"
|
||||
echo "=========================================="
|
||||
|
||||
def lockFile = null
|
||||
def installCommand = null
|
||||
|
||||
if(fileExists("package-lock.json")) {
|
||||
lockFile = "package-lock.json"
|
||||
installCommand = "npm install"
|
||||
} else if(fileExists("pnpm-lock.yaml")) {
|
||||
lockFile = "pnpm-lock.yaml"
|
||||
installCommand = "pnpm install"
|
||||
} else if(fileExists("yarn.lock")) {
|
||||
lockFile = "yarn.lock"
|
||||
installCommand = "yarn install"
|
||||
} else {
|
||||
error("No lock file found. Please ensure you have a package-lock.json, pnpm-lock.yaml, or yarn.lock file in your repository.")
|
||||
}
|
||||
|
||||
echo "Using lock file: ${lockFile}"
|
||||
echo "Running: ${installCommand}"
|
||||
sh(installCommand)
|
||||
echo "✓ Dependencies installed"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
stage('Build Package') {
|
||||
when {
|
||||
expression { env.SKIP_PUBLISH != 'true' }
|
||||
}
|
||||
steps {
|
||||
script {
|
||||
echo "=========================================="
|
||||
echo "Stage: Build Package"
|
||||
echo "=========================================="
|
||||
|
||||
def buildCommand = null
|
||||
|
||||
if(fileExists("package-lock.json")) {
|
||||
buildCommand = "npm run build"
|
||||
} else if(fileExists("pnpm-lock.yaml")) {
|
||||
buildCommand = "pnpm run build"
|
||||
} else if(fileExists("yarn.lock")) {
|
||||
buildCommand = "yarn run build"
|
||||
} else {
|
||||
error("No lock file found. Please ensure you have a package-lock.json, pnpm-lock.yaml, or yarn.lock file in your repository.")
|
||||
}
|
||||
|
||||
echo "Running: ${buildCommand}"
|
||||
sh(buildCommand)
|
||||
echo "✓ Build completed"
|
||||
giteaUtils.setSuccess("build")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
stage('Publish Package') {
|
||||
when {
|
||||
expression { env.SKIP_PUBLISH != 'true' }
|
||||
}
|
||||
steps {
|
||||
script {
|
||||
echo "=========================================="
|
||||
echo "Stage: Publish Package"
|
||||
echo "=========================================="
|
||||
|
||||
// Get registry URL
|
||||
def registryUrl = npmUtils.getRegistryUrl([
|
||||
registry: config.registry,
|
||||
repository: config.repository
|
||||
])
|
||||
|
||||
// Extract scope if package is scoped
|
||||
def scope = npmUtils.extractScope(config.packageName)
|
||||
|
||||
withCredentials([usernamePassword(
|
||||
credentialsId: config.credentialsId,
|
||||
usernameVariable: 'NEXUS_USER',
|
||||
passwordVariable: 'NEXUS_PASS'
|
||||
)]) {
|
||||
// Configure npm registry and authentication
|
||||
sh("""
|
||||
npm config set registry ${registryUrl}
|
||||
npm config set _auth \$(echo -n "\${NEXUS_USER}:\${NEXUS_PASS}" | base64)
|
||||
npm config fix
|
||||
""")
|
||||
|
||||
// For scoped packages, configure scope-specific registry
|
||||
if (scope) {
|
||||
sh("npm config set ${scope}:registry ${registryUrl}")
|
||||
echo "Configured registry for scope: ${scope}"
|
||||
}
|
||||
|
||||
// Publish to Nexus with retry logic
|
||||
def published = false
|
||||
def lastError = null
|
||||
for (int i = 0; i < 3; i++) {
|
||||
try {
|
||||
sh("npm publish")
|
||||
published = true
|
||||
break
|
||||
} catch (Exception e) {
|
||||
lastError = e
|
||||
if (i < 2) {
|
||||
echo "Publish attempt ${i + 1} failed, retrying in 2 seconds..."
|
||||
sleep(2)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!published) {
|
||||
error("Failed to publish after 3 attempts: ${lastError.message}")
|
||||
}
|
||||
}
|
||||
|
||||
echo "✓ Successfully published: ${config.packageName}@${env.PACKAGE_VERSION}"
|
||||
giteaUtils.setSuccess("publish")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
stage('Commit and Push Changes') {
|
||||
when {
|
||||
expression { env.SKIP_PUBLISH != 'true' }
|
||||
}
|
||||
steps {
|
||||
script {
|
||||
echo "=========================================="
|
||||
echo "Stage: Commit and Push Changes"
|
||||
echo "=========================================="
|
||||
|
||||
// Check git state
|
||||
def gitDir = sh(
|
||||
script: 'git rev-parse --git-dir 2>/dev/null || echo ""',
|
||||
returnStdout: true
|
||||
).trim()
|
||||
|
||||
if (!gitDir) {
|
||||
echo "WARNING: Not in a git repository, skipping commit/push"
|
||||
return
|
||||
}
|
||||
|
||||
withCredentials([usernamePassword(
|
||||
credentialsId: config.gitCredentialsId,
|
||||
usernameVariable: 'GITEA_USER',
|
||||
passwordVariable: 'GITEA_TOKEN'
|
||||
)]) {
|
||||
// Get current remote URL
|
||||
def remoteUrl = originalGitRemote ?: sh(
|
||||
script: 'git config --get remote.origin.url',
|
||||
returnStdout: true
|
||||
).trim()
|
||||
|
||||
// Configure git user
|
||||
sh("""
|
||||
git config user.name "Jenkins"
|
||||
git config user.email "oss@kodeniks.com"
|
||||
""")
|
||||
|
||||
// Update remote URL to include credentials for authentication
|
||||
sh("""
|
||||
REMOTE_URL='${remoteUrl}'
|
||||
REPO_PATH=\$(echo "\$REMOTE_URL" | sed 's|.*git\\.kodeniks\\.com/||' | sed 's|\\.git\$||')
|
||||
AUTH_URL="https://\${GITEA_USER}:\${GITEA_TOKEN}@git.kodeniks.com/\$REPO_PATH.git"
|
||||
git remote set-url origin "\$AUTH_URL"
|
||||
""")
|
||||
|
||||
// Determine which lock files to add
|
||||
def filesToAdd = ["package.json"]
|
||||
if(fileExists("package-lock.json")) {
|
||||
filesToAdd.add("package-lock.json")
|
||||
} else if(fileExists("pnpm-lock.yaml")) {
|
||||
filesToAdd.add("pnpm-lock.yaml")
|
||||
} else if(fileExists("yarn.lock")) {
|
||||
filesToAdd.add("yarn.lock")
|
||||
}
|
||||
|
||||
// Add files to staging
|
||||
echo "Adding files to git: ${filesToAdd.join(', ')}"
|
||||
sh("git add ${filesToAdd.join(' ')} 2>/dev/null || true")
|
||||
|
||||
// Check if there are staged changes
|
||||
// Use a simpler approach: check if git diff --staged has any output
|
||||
def stagedDiff = sh(
|
||||
script: 'git diff --staged --name-only',
|
||||
returnStdout: true
|
||||
).trim()
|
||||
|
||||
def hasChanges = !stagedDiff.isEmpty()
|
||||
echo "Staged changes check: ${hasChanges ? 'Changes found' : 'No changes'}"
|
||||
if (hasChanges) {
|
||||
echo "Files with changes: ${stagedDiff}"
|
||||
}
|
||||
|
||||
if (hasChanges) {
|
||||
echo "Committing changes..."
|
||||
sh("git commit -m 'chore: bump version to ${env.PACKAGE_VERSION}'")
|
||||
echo "✓ Committed version bump to ${env.PACKAGE_VERSION}"
|
||||
} else {
|
||||
echo "No changes to commit (version may have been committed already or package.json unchanged)"
|
||||
// Verify what's actually in package.json vs what we expect
|
||||
try {
|
||||
def actualVersion = npmUtils.npmPackageVersion()
|
||||
echo "Current package.json version: ${actualVersion}, expected: ${env.PACKAGE_VERSION}"
|
||||
} catch (Exception e) {
|
||||
echo "Could not read package.json version: ${e.message}"
|
||||
}
|
||||
}
|
||||
|
||||
// Get branch name with fallbacks
|
||||
def branchName = gitUtils.getBranchName()
|
||||
if (!branchName || branchName.isEmpty() || branchName == 'HEAD') {
|
||||
// Try multiple fallback strategies
|
||||
branchName = sh(
|
||||
script: '''
|
||||
git rev-parse --abbrev-ref HEAD 2>/dev/null || \
|
||||
git rev-parse --abbrev-ref --symbolic-full-name @{u} 2>/dev/null | sed "s|.*/||" || \
|
||||
echo "master"
|
||||
''',
|
||||
returnStdout: true
|
||||
).trim()
|
||||
}
|
||||
|
||||
echo "Using branch: ${branchName}"
|
||||
|
||||
// Create tag
|
||||
sh("""
|
||||
git tag -f v${env.PACKAGE_VERSION} 2>/dev/null || git tag v${env.PACKAGE_VERSION}
|
||||
""")
|
||||
|
||||
// Push with retry logic
|
||||
def pushSuccess = false
|
||||
def pushError = null
|
||||
|
||||
for (int i = 0; i < config.gitPushRetries; i++) {
|
||||
try {
|
||||
// Verify remote is accessible
|
||||
sh("git ls-remote origin > /dev/null 2>&1 || true")
|
||||
|
||||
// Push branch
|
||||
sh("git push origin HEAD:refs/heads/${branchName}")
|
||||
|
||||
// Push tag
|
||||
sh("git push origin v${env.PACKAGE_VERSION}")
|
||||
|
||||
// Verify push succeeded by checking if tag exists on remote
|
||||
// grep -q returns exit code 0 if found, 1 if not found
|
||||
def tagCheck = sh(
|
||||
script: "git ls-remote --tags origin v${env.PACKAGE_VERSION} | grep -q v${env.PACKAGE_VERSION}",
|
||||
returnStatus: true
|
||||
)
|
||||
|
||||
if (tagCheck != 0) {
|
||||
throw new Exception("Tag verification failed: tag v${env.PACKAGE_VERSION} not found on remote")
|
||||
}
|
||||
|
||||
echo "✓ Tag v${env.PACKAGE_VERSION} verified on remote"
|
||||
|
||||
pushSuccess = true
|
||||
break
|
||||
} catch (Exception e) {
|
||||
pushError = e
|
||||
if (i < config.gitPushRetries - 1) {
|
||||
echo "Push attempt ${i + 1} failed: ${e.message}"
|
||||
echo "Retrying in 2 seconds..."
|
||||
sleep(2)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!pushSuccess) {
|
||||
error("Failed to push to git after ${config.gitPushRetries} attempts: ${pushError.message}")
|
||||
}
|
||||
|
||||
// Restore original remote URL
|
||||
if (originalGitRemote) {
|
||||
sh("git remote set-url origin '${originalGitRemote}' || true")
|
||||
}
|
||||
}
|
||||
|
||||
echo "✓ Committed and pushed version ${env.PACKAGE_VERSION} with tag v${env.PACKAGE_VERSION}"
|
||||
giteaUtils.setSuccess("commit-push")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
post {
|
||||
always {
|
||||
script {
|
||||
// Cleanup npm config
|
||||
try {
|
||||
sh("npm config delete registry || true")
|
||||
sh("npm config delete _auth || true")
|
||||
// Clean up scoped registry configs
|
||||
def scope = npmUtils.extractScope(config.packageName)
|
||||
if (scope) {
|
||||
sh("npm config delete ${scope}:registry || true")
|
||||
}
|
||||
} catch (Exception e) {
|
||||
echo "Warning: Failed to clean up npm config: ${e.message}"
|
||||
}
|
||||
|
||||
// Restore git remote if changed
|
||||
try {
|
||||
if (env.GIT_ORIGINAL_REMOTE_URL) {
|
||||
sh("git remote set-url origin '${env.GIT_ORIGINAL_REMOTE_URL}' || true")
|
||||
}
|
||||
} catch (Exception e) {
|
||||
echo "Warning: Failed to restore git remote: ${e.message}"
|
||||
}
|
||||
}
|
||||
}
|
||||
success {
|
||||
script {
|
||||
giteaUtils.setSuccess("pipeline")
|
||||
echo "=========================================="
|
||||
echo "✓ NPM package publish completed successfully!"
|
||||
echo "=========================================="
|
||||
}
|
||||
}
|
||||
failure {
|
||||
script {
|
||||
giteaUtils.setFailed("pipeline")
|
||||
echo "=========================================="
|
||||
echo "✗ Publish failed!"
|
||||
echo "=========================================="
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return this
|
||||
49
vars/resources.groovy
Normal file
49
vars/resources.groovy
Normal file
@@ -0,0 +1,49 @@
|
||||
def getInputStream(resourcePath) {
|
||||
def input = libraryResource(resourcePath)
|
||||
return new ByteArrayInputStream(input.getBytes())
|
||||
}
|
||||
|
||||
def getProperties(resourcePath) {
|
||||
def stream = getInputStream(resourcePath)
|
||||
def properties = new Properties()
|
||||
properties.load(stream)
|
||||
return properties
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Generates a path to a temporary file location, ending with {@code path} parameter.
|
||||
*
|
||||
* @param path path suffix
|
||||
* @return path to file inside a temp directory
|
||||
*/
|
||||
@NonCPS
|
||||
String createTempLocation(String path) {
|
||||
String tmpDir = pwd tmp: true
|
||||
return tmpDir + File.separator + new File(path).getName()
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the path to a temp location of a script from the global library (resources/ subdirectory)
|
||||
*
|
||||
* @param source path within the resources/ subdirectory of this repo
|
||||
* @param destination destination path (optional)
|
||||
* @param overwrite file (optional)
|
||||
* @return path to local file
|
||||
*/
|
||||
String copyResourceFile(Map args) {
|
||||
def destination = args.destination ?: createTempLocation(args.source)
|
||||
def testDestination = new File(destination)
|
||||
try {
|
||||
if(!testDestination.exists() || args.overwrite == true) {
|
||||
writeFile file: destination, text: libraryResource(args.source)
|
||||
echo "copyResourceFile: copied ${args.source} to ${destination}"
|
||||
} else {
|
||||
echo "copyResourceFile: ${destination} already exists... to replace use copyResourceFile(overwrite: true)"
|
||||
}
|
||||
} catch(err) {
|
||||
echo("Unable to locate file: ${args.source}")
|
||||
echo err.toString()
|
||||
}
|
||||
return destination
|
||||
}
|
||||
18
vars/spicyDefaults.groovy
Normal file
18
vars/spicyDefaults.groovy
Normal file
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* Default pipeline properties for Spicy CDK pipelines
|
||||
*/
|
||||
|
||||
def call(Map args) {
|
||||
return args + [
|
||||
/*
|
||||
* Properties for the Jenkins Pipeline. Does not allow concurrent builds by default.
|
||||
* If you'd like to schedule a job using a cron syntax, set pipelineProperties to:
|
||||
*
|
||||
* [disableConcurrentBuilds(), pipelineTriggers([cron('H 13 * * *')])]
|
||||
*/
|
||||
pipelineProperties: [disableConcurrentBuilds()].plus(args.pipelineProperties ?: []),
|
||||
]
|
||||
}
|
||||
|
||||
return this
|
||||
|
||||
199
vars/spicyECSCluster.groovy
Normal file
199
vars/spicyECSCluster.groovy
Normal file
@@ -0,0 +1,199 @@
|
||||
/**
|
||||
* Spicy ECS Cluster Pipeline
|
||||
*
|
||||
* Deploys an ECS cluster using AWS CDK with:
|
||||
* - EC2 Capacity Provider with managed scaling
|
||||
* - Optional Fargate capacity providers (FARGATE + FARGATE_SPOT)
|
||||
* - Mixed instances policy for Spot support
|
||||
* - Instance draining on termination
|
||||
* - Optional internal/external ALBs
|
||||
*
|
||||
* Usage in Jenkinsfile:
|
||||
* ```groovy
|
||||
* @Library(["spicy-automation@main"]) _
|
||||
*
|
||||
* spicyECSCluster(
|
||||
* jenkinsAwsCredentialsId: "aws-credentials",
|
||||
* region: "ca-central-1",
|
||||
* stackName: "spicy-ecs-cluster",
|
||||
* vpcStackName: "production-vpc",
|
||||
* ownerTag: "SpicyTeam",
|
||||
* productTag: "spicy",
|
||||
* componentTag: "ecs-cluster",
|
||||
* environment: "dev",
|
||||
* instanceType: "m5a.large",
|
||||
* minClusterSize: 2,
|
||||
* maxClusterSize: 4,
|
||||
* spotEnabled: true,
|
||||
* onDemandPercentage: 20,
|
||||
* createExternalLoadBalancer: true,
|
||||
* createInternalLoadBalancer: true,
|
||||
* certificateArn: "arn:aws:acm:ca-central-1:123456789:certificate/xxx",
|
||||
* )
|
||||
* ```
|
||||
*/
|
||||
|
||||
def call(Map args) {
|
||||
args = spicyDefaults(args)
|
||||
|
||||
timeout(time: 1, unit: "DAYS") {
|
||||
timestamps {
|
||||
node("docker") {
|
||||
properties(args.pipelineProperties)
|
||||
ansiColor("xterm") {
|
||||
|
||||
stage("Checkout") {
|
||||
checkout scm
|
||||
giteaUtils.setSuccess("checkout")
|
||||
}
|
||||
|
||||
stage("Setup") {
|
||||
cdkUtils.install()
|
||||
giteaUtils.setSuccess("setup")
|
||||
}
|
||||
|
||||
if (args.onPreDeploy) {
|
||||
spicyUtils.stageWithFailure("PreDeploy") {
|
||||
args.onPreDeploy.call(args, [:])
|
||||
}
|
||||
}
|
||||
|
||||
stage("Deploy ECS Cluster") {
|
||||
if (gitUtils.isMain()) {
|
||||
try {
|
||||
def context = buildEcsClusterContext(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-cluster",
|
||||
context: context
|
||||
)
|
||||
}
|
||||
|
||||
// Manual approval for production
|
||||
if (args.environment == 'prod' || args.environment == 'production') {
|
||||
manualApproval(
|
||||
message: "Deploy ECS cluster ${args.stackName} to production?",
|
||||
submitter: args.approvers ?: ''
|
||||
)
|
||||
}
|
||||
|
||||
// Deploy the stack
|
||||
echo "Deploying ECS cluster stack: ${args.stackName}"
|
||||
cdkUtils.deploy(
|
||||
account: account,
|
||||
stackName: args.stackName,
|
||||
stackType: "ecs-cluster",
|
||||
context: context
|
||||
)
|
||||
|
||||
giteaUtils.setSuccess("deploy")
|
||||
|
||||
if (args.onPostDeploy) {
|
||||
spicyUtils.stageWithFailure("PostDeploy") {
|
||||
args.onPostDeploy.call(args, [
|
||||
stackName: args.stackName
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
} 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) {
|
||||
def context = buildEcsClusterContext(args)
|
||||
def account = awsUtils.buildAccountConfig(args)
|
||||
|
||||
echo "Showing CDK diff for PR review..."
|
||||
try {
|
||||
cdkUtils.diff(
|
||||
account: account,
|
||||
stackName: args.stackName,
|
||||
stackType: "ecs-cluster",
|
||||
context: context
|
||||
)
|
||||
} catch (err) {
|
||||
echo "Diff failed (stack may not exist yet): ${err.message}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
giteaUtils.setSuccess("pipeline")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Build CDK context map from pipeline arguments
|
||||
*/
|
||||
def buildEcsClusterContext(Map args) {
|
||||
def context = [:]
|
||||
|
||||
// Required: VPC stack name (all VPC details imported from VPC stack exports)
|
||||
context.vpcStackName = args.vpcStackName
|
||||
if (!args.numberOfAzs) {
|
||||
error("numberOfAzs is required for ECS cluster; pass the same value used for the VPC stack (2-4).")
|
||||
}
|
||||
context.numberOfAzs = args.numberOfAzs.toString()
|
||||
|
||||
// Required tags
|
||||
context.ownerTag = args.ownerTag
|
||||
context.productTag = args.productTag
|
||||
context.componentTag = args.componentTag ?: 'ecs-cluster'
|
||||
context.environment = args.environment ?: 'dev'
|
||||
context.build = args.build ?: gitUtils.getShortSHA()
|
||||
|
||||
// Instance configuration
|
||||
if (args.instanceType) context.instanceType = args.instanceType
|
||||
if (args.additionalInstanceTypes) context.additionalInstanceTypes = args.additionalInstanceTypes
|
||||
if (args.keyName) context.keyName = args.keyName
|
||||
if (args.ebsVolumeSize) context.ebsVolumeSize = args.ebsVolumeSize.toString()
|
||||
|
||||
// Container Insights
|
||||
if (args.containerInsights != null) context.containerInsights = args.containerInsights.toString()
|
||||
|
||||
// Scaling configuration
|
||||
if (args.minClusterSize) context.minClusterSize = args.minClusterSize.toString()
|
||||
if (args.maxClusterSize) context.maxClusterSize = args.maxClusterSize.toString()
|
||||
if (args.targetCapacityPercent) context.targetCapacityPercent = args.targetCapacityPercent.toString()
|
||||
|
||||
// Spot configuration
|
||||
if (args.spotEnabled != null) context.spotEnabled = args.spotEnabled.toString()
|
||||
if (args.onDemandPercentage != null) context.onDemandPercentage = args.onDemandPercentage.toString()
|
||||
if (args.spotAllocationStrategy) context.spotAllocationStrategy = args.spotAllocationStrategy
|
||||
|
||||
// Load balancer configuration
|
||||
if (args.createExternalLoadBalancer != null) {
|
||||
context.createExternalLoadBalancer = args.createExternalLoadBalancer.toString()
|
||||
}
|
||||
if (args.createInternalLoadBalancer != null) {
|
||||
context.createInternalLoadBalancer = args.createInternalLoadBalancer.toString()
|
||||
}
|
||||
if (args.certificateArn) context.certificateArn = args.certificateArn
|
||||
|
||||
// Fargate configuration (enables both FARGATE and FARGATE_SPOT capacity providers)
|
||||
if (args.enableFargate != null) context.enableFargate = args.enableFargate.toString()
|
||||
|
||||
// Timeouts
|
||||
if (args.drainingTimeout) context.drainingTimeout = args.drainingTimeout.toString()
|
||||
if (args.maxInstanceLifetime) context.maxInstanceLifetime = args.maxInstanceLifetime.toString()
|
||||
|
||||
return context
|
||||
}
|
||||
|
||||
return this
|
||||
|
||||
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
|
||||
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
|
||||
|
||||
45
vars/spicyUtils.groovy
Normal file
45
vars/spicyUtils.groovy
Normal file
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* Utility functions for Spicy CDK pipelines
|
||||
*/
|
||||
|
||||
def setPipelineProperties(baseProperties, propertiesToRemove) {
|
||||
def props = []
|
||||
if (baseProperties) {
|
||||
props.addAll(baseProperties)
|
||||
}
|
||||
if (propertiesToRemove) {
|
||||
props.removeAll(propertiesToRemove)
|
||||
}
|
||||
echo("Setting pipeline properties to ${props}")
|
||||
properties(props)
|
||||
}
|
||||
|
||||
def stageWithFailure(stageName, Map args = [:], Closure body) {
|
||||
customStage([
|
||||
stageName: stageName,
|
||||
failOnError: true
|
||||
] + args, body)
|
||||
}
|
||||
|
||||
def stageWithWarning(stageName, Map args = [:], Closure body) {
|
||||
customStage([
|
||||
stageName: stageName,
|
||||
failOnError: false
|
||||
] + args, body)
|
||||
}
|
||||
|
||||
def customStage(Map args, Closure body) {
|
||||
stage(args.stageName) {
|
||||
try {
|
||||
body()
|
||||
} catch (err) {
|
||||
if (args.failOnError) {
|
||||
throw err
|
||||
}
|
||||
println(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return this
|
||||
|
||||
189
vars/spicyVPC.groovy
Normal file
189
vars/spicyVPC.groovy
Normal file
@@ -0,0 +1,189 @@
|
||||
/**
|
||||
* Spicy VPC Pipeline
|
||||
*
|
||||
* Deploys a VPC using AWS CDK.
|
||||
*
|
||||
* Usage in Jenkinsfile:
|
||||
* ```groovy
|
||||
* @Library(["spicy-automation@main"]) _
|
||||
*
|
||||
* spicyVPC(
|
||||
* jenkinsAwsCredentialsId: "aws-credentials",
|
||||
* region: "ca-central-1",
|
||||
* stackName: "spicy-vpc",
|
||||
* ownerTag: "SpicyTeam",
|
||||
* productTag: "spicy",
|
||||
* componentTag: "spicy-VPC",
|
||||
* availabilityZones: "ca-central-1a,ca-central-1b,ca-central-1c,ca-central-1d",
|
||||
* createPrivateSubnets: true,
|
||||
* createAdditionalPrivateSubnets: true,
|
||||
* )
|
||||
* ```
|
||||
*/
|
||||
|
||||
def call(Map args) {
|
||||
args = spicyDefaults(args)
|
||||
|
||||
timeout(time: 1, unit: "DAYS") {
|
||||
timestamps {
|
||||
node("docker") {
|
||||
properties(args.pipelineProperties)
|
||||
ansiColor("xterm") {
|
||||
|
||||
stage("Checkout") {
|
||||
checkout scm
|
||||
giteaUtils.setSuccess("checkout")
|
||||
}
|
||||
|
||||
stage("Setup") {
|
||||
cdkUtils.install()
|
||||
giteaUtils.setSuccess("setup")
|
||||
}
|
||||
|
||||
if (args.onPreDeploy) {
|
||||
spicyUtils.stageWithFailure("PreDeploy") {
|
||||
args.onPreDeploy.call(args, [:])
|
||||
}
|
||||
}
|
||||
|
||||
stage("Deploy VPC") {
|
||||
if (gitUtils.isMain()) {
|
||||
try {
|
||||
// Build context from args
|
||||
def context = buildVpcContext(args)
|
||||
|
||||
// Create account object for cdkUtils
|
||||
def account = awsUtils.buildAccountConfig(args)
|
||||
|
||||
// Show diff first (optional, for visibility)
|
||||
if (args.showDiff != false) {
|
||||
echo "Showing CDK diff..."
|
||||
cdkUtils.diff(
|
||||
account: account,
|
||||
stackName: args.stackName,
|
||||
stackType: "vpc",
|
||||
context: context
|
||||
)
|
||||
}
|
||||
|
||||
// Deploy the stack
|
||||
echo "Deploying VPC stack: ${args.stackName}"
|
||||
cdkUtils.deploy(
|
||||
account: account,
|
||||
stackName: args.stackName,
|
||||
stackType: "vpc",
|
||||
context: context
|
||||
)
|
||||
|
||||
giteaUtils.setSuccess("deploy")
|
||||
|
||||
if (args.onPostDeploy) {
|
||||
spicyUtils.stageWithFailure("PostDeploy") {
|
||||
args.onPostDeploy.call(args, [
|
||||
stackName: args.stackName
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
} catch (err) {
|
||||
giteaUtils.setFailed("deploy")
|
||||
throw err
|
||||
}
|
||||
} else {
|
||||
echo "Skipping deployment - not on main branch"
|
||||
|
||||
// Still show diff on non-main branches for PR review
|
||||
if (args.showDiffOnPR != false) {
|
||||
def context = buildVpcContext(args)
|
||||
def account = [
|
||||
region: args.region,
|
||||
jenkinsAwsCredentialsId: args.jenkinsAwsCredentialsId,
|
||||
accountId: args.accountId ?: ''
|
||||
]
|
||||
|
||||
echo "Showing CDK diff for PR review..."
|
||||
try {
|
||||
cdkUtils.diff(
|
||||
account: account,
|
||||
stackName: args.stackName,
|
||||
stackType: "vpc",
|
||||
context: context
|
||||
)
|
||||
} catch (err) {
|
||||
echo "Diff failed (stack may not exist yet): ${err.message}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
giteaUtils.setSuccess("pipeline")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build CDK context map from pipeline arguments
|
||||
*/
|
||||
def buildVpcContext(Map args) {
|
||||
def context = [:]
|
||||
|
||||
// Required tags
|
||||
context.ownerTag = args.ownerTag
|
||||
context.productTag = args.productTag
|
||||
context.componentTag = args.componentTag
|
||||
|
||||
// Optional build tag
|
||||
context.build = args.build ?: gitUtils.getShortSHA()
|
||||
|
||||
// VPC configuration
|
||||
if (args.vpcCidr) context.vpcCidr = args.vpcCidr
|
||||
if (args.vpcTenancy) context.vpcTenancy = args.vpcTenancy
|
||||
|
||||
// Availability zones
|
||||
if (args.availabilityZones) {
|
||||
context.availabilityZones = args.availabilityZones
|
||||
// Calculate numberOfAzs from the AZ list if not explicitly provided
|
||||
if (!args.numberOfAzs) {
|
||||
context.numberOfAzs = args.availabilityZones.split(",").length.toString()
|
||||
}
|
||||
}
|
||||
if (args.numberOfAzs) context.numberOfAzs = args.numberOfAzs.toString()
|
||||
|
||||
// Subnet creation flags
|
||||
if (args.createPrivateSubnets != null) {
|
||||
context.createPrivateSubnets = args.createPrivateSubnets.toString()
|
||||
}
|
||||
if (args.createAdditionalPrivateSubnets != null) {
|
||||
context.createAdditionalPrivateSubnets = args.createAdditionalPrivateSubnets.toString()
|
||||
}
|
||||
|
||||
// Subnet tags
|
||||
if (args.publicSubnetTag) context.publicSubnetTag = args.publicSubnetTag
|
||||
if (args.privateSubnetATag) context.privateSubnetATag = args.privateSubnetATag
|
||||
if (args.privateSubnetBTag) context.privateSubnetBTag = args.privateSubnetBTag
|
||||
|
||||
// Public subnet CIDRs
|
||||
if (args.publicSubnetACidr) context.publicSubnetACidr = args.publicSubnetACidr
|
||||
if (args.publicSubnetBCidr) context.publicSubnetBCidr = args.publicSubnetBCidr
|
||||
if (args.publicSubnetCCidr) context.publicSubnetCCidr = args.publicSubnetCCidr
|
||||
if (args.publicSubnetDCidr) context.publicSubnetDCidr = args.publicSubnetDCidr
|
||||
|
||||
// Private subnet 1 CIDRs
|
||||
if (args.privateSubnetA1Cidr) context.privateSubnetA1Cidr = args.privateSubnetA1Cidr
|
||||
if (args.privateSubnetB1Cidr) context.privateSubnetB1Cidr = args.privateSubnetB1Cidr
|
||||
if (args.privateSubnetC1Cidr) context.privateSubnetC1Cidr = args.privateSubnetC1Cidr
|
||||
if (args.privateSubnetD1Cidr) context.privateSubnetD1Cidr = args.privateSubnetD1Cidr
|
||||
|
||||
// Private subnet 2 CIDRs (with NACLs)
|
||||
if (args.privateSubnetA2Cidr) context.privateSubnetA2Cidr = args.privateSubnetA2Cidr
|
||||
if (args.privateSubnetB2Cidr) context.privateSubnetB2Cidr = args.privateSubnetB2Cidr
|
||||
if (args.privateSubnetC2Cidr) context.privateSubnetC2Cidr = args.privateSubnetC2Cidr
|
||||
if (args.privateSubnetD2Cidr) context.privateSubnetD2Cidr = args.privateSubnetD2Cidr
|
||||
|
||||
return context
|
||||
}
|
||||
|
||||
return this
|
||||
|
||||
Reference in New Issue
Block a user