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:
2025-11-18 22:21:00 -08:00
commit 68684df471
51 changed files with 15587 additions and 0 deletions

100
vars/accounts.groovy Normal file
View 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
View 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

View 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
View 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
View 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
View 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
View 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

View 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
View 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

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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