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

108
.cursorrules Normal file
View File

@@ -0,0 +1,108 @@
# Spicy Automation
## Project Overview
AWS CDK infrastructure and Jenkins shared library for deploying VPCs, ECS clusters, and ECS services on AWS. Provides type-safe TypeScript CDK constructs and Jenkins pipeline functions for automated infrastructure deployment with blue/green deployments, mixed capacity strategies (EC2 + Fargate), and persistent ALB patterns.
## Architecture Principles
### Language & Stack
- **TypeScript** for CDK constructs (AWS CDK v2)
- **Groovy** for Jenkins shared library pipelines
- **AWS CDK** for infrastructure as code
- **Jest** for testing
- **pnpm** for package management
### Code Organization
```text
spicy-automation/
├── bin/spicy-cdk.ts # CDK app entry point
├── lib/
│ ├── constructs/ # Reusable CDK constructs
│ │ ├── spicy-vpc.ts # VPC construct
│ │ ├── spicy-ecs-cluster.ts # ECS cluster construct
│ │ ├── spicy-ecs-service.ts # ECS service construct
│ │ └── spicy-alb.ts # Persistent ALB construct
│ └── stacks/ # CDK stacks (fromContext factories)
│ ├── spicy-vpc-stack.ts
│ ├── spicy-ecs-cluster-stack.ts
│ ├── spicy-ecs-service-stack.ts
│ └── spicy-alb-stack.ts
├── vars/ # Jenkins shared library
│ ├── spicyVPC.groovy # VPC pipeline
│ ├── spicyECSCluster.groovy # ECS cluster pipeline
│ ├── spicyECSService.groovy # ECS service pipeline
│ ├── spicyRollback.groovy # Blue/green rollback
│ ├── cdkUtils.groovy # CDK command utilities
│ ├── dockerUtils.groovy # Docker/Nexus utilities
│ ├── gitUtils.groovy # Git utilities
│ └── accounts.groovy # Account configurations
├── docs/ # Documentation
├── test/ # Jest tests
└── Dockerfile # Jenkins agent image
```
**Directory Rules:**
- `lib/constructs/` - Reusable CDK constructs (no stack logic)
- `lib/stacks/` - Stack factories that read from CDK context
- `vars/` - Jenkins shared library functions (Groovy)
- `docs/` - User-facing documentation (keep minimal, avoid duplication)
- `test/` - Unit tests for constructs
## Coding Standards
### TypeScript Conventions
- Use interfaces for all public APIs
- Document exported functions and types with JSDoc
- Prefer readonly properties where possible
- Use strict TypeScript settings
- One construct per file, named exports only
- Use CDK best practices (overrideLogicalId for stability)
### Error Handling
- Validate required parameters early in constructors
- Throw descriptive errors with usage examples
- Use CDK's built-in validation where possible
- Don't swallow errors silently
### Concurrency
- CDK constructs are synchronous (no async/await needed)
- Jenkins pipelines handle concurrency at the stage level
- No shared mutable state in constructs
## Documentation
- Keep README.md updated
- Code comments for complex logic
## Minimal Viable Product (MVP)
1. **Keep it minimal**: Code should be the smallest implementation that works correctly
2. **Avoid over-engineering**: Don't add features "just in case" - add them when needed
3. **Simple solutions first**: Prefer simple, straightforward code over complex abstractions
4. **One thing at a time**: Each function/type should do one thing well
## When Making Changes
1. **Write tests first**: Define all expectations and error cases in tests BEFORE any implementation
2. **Mock third-party dependencies**: Use mocks for external libraries, don't test their internals
3. **Implement minimal code**: Write only what's needed to pass the tests
4. **Follow patterns**: Use existing code patterns
5. **Keep it simple**: Prefer simple, readable code over clever solutions
## Questions to Ask
If unsure about implementation:
1. Does it follow existing patterns?
2. Is it testable?
3. Does it maintain thread safety?
4. Is it documented?
5. Does it validate inputs?
6. Does it handle errors properly?

10
.gitignore vendored Normal file
View File

@@ -0,0 +1,10 @@
*.js
!jest.config.js
*.d.ts
node_modules
# CDK asset staging directory
.cdk.staging
cdk.out
OLD_STUFF/
OLDER_STUFF/

216
docs/ACCOUNTS.md Normal file
View File

@@ -0,0 +1,216 @@
# Account Configuration Guide
Configure AWS accounts for multi-environment deployments.
## Overview
The `accounts.groovy` utility provides a centralized way to manage AWS account configurations across environments. This eliminates hardcoding credentials and region information in individual Jenkinsfiles.
## Usage
```groovy
@Library(["spicy-automation@main"]) _
// Get all accounts
def allAccounts = accounts.get()
// Use a specific environment
def prodAccount = allAccounts.SPICY_CA_CENRAL_1_PROD
spicyVPC(
jenkinsAwsCredentialsId: prodAccount.jenkinsAwsCredentialsId,
region: prodAccount.region,
accountId: prodAccount.accountId,
stackName: prodAccount.vpcStackName,
ownerTag: "Platform",
productTag: "spicy",
componentTag: "vpc",
)
```
## Available Accounts
| Key | Environment | Description |
|-----|-------------|-------------|
| `SPICY_CA_CENRAL_1` | Base | Base account configuration |
| `SPICY_CA_CENRAL_1_DEV` | Development | Development environment |
| `SPICY_CA_CENRAL_1_SANDBOX` | Sandbox | Sandbox/experimental environment |
| `SPICY_CA_CENRAL_1_QA` | QA | Quality assurance environment |
| `SPICY_CA_CENRAL_1_STAGING` | Staging | Pre-production environment |
| `SPICY_CA_CENRAL_1_PROD` | Production | Production environment |
## Account Properties
Each account object contains:
| Property | Type | Description |
|----------|------|-------------|
| `accountId` | string | AWS Account ID (12-digit) |
| `region` | string | AWS Region (e.g., `ca-central-1`) |
| `jenkinsAwsCredentialsId` | string | Jenkins credential ID for AWS access |
| `vpcStackName` | string | Default VPC stack name for this environment |
| `ecsClusterStackName` | string | Default ECS cluster stack name |
## Configuration Files
Account configurations are loaded from YAML files in the `resources/` directory:
```
jenkins/
└── resources/
└── accounts/
├── aws-spicy-ca-central-1.yml # Base account
└── environments/
├── aws-spicy-ca-central-1-dev.yml
├── aws-spicy-ca-central-1-sandbox.yml
├── aws-spicy-ca-central-1-staging.yml
└── aws-spicy-ca-central-1-prod.yml
```
### Base Account Configuration
`resources/accounts/aws-spicy-ca-central-1.yml`:
```yaml
accountId: "123456789012"
region: "ca-central-1"
jenkinsAwsCredentialsId: "aws-spicy-ca-central-1"
```
### Environment Configuration
`resources/accounts/environments/aws-spicy-ca-central-1-prod.yml`:
```yaml
# Inherits from base account
vpcStackName: "spicy-vpc-prod"
ecsClusterStackName: "spicy-ecs-cluster-prod"
jenkinsAwsCredentialsId: "aws-spicy-ca-central-1-prod"
```
## Adding a New Account
### 1. Create Base Account File
Create `resources/accounts/aws-mycompany-us-west-2.yml`:
```yaml
accountId: "987654321098"
region: "us-west-2"
jenkinsAwsCredentialsId: "aws-mycompany-us-west-2"
```
### 2. Create Environment Files
Create `resources/accounts/environments/aws-mycompany-us-west-2-prod.yml`:
```yaml
vpcStackName: "mycompany-vpc-prod"
ecsClusterStackName: "mycompany-ecs-cluster-prod"
```
### 3. Update accounts.groovy
Edit `jenkins/vars/accounts.groovy`:
```groovy
def getAccounts() {
def accounts = [:]
// Existing accounts...
accounts.put("SPICY_CA_CENRAL_1", resources.getProperties("accounts/aws-spicy-ca-central-1.yml"))
// Add new account
accounts.put("MYCOMPANY_US_WEST_2", resources.getProperties("accounts/aws-mycompany-us-west-2.yml"))
return accounts
}
def getProduction() {
def accounts = [:]
// Existing...
accounts.put(
"SPICY_CA_CENRAL_1_PROD",
getAccounts().SPICY_CA_CENRAL_1 + resources.getProperties("accounts/environments/aws-spicy-ca-central-1-prod.yml")
)
// Add new environment
accounts.put(
"MYCOMPANY_US_WEST_2_PROD",
getAccounts().MYCOMPANY_US_WEST_2 + resources.getProperties("accounts/environments/aws-mycompany-us-west-2-prod.yml")
)
return accounts
}
```
## Jenkins Credentials Setup
For each account, create AWS credentials in Jenkins:
1. Go to **Manage Jenkins****Manage Credentials**
2. Add credentials:
- **Kind**: Username with password
- **Username**: AWS Access Key ID
- **Password**: AWS Secret Access Key
- **ID**: Match the `jenkinsAwsCredentialsId` in your config (e.g., `aws-spicy-ca-central-1-prod`)
## Multi-Region Deployments
Deploy to multiple regions:
```groovy
@Library(["spicy-automation@main"]) _
def regions = [
accounts.get().SPICY_CA_CENRAL_1_PROD,
accounts.get().SPICY_EU_WEST_1_PROD, // If configured
]
regions.each { account ->
spicyVPC(
jenkinsAwsCredentialsId: account.jenkinsAwsCredentialsId,
region: account.region,
stackName: "vpc-${account.region}",
ownerTag: "Platform",
productTag: "spicy",
componentTag: "vpc",
)
}
```
## Environment-Specific Overrides
Override settings per environment:
```groovy
@Library(["spicy-automation@main"]) _
def account = accounts.get().SPICY_CA_CENRAL_1_DEV
spicyVPC(
jenkinsAwsCredentialsId: account.jenkinsAwsCredentialsId,
region: account.region,
stackName: account.vpcStackName,
ownerTag: "Dev",
productTag: "spicy",
componentTag: "vpc",
// Dev-specific: fewer AZs, no NACL subnets
numberOfAzs: 2,
createAdditionalPrivateSubnets: false,
)
```
## Best Practices
1. **Use environment-specific credentials**: Each environment should have its own AWS credentials with appropriate permissions.
2. **Naming conventions**: Use consistent naming like `{company}-{region}-{env}` for stack names.
3. **Least privilege**: Production credentials should only have permissions needed for deployment.
4. **Separate accounts**: Use separate AWS accounts for production vs non-production when possible.
5. **Centralize configuration**: Keep all account configs in the shared library, not in individual repos.

925
docs/CDK_SYNTH_EXAMPLES.md Normal file
View File

@@ -0,0 +1,925 @@
# CDK Synth Command Examples
This document provides comprehensive examples of `pnpm exec cdk synth` commands for all stack types and configurations.
## Prerequisites
```bash
cd spicy-automation
pnpm install
pnpm run build
```
## Common Warnings
The following warnings are **expected** and **non-critical**:
1. **Route Table ID Warnings**: When importing VPCs, route table IDs are optional. These warnings can be ignored.
```
[Warning] No routeTableId was provided to the subnet 'subnet-xxx'
```
2. **Construct Metadata Warnings**: CDK internal warnings about managed policies. These are informational only.
```
[Warning] Failed to add construct metadata for node [ExecutionRole]
```
## CloudFormation Import Pattern (Minimal Props)
The CDK implementation supports the same CloudFormation import pattern as the old Ansible/CloudFormation templates:
- **Minimal props**: Only `clusterName` required for most deployments
- **Auto-imports**: VPC ID, listener ARNs, and logs bucket auto-import from CloudFormation exports
- **No defaults**: All required values must exist in exports or be provided explicitly - fails early if missing
**Export chain:**
- VPC stack exports: `${vpcStackName}-VPCID`, `${vpcStackName}-VPCCIDR`, `${vpcStackName}-PrivateSubnetA1ID`, etc.
- Cluster stack exports: `${clusterName}-VPC`, `${clusterName}-internet-facing-https-listener`, `${clusterName}-logs-s3-bucket`
- ALB stack exports: `${albStackName}-internet-facing-arn`, `${albStackName}-internet-facing-https-listener`, `${albStackName}-internet-facing-http-listener`
**Important:** The implementation **fails early** if required exports don't exist. There are no defaults - either the data exists in CloudFormation exports, or the synth fails with a clear error message.
## VPC Stack
### Minimal VPC (Default Configuration)
```bash
pnpm exec cdk synth \
-c stackType=vpc \
-c stackName=my-vpc \
-c ownerTag=MyTeam \
-c productTag=myproduct \
-c componentTag=vpc
```
**Creates:**
- VPC with default CIDR (172.1.0.0/16)
- 4 Availability Zones
- Public subnets (one per AZ)
- Private subnets Type 1 (one per AZ with NAT Gateways)
- Private subnets Type 2 (one per AZ with NACLs)
- S3 Gateway Endpoint
### Custom VPC Configuration
```bash
pnpm exec cdk synth \
-c stackType=vpc \
-c stackName=production-vpc \
-c ownerTag=Platform \
-c productTag=spicy \
-c componentTag=network \
-c vpcCidr=10.0.0.0/16 \
-c numberOfAzs=3 \
-c availabilityZones=ca-central-1a,ca-central-1b,ca-central-1c \
-c createPrivateSubnets=true \
-c createAdditionalPrivateSubnets=true
```
### Development VPC (2 AZs, No NACL Subnets)
```bash
pnpm exec cdk synth \
-c stackType=vpc \
-c stackName=dev-vpc \
-c ownerTag=Dev \
-c productTag=myapp \
-c componentTag=vpc \
-c numberOfAzs=2 \
-c createAdditionalPrivateSubnets=false
```
### Public-Only VPC (No NAT Gateways)
```bash
pnpm exec cdk synth \
-c stackType=vpc \
-c stackName=public-vpc \
-c ownerTag=Dev \
-c productTag=myapp \
-c componentTag=vpc \
-c createPrivateSubnets=false
```
---
## ECS Cluster Stack
### Minimal Cluster (No Load Balancers) - Using CloudFormation Imports
**Minimal props approach:** Only `vpcStackName` required. All VPC details auto-import from VPC stack exports.
```bash
pnpm exec cdk synth \
-c stackType=ecs-cluster \
-c stackName=my-cluster \
-c vpcStackName=my-vpc \
-c ownerTag=MyTeam \
-c productTag=myproduct \
-c componentTag=ecs-cluster \
-c environment=dev
```
**What auto-imports from VPC stack:**
- VPC ID from `${vpcStackName}-VPCID`
- VPC CIDR from `${vpcStackName}-VPCCIDR`
- Number of AZs from `${vpcStackName}-NumberOfAZs`
- Private subnet IDs from `${vpcStackName}-PrivateSubnetA1ID`, `${vpcStackName}-PrivateSubnetB1ID`, etc.
- Public subnet IDs from `${vpcStackName}-PublicSubnetAID`, `${vpcStackName}-PublicSubnetBID`, etc. (if `createExternalLoadBalancer=true`)
- Availability zones auto-derived from region and number of AZs
### Cluster with External ALB - Using CloudFormation Imports
**Minimal props:**
```bash
pnpm exec cdk synth \
-c stackType=ecs-cluster \
-c stackName=my-cluster \
-c vpcStackName=my-vpc \
-c createExternalLoadBalancer=true \
-c certificateArn=arn:aws:acm:ca-central-1:123456789:certificate/xxx \
-c ownerTag=MyTeam \
-c productTag=myproduct \
-c componentTag=ecs-cluster \
-c environment=dev
```
**What auto-imports:**
- All VPC details (VPC ID, CIDR, subnets, AZs) from VPC stack exports
- Public subnets auto-imported for external ALB when `createExternalLoadBalancer=true`
### Cluster with Both ALBs
```bash
pnpm exec cdk synth \
-c stackType=ecs-cluster \
-c stackName=production-cluster \
-c vpcStackName=production-vpc \
-c createExternalLoadBalancer=true \
-c createInternalLoadBalancer=true \
-c certificateArn=arn:aws:acm:ca-central-1:123456789:certificate/xxx \
-c ownerTag=Platform \
-c productTag=spicy \
-c componentTag=ecs-cluster \
-c environment=prod
```
### Cluster with Spot Instances
```bash
pnpm exec cdk synth \
-c stackType=ecs-cluster \
-c stackName=dev-cluster \
-c vpcStackName=dev-vpc \
-c spotEnabled=true \
-c onDemandPercentage=20 \
-c spotAllocationStrategy=capacity-optimized \
-c instanceType=m5a.large \
-c minClusterSize=2 \
-c maxClusterSize=4 \
-c ownerTag=Dev \
-c productTag=myapp \
-c componentTag=ecs-cluster \
-c environment=dev
```
### Cluster with Fargate Enabled
```bash
pnpm exec cdk synth \
-c stackType=ecs-cluster \
-c stackName=hybrid-cluster \
-c vpcStackName=my-vpc \
-c enableFargate=true \
-c ownerTag=MyTeam \
-c productTag=myproduct \
-c componentTag=ecs-cluster \
-c environment=dev
```
---
---
## ALB Stack (Persistent bg-common ALB)
The ALB stack creates a persistent Application Load Balancer that can be shared across multiple service deployments. This is the "bg-common" pattern where the ALB persists even when service stacks are deleted and recreated.
**Important:** The ALB stack must be deployed **before** service stacks that reference it. The ALB ARN and listener ARNs from the ALB stack outputs are required as inputs to the service stack.
### Internet-Facing ALB
```bash
pnpm exec cdk synth \
-c stackType=alb \
-c stackName=my-service-alb \
-c vpcId=vpc-12345678 \
-c vpcCidrBlock=10.0.0.0/16 \
-c availabilityZones=ca-central-1a,ca-central-1b \
-c subnetIds=subnet-xxx,subnet-yyy \
-c scheme=internet-facing \
-c certificateArn=arn:aws:acm:ca-central-1:123456789:certificate/xxx \
-c logsBucketName=my-cluster-logs \
-c ownerTag=MyTeam \
-c productTag=myproduct \
-c componentTag=alb \
-c environment=dev
```
**Creates:**
- Application Load Balancer (internet-facing)
- Security Group for ALB
- HTTP Listener (port 80)
- HTTPS Listener (port 443) with SSL certificate
- HTTP→HTTPS redirect rule
- ALB access logs to S3 (if logsBucketName provided)
### Internal ALB
```bash
pnpm exec cdk synth \
-c stackType=alb \
-c stackName=my-service-alb \
-c vpcId=vpc-12345678 \
-c vpcCidrBlock=10.0.0.0/16 \
-c availabilityZones=ca-central-1a,ca-central-1b \
-c subnetIds=subnet-aaa,subnet-bbb \
-c scheme=internal \
-c certificateArn=arn:aws:acm:ca-central-1:123456789:certificate/xxx \
-c logsBucketName=my-cluster-logs \
-c ownerTag=MyTeam \
-c productTag=myproduct \
-c componentTag=alb \
-c environment=dev
```
### ALB with Blue/Green DNS
```bash
pnpm exec cdk synth \
-c stackType=alb \
-c stackName=my-service-alb \
-c vpcId=vpc-12345678 \
-c vpcCidrBlock=10.0.0.0/16 \
-c availabilityZones=ca-central-1a,ca-central-1b \
-c subnetIds=subnet-xxx,subnet-yyy \
-c scheme=internet-facing \
-c certificateArn=arn:aws:acm:ca-central-1:123456789:certificate/xxx \
-c logsBucketName=my-cluster-logs \
-c activeHostname=api.example.com \
-c inactiveHostname=inactive-api.example.com \
-c bgHostedZoneId=Z1234567890 \
-c ownerTag=MyTeam \
-c productTag=myproduct \
-c componentTag=alb \
-c environment=prod
```
**Creates:**
- All resources from internet-facing ALB example
- Route53 A record for active hostname (api.example.com)
- Route53 A record for inactive hostname (inactive-api.example.com)
---
## ECS Service Stack
### Minimal Service (No Load Balancer) - Using CloudFormation Imports
**Minimal props approach:** Only `clusterName` is required. VPC info auto-imports from cluster stack exports.
```bash
pnpm exec cdk synth \
-c stackType=ecs-service \
-c stackName=my-service \
-c clusterName=my-cluster \
-c serviceName=my-service \
-c image=nginx:latest \
-c containerPort=80 \
-c ownerTag=MyTeam \
-c productTag=myproduct \
-c componentTag=service \
-c environment=dev
```
**What auto-imports:**
- VPC stack name from `${clusterStackName}-VPCStackName` (cluster stack export)
- VPC ID from `${clusterStackName}-VPC` (cluster stack export)
- VPC CIDR from `${vpcStackName}-VPCCIDR` (VPC stack export, using imported VPC stack name)
- Private subnets from `${vpcStackName}-PrivateSubnetA1ID`, etc. (VPC stack exports)
- Number of AZs from `${vpcStackName}-NumberOfAZs` (VPC stack export)
- Logs bucket from `${clusterStackName}-logs-s3-bucket` (cluster stack export)
**Note:** The service stack imports the VPC stack name from the cluster stack export `${clusterStackName}-VPCStackName`, then uses that to import all VPC details (CIDR, subnets, AZs) from the VPC stack exports.
### Service with Cluster ALB - Using CloudFormation Imports
**Minimal props:** Listener ARN auto-imports from cluster stack.
```bash
pnpm exec cdk synth \
-c stackType=ecs-service \
-c stackName=my-service \
-c clusterName=my-cluster \
-c serviceName=my-service \
-c image=nginx:latest \
-c containerPort=80 \
-c hostHeader=api.example.com \
-c priority=100 \
-c useExternalALB=true \
-c healthCheckPath=/health \
-c ownerTag=MyTeam \
-c productTag=myproduct \
-c componentTag=service \
-c environment=dev
```
**What auto-imports:**
- VPC stack name from `${clusterStackName}-VPCStackName` (cluster stack export)
- VPC ID from `${clusterStackName}-VPC` (cluster stack export)
- VPC CIDR from `${vpcStackName}-VPCCIDR` (VPC stack export, using imported VPC stack name)
- Private subnets from `${vpcStackName}-PrivateSubnetA1ID`, etc. (VPC stack exports)
- Number of AZs from `${vpcStackName}-NumberOfAZs` (VPC stack export)
- External listener ARN from `${clusterStackName}-internet-facing-https-listener` (when `useExternalALB=true`)
- Internal listener ARN from `${clusterStackName}-internal-https-listener` (when `useInternalALB=true`)
**Explicit listener ARN (backward compatible):**
```bash
pnpm exec cdk synth \
-c stackType=ecs-service \
-c stackName=my-service \
-c clusterName=my-cluster \
-c vpcId=vpc-12345678 \
-c vpcCidrBlock=10.0.0.0/16 \
-c availabilityZones=ca-central-1a,ca-central-1b \
-c privateSubnetIds=subnet-aaa,subnet-bbb \
-c serviceName=my-service \
-c image=nginx:latest \
-c containerPort=80 \
-c hostHeader=api.example.com \
-c priority=100 \
-c useExternalALB=true \
-c externalListenerArn=arn:aws:elasticloadbalancing:ca-central-1:123456789:listener/app/test-alb/1234567890123456/1234567890123456 \
-c healthCheckPath=/health \
-c ownerTag=MyTeam \
-c productTag=myproduct \
-c componentTag=service \
-c environment=dev
```
### Service with bg-common ALB (Default - useClusterAlb=false)
**Important:** When using bg-common ALB, the ALB and Route53 DNS records are created in a **separate ALB stack**, not in the service stack. The service stack only creates listener rules on the existing ALB.
**Step 1: Deploy ALB Stack** (creates ALB and Route53 records)
```bash
pnpm exec cdk synth \
-c stackType=alb \
-c stackName=my-service-alb \
-c vpcId=vpc-12345678 \
-c vpcCidrBlock=10.0.0.0/16 \
-c availabilityZones=ca-central-1a,ca-central-1b \
-c subnetIds=subnet-xxx,subnet-yyy \
-c scheme=internet-facing \
-c certificateArn=arn:aws:acm:ca-central-1:123456789:certificate/xxx \
-c logsBucketName=my-cluster-logs \
-c ownerTag=MyTeam \
-c productTag=myproduct \
-c componentTag=alb \
-c environment=dev
```
**Step 2: Deploy Service Stack** (creates listener rules on existing ALB)
**Minimal props using CloudFormation imports:**
```bash
pnpm exec cdk synth \
-c stackType=ecs-service \
-c stackName=my-service \
-c clusterName=my-cluster \
-c albStackName=my-service-alb \
-c serviceName=my-service \
-c image=nginx:latest \
-c containerPort=80 \
-c healthCheckPath=/health \
-c hostHeader=api.example.com \
-c priority=100 \
-c ownerTag=MyTeam \
-c productTag=myproduct \
-c componentTag=service \
-c environment=dev
```
**What auto-imports:**
- VPC ID from `${clusterName}-VPC` (cluster stack export)
- VPC CIDR from `${clusterName}-VPCCIDR` (cluster stack export)
- Private subnets from `${clusterName}-PrivateSubnetA1ID`, etc. (cluster stack exports)
- Availability zones from `${clusterName}-AvailabilityZones` (cluster stack export)
- ALB ARN from `${albStackName}-internet-facing-arn` (ALB stack export)
- HTTPS listener ARN from `${albStackName}-internet-facing-https-listener` (ALB stack export)
- HTTP listener ARN from `${albStackName}-internet-facing-http-listener` (ALB stack export)
- Logs bucket from `${clusterName}-logs-s3-bucket` (cluster stack export)
**Note:** `vpcStackName` is optional - the service stack prefers importing from the cluster stack. The ALB stack name (`albStackName`) is automatically derived from the service stackName (e.g., `my-service-dev` → `my-service-dev-alb`).
**Note:** The service creates listener rules on **both** HTTP and HTTPS listeners (like the old template), so both listeners are imported if available.
**Note:** In the Jenkins pipeline, the ALB stack is created automatically. For manual CDK synth, you must deploy the ALB stack first and use `albStackName` for auto-imports.
### Service with bg-common ALB (Internal)
**Step 1: Deploy Internal ALB Stack**
```bash
pnpm exec cdk synth \
-c stackType=alb \
-c stackName=my-service-alb \
-c vpcId=vpc-12345678 \
-c vpcCidrBlock=10.0.0.0/16 \
-c availabilityZones=ca-central-1a,ca-central-1b \
-c subnetIds=subnet-aaa,subnet-bbb \
-c scheme=internal \
-c certificateArn=arn:aws:acm:ca-central-1:123456789:certificate/xxx \
-c logsBucketName=my-cluster-logs \
-c ownerTag=MyTeam \
-c productTag=myproduct \
-c componentTag=alb \
-c environment=dev
```
**Step 2: Deploy Service Stack**
```bash
pnpm exec cdk synth \
-c stackType=ecs-service \
-c stackName=my-service \
-c clusterName=my-cluster \
-c albStackName=my-service-alb \
-c serviceName=my-service \
-c image=nginx:latest \
-c containerPort=80 \
-c healthCheckPath=/health \
-c hostHeader=api-internal.example.com \
-c priority=100 \
-c ownerTag=MyTeam \
-c productTag=myproduct \
-c componentTag=service \
-c environment=dev
```
### Service with bg-common ALB + Blue/Green Deployment
**Note:** Blue/green deployments are handled automatically by the Jenkins pipeline. The pipeline:
1. Automatically creates the bg-common ALB stack (if it doesn't exist)
2. Deploys to the inactive color (blue or green)
3. Runs `blueGreenTest` against the inactive hostname
4. Waits for deployment to stabilize
5. Swaps hostnames to make the new deployment active
6. Runs `smokeTest` against the new active hostname
7. Keeps the old stack for rollback window (default 2 hours)
**For manual CDK synth, deploy both stacks:**
**Step 1: Deploy ALB Stack with Blue/Green DNS**
```bash
pnpm exec cdk synth \
-c stackType=alb \
-c stackName=my-service-alb \
-c vpcId=vpc-12345678 \
-c vpcCidrBlock=10.0.0.0/16 \
-c availabilityZones=ca-central-1a,ca-central-1b \
-c subnetIds=subnet-xxx,subnet-yyy \
-c scheme=internet-facing \
-c certificateArn=arn:aws:acm:ca-central-1:123456789:certificate/xxx \
-c logsBucketName=my-cluster-logs \
-c activeHostname=api.example.com \
-c inactiveHostname=inactive-api.example.com \
-c bgHostedZoneId=Z1234567890 \
-c ownerTag=MyTeam \
-c productTag=myproduct \
-c componentTag=alb \
-c environment=prod
```
**Step 2: Deploy Service Stack (Blue or Green)**
After deploying the ALB stack, use `albStackName` for auto-imports:
```bash
pnpm exec cdk synth \
-c stackType=ecs-service \
-c stackName=my-service-blue \
-c clusterName=my-cluster \
-c albStackName=my-service-alb \
-c serviceName=my-service \
-c image=nginx:latest \
-c containerPort=80 \
-c activeHostname=api.example.com \
-c inactiveHostname=inactive-api.example.com \
-c bgHostedZoneId=Z1234567890 \
-c healthCheckPath=/health \
-c hostHeader=api.example.com \
-c priority=100 \
-c ownerTag=MyTeam \
-c productTag=myproduct \
-c componentTag=service \
-c environment=prod
```
**How blue/green works:**
1. ALB stack creates DNS records for both active and inactive hostnames (both point to the same ALB)
2. Pipeline determines current active color from SSM Parameter Store (defaults to 'blue')
3. Pipeline deploys new version to inactive color (green if blue is active, blue if green is active)
4. Pipeline runs `blueGreenTest` hook against inactive hostname
5. Pipeline swaps hostnames by updating listener rule priorities
6. Pipeline runs `smokeTest` hook against new active hostname
7. Old stack is retained for rollback window (configurable via `rollbackWindowHours`)
8. Rollback is instant - just swaps hostnames back (no new deployment needed)
### Service with Auto-Scaling
```bash
pnpm exec cdk synth \
-c stackType=ecs-service \
-c stackName=my-service \
-c clusterName=my-cluster \
-c serviceName=my-service \
-c image=nginx:latest \
-c containerPort=80 \
-c desiredCount=2 \
-c minCapacity=2 \
-c maxCapacity=20 \
-c targetCpuUtilization=70 \
-c targetMemoryUtilization=80 \
-c targetRequestsPerTarget=1000 \
-c ownerTag=MyTeam \
-c productTag=myproduct \
-c componentTag=service \
-c environment=dev
```
### Service with Environment Variables and Secrets
```bash
pnpm exec cdk synth \
-c stackType=ecs-service \
-c stackName=my-service \
-c clusterName=my-cluster \
-c serviceName=my-service \
-c image=nginx:latest \
-c containerPort=80 \
-c environment_vars='{"NODE_ENV":"production","LOG_LEVEL":"info"}' \
-c secrets='{"DATABASE_URL":"arn:aws:secretsmanager:ca-central-1:123456789:secret:db-url","API_KEY":"arn:aws:secretsmanager:ca-central-1:123456789:secret:api-key"}' \
-c ownerTag=MyTeam \
-c productTag=myproduct \
-c componentTag=service \
-c environment=dev
```
### Service with Mixed Capacity Strategy (EC2 + Fargate)
```bash
pnpm exec cdk synth \
-c stackType=ecs-service \
-c stackName=my-service \
-c clusterName=my-cluster \
-c serviceName=my-service \
-c image=nginx:latest \
-c containerPort=80 \
-c cpu=512 \
-c memory=1024 \
-c capacityProviderStrategy='[{"capacityProvider":"my-cluster-ec2","base":2,"weight":3},{"capacityProvider":"FARGATE_SPOT","weight":1}]' \
-c ownerTag=MyTeam \
-c productTag=myproduct \
-c componentTag=service \
-c environment=dev
```
### Service with Custom Resource Limits
```bash
pnpm exec cdk synth \
-c stackType=ecs-service \
-c stackName=my-service \
-c clusterName=my-cluster \
-c serviceName=my-service \
-c image=nginx:latest \
-c containerPort=3000 \
-c cpu=1024 \
-c memory=2048 \
-c desiredCount=4 \
-c ownerTag=MyTeam \
-c productTag=myproduct \
-c componentTag=service \
-c environment=prod
```
---
## Complete Production Example
### Full Stack Deployment Sequence
```bash
# 1. Deploy VPC
pnpm exec cdk synth \
-c stackType=vpc \
-c stackName=production-vpc \
-c ownerTag=Platform \
-c productTag=spicy \
-c componentTag=vpc \
-c numberOfAzs=4 \
-c availabilityZones=ca-central-1a,ca-central-1b,ca-central-1c,ca-central-1d
# 2. Deploy ECS Cluster
pnpm exec cdk synth \
-c stackType=ecs-cluster \
-c stackName=production-cluster \
-c vpcStackName=production-vpc \
-c createExternalLoadBalancer=true \
-c createInternalLoadBalancer=true \
-c certificateArn=arn:aws:acm:ca-central-1:123456789:certificate/prod \
-c instanceType=m5a.xlarge \
-c minClusterSize=3 \
-c maxClusterSize=10 \
-c spotEnabled=true \
-c onDemandPercentage=50 \
-c ownerTag=Platform \
-c productTag=spicy \
-c componentTag=ecs-cluster \
-c environment=prod
# 3. Deploy ALB Stack (bg-common pattern)
pnpm exec cdk synth \
-c stackType=alb \
-c stackName=api-service-prod-alb \
-c vpcStackName=production-vpc \
-c numberOfAzs=3 \
-c scheme=internet-facing \
-c certificateArn=arn:aws:acm:ca-central-1:123456789:certificate/prod \
-c logsBucketName=production-cluster-ca-central-1-alb-logs \
-c activeHostname=api.example.com \
-c inactiveHostname=inactive-api.example.com \
-c bgHostedZoneId=Z1234567890 \
-c ownerTag=Platform \
-c productTag=spicy \
-c componentTag=alb \
-c environment=prod
# 4. Deploy Service Stack (references existing ALB via albStackName)
pnpm exec cdk synth \
-c stackType=ecs-service \
-c stackName=api-service-prod-blue \
-c clusterName=production-cluster \
-c albStackName=api-service-prod-alb \
-c serviceName=api-service \
-c image=nexus.kodeniks.com/docker-hosted/api-service:v1.2.3 \
-c containerPort=3000 \
-c cpu=512 \
-c memory=1024 \
-c activeHostname=api.example.com \
-c inactiveHostname=inactive-api.example.com \
-c bgHostedZoneId=Z1234567890 \
-c desiredCount=4 \
-c minCapacity=4 \
-c maxCapacity=24 \
-c targetCpuUtilization=70 \
-c targetMemoryUtilization=80 \
-c targetRequestsPerTarget=1000 \
-c healthCheckPath=/health \
-c hostHeader=api.example.com \
-c priority=100 \
-c ownerTag=Platform \
-c productTag=spicy \
-c componentTag=api \
-c environment=prod
```
---
## Parameter Reference
### Common Parameters (All Stacks)
| Parameter | Required | Description | Example |
| -------------- | -------- | ----------------------------------------------- | ----------- |
| `stackType` | ✅ | Stack type: `vpc`, `ecs-cluster`, `ecs-service` | `vpc` |
| `stackName` | ✅ | CloudFormation stack name | `my-vpc` |
| `ownerTag` | ✅ | Owner tag | `MyTeam` |
| `productTag` | ✅ | Product tag | `myproduct` |
| `componentTag` | ✅ | Component tag | `vpc` |
| `environment` | ⚠️ | Environment (required for cluster/service) | `dev` |
### VPC-Specific Parameters
| Parameter | Default | Description |
| -------------------------------- | -------------- | ----------------------------- |
| `vpcCidr` | `172.1.0.0/16` | VPC CIDR block |
| `numberOfAzs` | `4` | Number of AZs (2, 3, or 4) |
| `availabilityZones` | auto | Comma-separated AZ list |
| `createPrivateSubnets` | `true` | Create private subnets |
| `createAdditionalPrivateSubnets` | `true` | Create NACL-protected subnets |
### ECS Cluster Parameters
| Parameter | Default | Description |
| ---------------------------- | --------------- | ---------------------------------------------------------------------------------------------------- |
| `vpcStackName` | ✅ **Required** | VPC stack name - **all VPC details** (VPC ID, CIDR, subnets, AZs) auto-import from VPC stack exports |
| `instanceType` | `m5a.large` | EC2 instance type |
| `minClusterSize` | `2` | Minimum instances |
| `maxClusterSize` | `4` | Maximum instances |
| `createExternalLoadBalancer` | `false` | Create external ALB (public subnets auto-imported from VPC stack if enabled) |
| `createInternalLoadBalancer` | `false` | Create internal ALB |
| `certificateArn` | - | SSL certificate ARN |
| `spotEnabled` | `false` | Enable Spot instances |
| `onDemandPercentage` | `100` | On-demand percentage |
| `enableFargate` | `false` | Enable Fargate providers |
### ALB Stack Parameters
| Parameter | Default | Description |
| --------------------- | ----------------- | -------------------------------------------------------------------------------------------------------- |
| `vpcId` | - | VPC ID (required, or use `vpcStackName` to import) |
| `vpcCidrBlock` | - | VPC CIDR (required, or use `vpcStackName` to import) |
| `availabilityZones` | - | Comma-separated AZs (required, or use `vpcStackName` + `numberOfAzs` to import) |
| `subnetIds` | - | Subnet IDs for ALB (required) |
| `scheme` | `internet-facing` | ALB scheme: `internet-facing` or `internal` |
| `certificateArn` | - | SSL certificate ARN (required for HTTPS) |
| `logsBucketName` | - | S3 bucket name for ALB access logs |
| `logsPrefix` | - | S3 prefix for ALB access logs |
| `idleTimeout` | `60` | ALB idle timeout in seconds |
| `redirectHttpToHttps` | `true` | Enable HTTP→HTTPS redirect |
| `hostName` | - | Simple hostname (e.g., api.example.com) - auto-generates active/inactive hostnames (like old `HostName`) |
| `activeHostname` | - | Active hostname for blue/green DNS |
| `inactiveHostname` | - | Inactive hostname for blue/green DNS |
| `bgHostedZoneId` | - | Route53 hosted zone ID for blue/green DNS (optional - only needed if creating DNS records) |
### ECS Service Parameters
| Parameter | Default | Description |
| ------------------- | ------- | ---------------------------------------------------------------------------------------------------------------------- |
| `clusterName` | - | ECS cluster name (required) - used to auto-import VPC stack name, VPC ID, and listener ARNs from cluster stack exports |
| `clusterStackName` | - | Cluster stack name (defaults to `clusterName`) - used for CloudFormation imports |
| `vpcStackName` | - | VPC stack name (auto-imported from `${clusterStackName}-VPCStackName`, or provide explicitly) |
| `albStackName` | - | ALB stack name (for bg-common ALB) - used to auto-import ALB ARN and listener ARNs from ALB stack |
| `vpcId` | - | VPC ID (required, or auto-imported from `${clusterStackName}-VPC`) |
| `vpcCidrBlock` | - | VPC CIDR (required, or auto-imported from `${vpcStackName}-VPCCIDR` using imported VPC stack name) |
| `availabilityZones` | - | Comma-separated AZs (required, or auto-derived from VPC stack imports with `numberOfAzs`) |
| `privateSubnetIds` | - | Private subnet IDs (required, or auto-imported from `${vpcStackName}-PrivateSubnetA1ID`, etc.) |
| `numberOfAzs` | - | Number of AZs (required when importing subnets from VPC stack) |
| `publicSubnetIds` | - | Public subnet IDs (for individual ALB) |
| `serviceName` | - | Service name (required) |
| `image` | - | Docker image URI (required) |
| `containerPort` | `3000` | Container port |
| `cpu` | `256` | CPU units |
| `memory` | `512` | Memory in MiB |
| `desiredCount` | `2` | Desired task count |
#### bg-common ALB Parameters (useClusterAlb=false)
When `useClusterAlb` is false or not set, the service uses a bg-common ALB from a separate ALB stack. These parameters reference the existing ALB:
**Minimal props using CloudFormation imports:**
- Provide `albStackName` to auto-import ALB ARN and listener ARNs
- Service creates listener rules on **both** HTTP and HTTPS listeners (like the old template)
| Parameter | Default | Description |
| ----------------------------- | ------- | --------------------------------------------------------------------------------------------------------------- |
| `albStackName` | - | ALB stack name - used to auto-import ALB ARN and listener ARNs (preferred) |
| `existingAlbArn` | - | ALB ARN (auto-imported from `${albStackName}-internet-facing-arn`, or provide explicitly) |
| `existingAlbHttpsListenerArn` | - | HTTPS listener ARN (auto-imported from `${albStackName}-internet-facing-https-listener`, or provide explicitly) |
| `existingAlbHttpListenerArn` | - | HTTP listener ARN (auto-imported from `${albStackName}-internet-facing-http-listener`, or provide explicitly) |
| `hostHeader` | - | Host header for listener rule |
| `priority` | `100` | Listener rule priority (lower = higher precedence) |
| `stickiness` | `true` | Enable session stickiness on target group |
| `stickinessDuration` | `86400` | Session stickiness duration in seconds |
| `deregistrationDelay` | `60` | Target deregistration delay in seconds |
| `hostName` | - | Simple hostname (e.g., api.example.com) - auto-generates active/inactive (like old `HostName`) |
| `activeHostname` | - | Active hostname for blue/green DNS (must match ALB stack config) |
| `inactiveHostname` | - | Inactive hostname for blue/green DNS (must match ALB stack config) |
| `bgHostedZoneId` | - | Route53 hosted zone ID (optional - only needed if creating DNS records) |
#### Blue/Green Deployment Parameters
| Parameter | Default | Description |
| --------------------- | ------- | ---------------------------------------------------------------------------------------------------------------------- |
| `blueGreen` | `false` | Enable blue/green deployment (pipeline handles color switching automatically) |
| `hostName` | - | Simple hostname (e.g., api.example.com) - auto-generates `activeHostname` and `inactiveHostname` (like old `HostName`) |
| `activeHostname` | - | Active hostname (e.g., api.example.com) - used by bg-common ALB stack to create DNS records |
| `inactiveHostname` | - | Inactive hostname (e.g., inactive-api.example.com) - used by bg-common ALB stack to create DNS records |
| `bgHostedZoneId` | - | Route53 hosted zone ID (for bg-common ALB stack DNS records) - required for DNS records (hostnames are optional) |
| `rollbackWindowHours` | `2` | Hours to keep old stack for rollback (default 2 hours) |
**Note:** Hostnames are **optional**. Services without hostnames (pub/sub workers, background jobs) don't need DNS records. Only provide `bgHostedZoneId` if you want Route53 DNS records created.
**Cloudflare DNS Delegation:** If using Cloudflare for DNS, you can delegate a subdomain to AWS Route53:
1. Create a Route53 hosted zone for the subdomain (e.g., `production.mydomain.com`)
2. Point Cloudflare NS records for `*.production.mydomain.com` to the Route53 name servers
3. Use the Route53 hosted zone ID as `bgHostedZoneId`
4. AWS will manage DNS records for all services under that subdomain
#### Cluster ALB Parameters (useClusterAlb=true)
When `useClusterAlb` is true, the service uses the cluster's shared ALB:
**Minimal props using CloudFormation imports:**
- Provide `clusterName` to auto-import listener ARNs from cluster stack exports
- Listener ARNs auto-import from `${clusterName}-internet-facing-https-listener` or `${clusterName}-internal-https-listener`
| Parameter | Default | Description |
| --------------------- | ------- | --------------------------------------------------------------------------------------------------------- |
| `useClusterAlb` | `false` | Set to `true` to use cluster ALB |
| `hostHeader` | - | Host header for routing |
| `pathPatterns` | - | Comma-separated path patterns |
| `priority` | `100` | Listener rule priority |
| `useExternalALB` | `false` | Use external cluster ALB (auto-imports listener ARN from `${clusterName}-internet-facing-https-listener`) |
| `useInternalALB` | `false` | Use internal cluster ALB (auto-imports listener ARN from `${clusterName}-internal-https-listener`) |
| `externalListenerArn` | - | External listener ARN (auto-imported when `useExternalALB=true`, or provide explicitly) |
| `internalListenerArn` | - | Internal listener ARN (auto-imported when `useInternalALB=true`, or provide explicitly) |
#### Auto-Scaling Parameters
| Parameter | Default | Description |
| ------------------------- | ------- | ----------------------------------- |
| `minCapacity` | - | Minimum tasks |
| `maxCapacity` | - | Maximum tasks |
| `targetCpuUtilization` | - | Target CPU % (0-100) |
| `targetMemoryUtilization` | - | Target memory % (0-100) |
| `targetRequestsPerTarget` | - | Target requests per task per minute |
---
## Testing Checklist
When running synth commands, verify:
- ✅ No errors (only expected warnings)
- ✅ Resources are created as expected
- ✅ Logical IDs match expected patterns
- ✅ Tags are applied correctly
- ✅ Outputs are exported correctly
## Troubleshooting
### Common Issues
1. **Missing required parameters**: Check error message for missing context values
2. **Invalid subnet IDs**: Ensure subnet IDs exist in the specified VPC
3. **Certificate ARN format**: Must be valid ACM certificate ARN
4. **JSON parsing errors**: Ensure JSON strings are properly escaped/quoted
### Getting Stack Outputs
After deployment, get stack outputs:
```bash
aws cloudformation describe-stacks \
--stack-name my-stack \
--query 'Stacks[0].Outputs' \
--output table
```
---
## Quick Reference
### Minimal Commands
**VPC:**
```bash
pnpm exec cdk synth -c stackType=vpc -c stackName=test -c ownerTag=T -c productTag=t -c componentTag=v
```
**Cluster:**
```bash
pnpm exec cdk synth -c stackType=ecs-cluster -c stackName=test -c vpcStackName=test-vpc -c ownerTag=T -c productTag=t -c componentTag=c -c environment=d
```
**Service:**
```bash
pnpm exec cdk synth -c stackType=ecs-service -c stackName=test -c clusterName=test -c vpcId=vpc-123 -c vpcCidrBlock=10.0.0.0/16 -c availabilityZones=ca-central-1a -c privateSubnetIds=subnet-123 -c serviceName=test -c image=nginx:latest -c containerPort=80 -c ownerTag=T -c productTag=t -c componentTag=s -c environment=d
```

View File

@@ -0,0 +1,292 @@
# Cost Optimization for Testing Environments
Barebones setup guide for VPC → Cluster → Service with blue/green deployment and internet-facing ALB.
## CloudFormation Import Pattern
All examples use the **CloudFormation import pattern** for minimal configuration:
- **VPC Stack**: Only `numberOfAzs` needs to be specified (exports `${vpcStackName}-NumberOfAZs`)
- **Cluster Stack**: Uses `vpcStackName` to auto-import VPC ID, CIDR, subnets, and `numberOfAzs`. Exports `${clusterStackName}-VPCStackName` for other stacks.
- **Service Stack**: Uses `clusterName` to import VPC stack name from cluster export, then imports VPC details from VPC stack
- **ALB Stack**: Uses `clusterName` to import VPC stack name from cluster export, then imports VPC details from VPC stack
**Key benefit**: `numberOfAzs` only needs to be specified once (in the VPC stack), and all other stacks automatically import it.
## Cost Breakdown
### Optimized Testing Setup
- **VPC**: 2 AZs, 2 NAT Gateways = ~$64/month (NAT) + ~$2/month (EIPs)
- **Cluster**: 1 x t3.small instance (Spot) = ~$5-8/month, OR Fargate-only (0 instances) = $0/month
- **ALB**: Individual ALB per service = ~$16/month
- **Service**: 1 task = minimal additional cost
- **Total**: ~$87-90/month (with EC2) or ~$82-84/month (Fargate-only)
**Savings:** ~70-80% reduction vs production setup
## Barebones Blue/Green Setup
### Complete Configuration
```groovy
@Library(["spicy-automation@main"]) _
// 1. VPC (2 AZs, minimal cost)
spicyVPC(
jenkinsAwsCredentialsId: "aws-credentials",
region: "ca-central-1",
stackName: "test-vpc",
ownerTag: "Test",
productTag: "test",
componentTag: "vpc",
numberOfAzs: 2, // 2 AZs instead of 4 (saves 2 NAT Gateways)
createAdditionalPrivateSubnets: false, // Skip NACL subnets
)
// 2. Cluster (with Fargate enabled for cheaper option)
spicyECSCluster(
jenkinsAwsCredentialsId: "aws-credentials",
region: "ca-central-1",
stackName: "test-cluster",
vpcStackName: "test-vpc", // Auto-imports VPC ID, CIDR, subnets, numberOfAzs
ownerTag: "Test",
productTag: "test",
componentTag: "cluster",
environment: "test",
// Option A: EC2 with Spot (cheaper than on-demand)
instanceType: "t3.small", // Minimal viable instance
minClusterSize: 1, // Single instance
maxClusterSize: 1, // No scaling
spotEnabled: true, // Use Spot for 60-70% savings
onDemandPercentage: 0, // 100% Spot
enableFargate: true, // Enable Fargate capacity providers
// Option B: Fargate-only (no EC2 costs)
// enableFargate: true,
// minClusterSize: 0, // No EC2 instances
// maxClusterSize: 0, // No EC2 instances
containerInsights: false, // Disable Container Insights (saves CloudWatch costs)
createExternalLoadBalancer: false, // Service creates its own ALB
createInternalLoadBalancer: false,
)
// 3. Service with Blue/Green (pipeline auto-creates ALB stack)
spicyECSService(
jenkinsAwsCredentialsId: "aws-credentials",
region: "ca-central-1",
stackName: "test-service", // Pipeline adds -blue/-green suffixes
serviceName: "test-api",
clusterName: "test-cluster", // Auto-imports VPC from cluster stack
vpcStackName: "test-vpc", // Auto-imports subnets, numberOfAzs from VPC stack
// Container (minimal resources)
image: "nexus.kodeniks.com/docker-hosted/test-api:latest",
containerPort: 3000,
desiredCount: 1, // Single task
cpu: 256, // Minimal CPU (0.25 vCPU)
memory: 512, // Minimal memory (512 MiB)
// Use Fargate Spot if cluster has no EC2 instances (Option B above)
// capacityProviderStrategy: [
// [capacityProvider: "FARGATE_SPOT", weight: 1],
// ],
// Blue/Green Configuration
blueGreen: true,
hostName: "api-test.example.com", // Auto-generates active/inactive hostnames
// bgHostedZoneId: "Z1234567890ABCDEF", // Route53 hosted zone (optional, for Route53 DNS)
// ALB Configuration (pipeline auto-creates ALB stack if not exists)
useClusterAlb: false, // Use individual ALB (not cluster ALB)
albScheme: "internet-facing",
certificateArn: "arn:aws:acm:ca-central-1:123456789:certificate/xxx",
redirectHttpToHttps: true,
healthCheckPath: "/health",
// Tags
ownerTag: "Test",
productTag: "test",
componentTag: "api",
environment: "test",
// Test hooks
blueGreenTest: { args, buildInfo ->
sh "curl -f https://${buildInfo.inactiveHostname}/health"
},
smokeTest: { args, buildInfo ->
sh "curl -f https://${buildInfo.activeHostname}/health"
},
)
```
**What happens:**
1. **VPC Stack**: Creates 2 AZs with 2 NAT Gateways (~$66/mo)
2. **Cluster Stack**: Creates 1 Spot instance (~$5-8/mo) OR 0 instances with Fargate (~$0/mo)
3. **ALB Stack**: Pipeline automatically creates `test-service-alb` stack (~$16/mo)
4. **Service Stack**: Pipeline creates `test-service-green` (inactive) referencing the ALB
5. **DNS**: Route53 records created automatically if `bgHostedZoneId` provided, OR manual Cloudflare setup (see below)
6. **Swap**: Pipeline swaps ALB listener rules (no DNS changes)
**Cost Breakdown:**
| Component | Cost (EC2) | Cost (Fargate) |
| ------------------------- | -------------- | -------------- |
| VPC (2 AZs, 2 NAT) | $66/mo | $66/mo |
| Cluster (1 Spot instance) | $5-8/mo | $0/mo |
| Individual ALB | $16/mo | $16/mo |
| Service (1 task) | Minimal | $2-3/mo |
| **Total** | **~$87-90/mo** | **~$82-84/mo** |
**Key Simplifications:**
- ✅ Uses `vpcStackName` and `clusterName` for auto-imports (no manual subnet IDs)
- ✅ Pipeline automatically creates ALB stack (`test-service-alb`)
- ✅ Uses `hostName` parameter (auto-generates active/inactive hostnames)
- ✅ Minimal resources: 1 Spot instance (or Fargate), 1 task, 256 CPU / 512 MB memory
- ✅ Route53 DNS managed automatically (if `bgHostedZoneId` provided)
## Cloudflare DNS Setup
If your DNS is hosted with Cloudflare (not Route53), you'll need to manually configure DNS records after deployment:
1. **Get ALB DNS name** from ALB stack outputs (ALB stack name is `{serviceStackName}-alb`):
```bash
# Get ALB DNS name from ALB stack
aws cloudformation describe-stacks \
--stack-name test-service-alb \
--query 'Stacks[0].Outputs[?OutputKey==`LoadBalancerDNS`].OutputValue' \
--output text
```
Or find it in the AWS Console: EC2 → Load Balancers → Your ALB → DNS name
2. **Create Cloudflare DNS records** (set once, never change):
**Active hostname:**
- **Type**: CNAME
- **Name**: `api-test` (or your subdomain)
- **Target**: `{alb-dns-name}` (e.g., `test-service-alb-123456789.ca-central-1.elb.amazonaws.com`)
- **Proxy**: Enabled (orange cloud) for Cloudflare protection
- **TTL**: Auto
**Inactive hostname:**
- **Name**: `inactive-api-test`
- **Target**: Same ALB DNS name (both point to same ALB, routing is by hostname)
**Note:** These DNS records point to the ALB and never change. The ALB handles routing internally based on the Host header.
3. **SSL/TLS Settings in Cloudflare**:
- Set to **Full** or **Full (strict)** mode
- This ensures HTTPS works end-to-end (Cloudflare → ALB → Service)
### How Blue/Green Works
```text
┌──────────────────────────────────────────────────────────┐
│ Cloudflare DNS │
│ ┌──────────────────┐ ┌───────────────────────────┐ │
│ │ api-test.example │ │ inactive-api-test.example │ │
│ │ .com │ │ .com │ │
│ └────────┬─────────┘ └──────────────┬────────────┘ │
└───────────┼───────────────────────────┼──────────────────┘
│ │
│ (Both point to same ALB) │
└──────────────┬────────────┘
┌──────────────────────────────┐
│ AWS ALB (Individual) │
│ ┌─────────────┐ ┌─────────┐ │
│ │ Host: api- │ │ Host: │ │
│ │ test... │ │ inactive│ │
│ │ → BLUE │ │ → GREEN │ │
│ └─────┬───────┘ └────┬────┘ │
└────────┼──────────────┼──────┘
│ │
┌────────▼───────┐ ┌────▼──────────┐
│ BLUE Service │ │ GREEN Service │
│ (Active) │ │ (Inactive) │
└────────────────┘ └───────────────┘
```
**Deployment Flow:**
1. Deploy new version to inactive (GREEN) service
2. Test via `inactive-api-test.example.com` in Cloudflare
3. Pipeline swaps ALB hostname routing (BLUE ↔ GREEN)
4. Active traffic now goes to new version
5. Old version stays running for rollback window
**Important:** The hostname does NOT change during deployments. DNS records point to the ALB (set once, never change), and the ALB routes traffic based on the Host header. The pipeline swaps ALB listener rules (not DNS), so rollback is instant (~30 seconds).
## Rollback
The pipeline handles ALB routing swaps automatically. For manual rollback:
```groovy
@Library(["spicy-automation@main"]) _
spicyRollback(
jenkinsAwsCredentialsId: "aws-credentials",
region: "ca-central-1",
stackName: "test-service",
serviceName: "test-api",
// ... same config as deploy ...
activeHostname: "api-test.example.com",
inactiveHostname: "inactive-api-test.example.com",
// If using Route53:
// bgHostedZoneId: "Z1234567890ABCDEF",
)
```
This swaps the hostname routing in the ALB (not DNS records), so rollback is instant (~30 seconds). Your DNS records (Route53 or Cloudflare) never change.
## Important Notes
1. **ACM Certificate**: You'll need an ACM certificate in the same region as your ALB
- Request via AWS Console or CLI
- Must validate domain ownership
- Can use DNS validation (add CNAME to Cloudflare)
2. **Cloudflare Proxy**: Keep proxy enabled (orange cloud) for:
- DDoS protection
- CDN caching (if desired)
- SSL termination at Cloudflare edge
3. **Hostname Routing**: The ALB routes by `Host` header, so both Cloudflare DNS records point to the same ALB DNS name
4. **Testing**: Use the inactive hostname for integration tests before swapping
5. **Fargate vs EC2**: For cheapest option, use Fargate-only (set `minClusterSize: 0` and `maxClusterSize: 0` in cluster, then use `FARGATE_SPOT` capacity provider in service)
## Cleanup
Remember to destroy test stacks when done:
```bash
# Via Jenkins or AWS CLI
aws cloudformation delete-stack --stack-name test-service-blue
aws cloudformation delete-stack --stack-name test-service-green
aws cloudformation delete-stack --stack-name test-service-alb
aws cloudformation delete-stack --stack-name test-cluster
aws cloudformation delete-stack --stack-name test-vpc
```
## Summary
**Barebones blue/green setup:**
- VPC: 2 AZs, 2 NAT Gateways
- Cluster: 1 Spot instance (or Fargate-only with 0 instances)
- Service: 1 task with blue/green deployment
- ALB: Individual internet-facing ALB (auto-created by pipeline)
- DNS: Route53 (automatic) or Cloudflare (manual)
- **Total: ~$87-90/month (EC2) or ~$82-84/month (Fargate)**

365
docs/DEVELOPMENT.md Normal file
View File

@@ -0,0 +1,365 @@
# Local Development Guide
Run and test CDK locally without Jenkins.
## Prerequisites
- **Node.js 22+**
- **pnpm** (package manager)
- **AWS CLI** (configured with credentials)
## Setup
```bash
cd spicy-automation
# Install dependencies
pnpm install
```
## Bootstrapping
Before deploying any CDK stacks, you must bootstrap the CDK environment in your AWS account and region. This is a **one-time setup** per account/region combination.
### Bootstrap Command
```bash
# Using Docker (same as your deploy command)
docker run -v ~/.aws:/root/.aws:ro spicy-sdk \
cdk bootstrap \
aws://ACCOUNT_ID/REGION
# Or locally with npx
npx cdk bootstrap aws://ACCOUNT_ID/REGION
# Example for ca-central-1
npx cdk bootstrap aws://$AWS_ACCOUNT_ID/ca-central-1
```
### What Bootstrap Does
CDK bootstrap creates the following resources in your AWS account:
- S3 bucket for storing CDK assets
- IAM roles for deployment
- SSM parameters for tracking bootstrap version
### Verify Bootstrap Status
```bash
# Check if environment is bootstrapped
aws ssm get-parameter \
--name /cdk-bootstrap/hnb659fds/version \
--region ca-central-1
```
If the parameter exists, the environment is bootstrapped. If you get a `ParameterNotFound` error, you need to bootstrap.
### Bootstrap Qualifier
This project uses the bootstrap qualifier `hnb659fds` (configured in `cdk.json`). This qualifier must match between bootstrap and deploy commands.
## Running CDK Commands
CDK runs directly with `ts-node` - no build step required!
### Synthesize a Template
```bash
# Minimal VPC
npx cdk synth \
-c stackType=vpc \
-c stackName=my-vpc \
-c ownerTag=MyTeam \
-c productTag=myapp \
-c componentTag=vpc
# With custom options
npx cdk synth \
-c stackType=vpc \
-c stackName=my-vpc \
-c ownerTag=MyTeam \
-c productTag=myapp \
-c componentTag=vpc \
-c vpcCidr=10.0.0.0/16 \
-c numberOfAzs=2 \
-c createAdditionalPrivateSubnets=false
```
### Show Diff
```bash
npx cdk diff \
-c stackType=vpc \
-c stackName=my-vpc \
-c ownerTag=MyTeam \
-c productTag=myapp \
-c componentTag=vpc
```
### Deploy
```bash
# Requires AWS credentials
export AWS_PROFILE=my-profile
# or
export AWS_ACCESS_KEY_ID=...
export AWS_SECRET_ACCESS_KEY=...
export AWS_REGION=ca-central-1
npx cdk deploy \
-c stackType=vpc \
-c stackName=my-vpc \
-c ownerTag=MyTeam \
-c productTag=myapp \
-c componentTag=vpc
```
### Destroy
```bash
npx cdk destroy \
-c stackType=vpc \
-c stackName=my-vpc \
-c stackType=vpc
```
## Context Parameters
All VPC parameters can be passed via `-c key=value`:
| Context Key | Type | Description |
| -------------------------------- | ------ | ----------------------------------------------- |
| `stackType` | string | Stack type: `vpc`, `ecs-cluster`, `ecs-service` |
| `stackName` | string | CloudFormation stack name |
| `ownerTag` | string | Owner tag |
| `productTag` | string | Product tag |
| `componentTag` | string | Component tag |
| `build` | string | Build tag (defaults to git SHA) |
| `vpcCidr` | string | VPC CIDR block |
| `vpcTenancy` | string | `default` or `dedicated` |
| `numberOfAzs` | string | `2`, `3`, or `4` |
| `availabilityZones` | string | Comma-separated AZ list |
| `createPrivateSubnets` | string | `true` or `false` |
| `createAdditionalPrivateSubnets` | string | `true` or `false` |
| `publicSubnetTag` | string | Tag for public subnets |
| `privateSubnetATag` | string | Tag for private subnets type 1 |
| `privateSubnetBTag` | string | Tag for private subnets type 2 |
## Running Tests
```bash
# Run all tests
pnpm test
# Run with coverage
pnpm test -- --coverage
# Run specific test
pnpm test -- --testNamePattern="creates VPC"
# Watch mode
pnpm test -- --watch
```
## Project Structure
```
spicy-automation/
├── bin/
│ └── spicy-cdk.ts # CDK app entry point
├── lib/
│ ├── constructs/ # Reusable constructs
│ │ ├── spicy-vpc.ts # VPC construct
│ │ └── index.ts
│ ├── stacks/ # Stack definitions
│ │ ├── spicy-vpc-stack.ts
│ │ └── index.ts
│ └── index.ts
├── test/
│ └── spicy-cdk.test.ts # Jest tests
├── jenkins/
│ └── vars/ # Jenkins shared library
├── cdk.json # CDK configuration
├── tsconfig.json # TypeScript configuration
└── package.json
```
## Adding a New Construct
### 1. Create the Construct
`lib/constructs/my-construct.ts`:
```typescript
import * as cdk from 'aws-cdk-lib/core';
import { Construct } from 'constructs';
export interface MyConstructProps {
readonly name: string;
// ... other props
}
export class MyConstruct extends Construct {
constructor(scope: Construct, id: string, props: MyConstructProps) {
super(scope, id);
// Create resources...
}
}
```
### 2. Export from Index
`lib/constructs/index.ts`:
```typescript
export * from './spicy-vpc';
export * from './my-construct';
```
### 3. Create a Stack
`lib/stacks/my-stack.ts`:
```typescript
import * as cdk from 'aws-cdk-lib/core';
import { Construct } from 'constructs';
import { MyConstruct, MyConstructProps } from '../constructs/my-construct';
export class MyStack extends cdk.Stack {
constructor(scope: Construct, id: string, props: MyConstructProps & cdk.StackProps) {
super(scope, id, props);
new MyConstruct(this, 'MyConstruct', props);
}
static fromContext(scope: Construct, id: string, stackProps?: cdk.StackProps): MyStack {
const app = scope.node.root as cdk.App;
return new MyStack(app, id, {
...stackProps,
name: app.node.tryGetContext('name') ?? 'default',
});
}
}
```
### 4. Register in App
`bin/spicy-cdk.ts`:
```typescript
import { MyStack } from '../lib/stacks';
switch (stackType) {
case 'vpc':
SpicyVpcStack.fromContext(app, stackName, { env });
break;
case 'my-stack':
MyStack.fromContext(app, stackName, { env });
break;
// ...
}
```
### 5. Add Tests
`test/my-stack.test.ts`:
```typescript
import { Template } from 'aws-cdk-lib/assertions';
import * as cdk from 'aws-cdk-lib/core';
import { MyStack } from '../lib/stacks/my-stack';
describe('MyStack', () => {
test('creates resources', () => {
const app = new cdk.App();
const stack = new MyStack(app, 'TestStack', {
name: 'test',
});
const template = Template.fromStack(stack);
// Assert on resources
template.resourceCountIs('AWS::SomeService::Resource', 1);
});
});
```
## TypeScript Configuration
The project uses CommonJS modules for compatibility with ts-node:
```json
{
"compilerOptions": {
"target": "ES2022",
"module": "CommonJS",
"strict": true,
"esModuleInterop": true
}
}
```
## Debugging
### Enable CDK Debug Output
```bash
CDK_DEBUG=true npx cdk synth ...
```
### View Synthesized Template
Templates are output to `cdk.out/`:
```bash
npx cdk synth -c stackType=vpc -c stackName=test ...
cat cdk.out/test.template.json | jq .
```
### Check Logical IDs
Verify resource logical IDs are stable:
```bash
npx cdk synth ... 2>&1 | grep -E "^ [A-Za-z]+:"
```
## Building (Optional)
While not required (ts-node runs TypeScript directly), you can compile:
```bash
# Compile to JavaScript
pnpm run build
# Watch mode
pnpm run watch
```
Output goes to `dist/`.
## IDE Setup
### VS Code
Recommended extensions:
- ESLint
- Prettier
- AWS Toolkit
`.vscode/settings.json`:
```json
{
"typescript.tsdk": "node_modules/typescript/lib",
"editor.formatOnSave": true
}
```
### Cursor
The project works out of the box with Cursor's TypeScript support.

384
docs/ECS_CLUSTER.md Normal file
View File

@@ -0,0 +1,384 @@
# ECS Cluster Deployment
Deploy production-ready ECS clusters with AWS CDK.
## Features
- **EC2 Capacity Provider** with managed scaling (replaces custom SchedulableContainers metric)
- **Mixed Instances Policy** for Spot support (replaces Autospotting)
- **Launch Templates** with IMDSv2 and gp3 EBS volumes
- **Instance Draining** via lifecycle hooks for graceful task migration
- **Optional Fargate** capacity providers for serverless workloads
- **Internal/External ALBs** with HTTPS support
- **Container Insights** for monitoring
- **Automatic instance refresh** via max instance lifetime
## Quick Start
### Minimal Jenkinsfile - Using CloudFormation Imports
**Minimal props:** Only `vpcStackName` required. All VPC details auto-import from VPC stack exports.
```groovy
@Library(["spicy-automation@main"]) _
spicyECSCluster(
jenkinsAwsCredentialsId: "aws-credentials",
region: "ca-central-1",
stackName: "my-ecs-cluster",
vpcStackName: "my-vpc", // Auto-imports ALL VPC details (VPC ID, CIDR, subnets, AZs)
ownerTag: "MyTeam",
productTag: "my-product",
componentTag: "ecs-cluster",
environment: "dev"
)
```
**What auto-imports from VPC stack:**
- VPC ID from `${vpcStackName}-VPCID`
- VPC CIDR from `${vpcStackName}-VPCCIDR`
- Number of AZs from `${vpcStackName}-NumberOfAZs`
- Private subnet IDs from `${vpcStackName}-PrivateSubnetA1ID`, `${vpcStackName}-PrivateSubnetB1ID`, etc.
- Public subnet IDs from `${vpcStackName}-PublicSubnetAID`, `${vpcStackName}-PublicSubnetBID`, etc. (if `createExternalLoadBalancer: true`)
- Availability zones auto-derived from region and number of AZs
### Production Jenkinsfile with All Options
```groovy
@Library(["spicy-automation@main"]) _
spicyECSCluster(
// AWS Configuration
jenkinsAwsCredentialsId: "aws-credentials",
region: "ca-central-1",
accountId: "123456789012",
stackName: "prod-ecs-cluster",
// VPC Configuration - only vpcStackName required, all VPC details auto-import
vpcStackName: "production-vpc",
// VPC ID, CIDR, subnets, AZs, and numberOfAzs all auto-import from VPC stack exports
// Tags
ownerTag: "Platform",
productTag: "spicy",
componentTag: "ecs-cluster",
environment: "prod",
// Instance Configuration
instanceType: "m5a.xlarge",
additionalInstanceTypes: "m5.xlarge,m5d.xlarge,m5n.xlarge",
keyName: "my-keypair",
ebsVolumeSize: 100,
// Scaling
minClusterSize: 3,
maxClusterSize: 10,
targetCapacityPercent: 100,
// Spot Configuration (for cost savings)
spotEnabled: true,
onDemandPercentage: 50, // 50% On-Demand, 50% Spot
spotAllocationStrategy: "capacity-optimized",
// Load Balancers
createExternalLoadBalancer: true,
createInternalLoadBalancer: true,
certificateArn: "arn:aws:acm:ca-central-1:123456789012:certificate/xxx",
// Fargate (optional hybrid - enables both FARGATE and FARGATE_SPOT)
enableFargate: false,
// Timeouts
drainingTimeout: 900, // 15 minutes for task draining
maxInstanceLifetime: 604800, // 7 days for instance refresh
// Container Insights
containerInsights: true,
// Approval for production
approvers: "admin,platform-team"
)
```
## Parameters Reference
### Required Parameters
| Parameter | Description | Example |
| ------------------------- | -------------------------------------------------------------------------------------------------------------- | ------------------- |
| `jenkinsAwsCredentialsId` | Jenkins credential ID for AWS | `"aws-credentials"` |
| `region` | AWS region | `"ca-central-1"` |
| `stackName` | CloudFormation stack name | `"my-ecs-cluster"` |
| `vpcStackName` | VPC stack name - **required**. All VPC details (VPC ID, CIDR, subnets, AZs) auto-import from VPC stack exports | `"my-vpc"` |
| `ownerTag` | Owner tag value | `"MyTeam"` |
| `productTag` | Product tag value | `"my-product"` |
### Instance Configuration
| Parameter | Default | Description |
| ------------------------- | ----------- | ----------------------------------- |
| `instanceType` | `m5a.large` | Primary EC2 instance type |
| `additionalInstanceTypes` | - | Additional types for Spot diversity |
| `keyName` | - | EC2 key pair for SSH access |
| `ebsVolumeSize` | `100` | EBS volume size in GB |
| `containerInsights` | `true` | Enable Container Insights |
### Scaling Configuration
| Parameter | Default | Description |
| ----------------------- | ------- | -------------------------------------- |
| `minClusterSize` | `2` | Minimum number of instances |
| `maxClusterSize` | `4` | Maximum number of instances |
| `targetCapacityPercent` | `100` | Target utilization for managed scaling |
### Spot Configuration
| Parameter | Default | Description |
| ------------------------ | -------------------- | -------------------------------------- |
| `spotEnabled` | `false` | Enable Spot instances |
| `onDemandPercentage` | `100` | Percentage of On-Demand (rest is Spot) |
| `spotAllocationStrategy` | `capacity-optimized` | Spot allocation strategy |
**Spot Allocation Strategies:**
- `capacity-optimized` - Best for interruption avoidance (recommended)
- `lowest-price` - Best for cost, higher interruption risk
- `capacity-optimized-prioritized` - Prioritizes instance types you specify
### Load Balancer Configuration
| Parameter | Default | Description |
| ---------------------------- | ------- | ----------------------------------------------------------------------------------- |
| `createExternalLoadBalancer` | `false` | Create internet-facing ALB (public subnets auto-imported from VPC stack if enabled) |
| `createInternalLoadBalancer` | `false` | Create internal ALB |
| `certificateArn` | - | ACM certificate for HTTPS |
### Fargate Configuration
| Parameter | Default | Description |
| --------------- | ------- | ---------------------------------------------------------------------- |
| `enableFargate` | `false` | Enable Fargate capacity providers (adds both FARGATE and FARGATE_SPOT) |
### Lifecycle Configuration
| Parameter | Default | Description |
| --------------------- | -------- | --------------------------------- |
| `drainingTimeout` | `900` | Seconds to wait for task draining |
| `maxInstanceLifetime` | `604800` | Max instance age (7 days) |
## Environment-Specific Configuration
### Development/Sandbox
```groovy
spicyECSCluster(
// ... base config ...
environment: "dev",
minClusterSize: 1,
maxClusterSize: 2,
spotEnabled: true,
onDemandPercentage: 0, // 100% Spot for max savings
)
```
### Staging
```groovy
spicyECSCluster(
// ... base config ...
environment: "staging",
minClusterSize: 2,
maxClusterSize: 4,
spotEnabled: true,
onDemandPercentage: 20, // 80% Spot
)
```
### Production
```groovy
spicyECSCluster(
// ... base config ...
environment: "prod",
minClusterSize: 3,
maxClusterSize: 10,
spotEnabled: true,
onDemandPercentage: 50, // 50% On-Demand baseline
approvers: "admin,platform-team"
)
```
## Stack Outputs
The stack exports these values for use by ECS services:
| Output | Export Name | Description |
| -------------------------- | -------------------------------------------- | ---------------------- |
| `ClusterName` | `{stackName}-cluster-name` | ECS cluster name |
| `ClusterArn` | `{stackName}-cluster-arn` | ECS cluster ARN |
| `VPC` | `{stackName}-VPC` | VPC ID |
| `ECSHostSecurityGroup` | `{stackName}-ecs-host-security-group` | EC2 security group |
| `AutoScalingGroupName` | `{stackName}-auto-scaling-group` | ASG name |
| `ExternalLoadBalancerDNS` | `{stackName}-internet-facing-url` | External ALB DNS |
| `ExternalLoadBalancerArn` | `{stackName}-internet-facing-arn` | External ALB ARN |
| `ExternalHTTPListenerArn` | `{stackName}-internet-facing-http-listener` | HTTP listener ARN |
| `ExternalHTTPSListenerArn` | `{stackName}-internet-facing-https-listener` | HTTPS listener ARN |
| `InternalLoadBalancerDNS` | `{stackName}-internal-url` | Internal ALB DNS |
| `InternalLoadBalancerArn` | `{stackName}-internal-arn` | Internal ALB ARN |
| `InternalHTTPListenerArn` | `{stackName}-internal-http-listener` | HTTP listener ARN |
| `InternalHTTPSListenerArn` | `{stackName}-internal-https-listener` | HTTPS listener ARN |
| `LogsBucketName` | `{stackName}-logs-s3-bucket` | ALB access logs bucket |
## How It Works
### Capacity Providers (Replaces Custom Scaling)
The cluster uses **ECS Managed Scaling** via Capacity Providers:
```
┌─────────────────────────────────────────────────────────┐
│ ECS Cluster │
├─────────────────────────────────────────────────────────┤
│ Capacity Providers: │
│ ┌─────────────────────────────────────────────────┐ │
│ │ EC2 Capacity Provider │ │
│ │ - Managed Scaling: ON │ │
│ │ - Target Capacity: 100% │ │
│ │ - Min Scaling Step: 1 │ │
│ │ - Max Scaling Step: 10000 │ │
│ └─────────────────────────────────────────────────┘ │
│ ┌─────────────────────────────────────────────────┐ │
│ │ FARGATE (optional) │ │
│ └─────────────────────────────────────────────────┘ │
│ ┌─────────────────────────────────────────────────┐ │
│ │ FARGATE_SPOT (optional) │ │
│ └─────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────┘
```
This replaces the legacy `SchedulableContainers` Lambda metric with AWS-native scaling.
### Mixed Instances Policy (Replaces Autospotting)
When `spotEnabled: true`:
```
┌─────────────────────────────────────────────────────────┐
│ Auto Scaling Group │
├─────────────────────────────────────────────────────────┤
│ Mixed Instances Policy: │
│ ┌─────────────────────────────────────────────────┐ │
│ │ On-Demand Base Capacity: 0 │ │
│ │ On-Demand % Above Base: 50% │ │
│ │ Spot Allocation: capacity-optimized │ │
│ └─────────────────────────────────────────────────┘ │
│ │
│ Instance Type Overrides: │
│ - m5a.large (primary) │
│ - m5.large │
│ - m5d.large │
│ - m5n.large │
└─────────────────────────────────────────────────────────┘
```
Benefits over Autospotting:
- No Lambda to maintain
- Faster response (no polling delay)
- Better capacity data (AWS-native)
- Simpler architecture
### Instance Draining
Two-layer draining for zero-downtime:
1. **Spot Interruption Draining** (native ECS):
```bash
ECS_ENABLE_SPOT_INSTANCE_DRAINING=true
```
ECS agent drains tasks on 2-minute Spot termination notice.
2. **Lifecycle Hook Draining** (Lambda):
- ASG sends termination event to SNS
- Lambda sets instance to DRAINING
- Waits for running tasks to migrate
- Completes lifecycle action
### Launch Template Features
- **IMDSv2 Required**: Enhanced metadata security
- **gp3 EBS Volumes**: Better performance, lower cost than gp2
- **Encrypted Volumes**: EBS encryption enabled
- **SSM Agent**: Pre-installed for Session Manager access
## Migrating from Legacy Automation
### Parameter Mapping
| Legacy (Ansible) | New (CDK) |
| ----------------------------------- | ------------------------------ |
| `stackName` | `stackName` |
| `instanceType` | `instanceType` |
| `minClusterSize` | `minClusterSize` |
| `maxClusterSize` | `maxClusterSize` |
| `spotEnabled` | `spotEnabled` |
| `minOnDemandPercentage` | `onDemandPercentage` |
| `largestContainerCPUReservation` | (not needed - managed scaling) |
| `largestContainerMemoryReservation` | (not needed - managed scaling) |
| `clusterScaleUpAdjustment` | (not needed - managed scaling) |
| `clusterScaleDownAdjustment` | (not needed - managed scaling) |
### Removed Features
These legacy features are no longer needed:
- **SchedulableContainers Lambda**: Replaced by Capacity Provider managed scaling
- **Autospotting**: Replaced by Mixed Instances Policy
- **Launch Configurations**: Replaced by Launch Templates
- **gp2 volumes**: Upgraded to gp3
- **IMDSv1**: Now requires IMDSv2
## Troubleshooting
### Instances Not Joining Cluster
Check the ECS agent logs:
```bash
docker logs ecs-agent
cat /var/log/ecs/ecs-agent.log
```
Verify cluster name in user data:
```bash
cat /etc/ecs/ecs.config
```
### Tasks Not Draining
Check Lambda logs in CloudWatch:
```
/aws/lambda/{stackName}-DrainingLambda
```
### Spot Interruptions
Monitor with CloudWatch metrics:
- `AWS/EC2Spot` → `InterruptionRate`
- `AWS/ECS` → `CPUReservation`, `MemoryReservation`
Consider increasing `onDemandPercentage` for critical workloads.
## Cost Optimization Tips
1. **Use Spot in non-prod**: `onDemandPercentage: 0`
2. **Multiple instance types**: Better Spot availability
3. **Right-size instances**: Match to your container sizes
4. **Enable Fargate Spot**: For batch/background tasks
5. **Set max instance lifetime**: Force instance refresh for patches

867
docs/ECS_SERVICE.md Normal file
View File

@@ -0,0 +1,867 @@
# ECS Service Deployment
Deploy ECS services with mixed capacity provider strategies for EC2 + Fargate burst.
## Features
- **Blue/Green Deployments** - Zero-downtime deploys with instant rollback via hostname swap
- **Mixed Capacity Provider Strategy** - EC2 base + Fargate burst for cost optimization
- **Auto-scaling** on CPU, Memory, and ALB request count
- **ALB Integration** with host/path routing
- **Deployment Circuit Breaker** with automatic rollback
- **ECS Exec** for interactive debugging
- **Secrets Manager** integration
- **CloudWatch Logs** with configurable retention
- **Pipeline Hooks** - Custom behavior at every stage
## Quick Start
### Minimal Jenkinsfile (EC2 Only) - Using CloudFormation Imports
**Minimal props:** Only `clusterName` required. VPC info auto-imports from cluster stack exports.
```groovy
@Library(["spicy-automation@main"]) _
spicyECSService(
jenkinsAwsCredentialsId: "aws-credentials",
region: "ca-central-1",
stackName: "my-api-dev",
serviceName: "my-api",
// Cluster info - VPC ID auto-imports from ${clusterName}-VPC
clusterName: "my-cluster-dev",
// Container
image: "nexus.kodeniks.com/docker-hosted/my-api:latest",
containerPort: 3000,
// Tags
ownerTag: "MyTeam",
productTag: "my-product",
componentTag: "api",
environment: "dev"
)
```
**What auto-imports:**
- VPC stack name from `${clusterStackName}-VPCStackName` (cluster stack export)
- VPC ID from `${clusterStackName}-VPC` (cluster stack export)
- VPC CIDR from `${vpcStackName}-VPCCIDR` (VPC stack export, using imported VPC stack name)
- Private subnets from `${vpcStackName}-PrivateSubnetA1ID`, etc. (VPC stack exports)
- Number of AZs from `${vpcStackName}-NumberOfAZs` (VPC stack export)
- Logs bucket from `${clusterStackName}-logs-s3-bucket` (cluster stack export)
**Note:** The service stack imports the VPC stack name from the cluster stack export `${clusterStackName}-VPCStackName`, then uses that to import all VPC details (CIDR, subnets, AZs) from the VPC stack exports.
### Mixed Capacity Strategy (EC2 + Fargate Burst)
```groovy
@Library(["spicy-automation@main"]) _
spicyECSService(
jenkinsAwsCredentialsId: "aws-credentials",
region: "ca-central-1",
stackName: "my-api-prod",
serviceName: "my-api",
// Cluster info - VPC details auto-import from cluster/VPC stack exports
clusterName: "my-cluster-prod",
vpcStackName: "my-vpc", // Optional: auto-imported from cluster stack if not provided
// Container
image: "nexus.kodeniks.com/docker-hosted/my-api:v1.2.3",
containerPort: 3000,
cpu: 512,
memory: 1024,
// Mixed capacity strategy
capacityProviderStrategy: [
[capacityProvider: "my-cluster-prod-ec2", base: 2, weight: 3], // First 2 on EC2, then 75%
[capacityProvider: "FARGATE_SPOT", weight: 1], // 25% burst to Fargate Spot
],
// Scaling
desiredCount: 2,
minCapacity: 2,
maxCapacity: 20,
targetCpuUtilization: 70,
targetMemoryUtilization: 80,
// Routing - Use cluster ALB (default)
useClusterAlb: true, // Use cluster ALB (default)
albScheme: "internet-facing", // or "internal"
hostHeader: "api.example.com",
priority: 100,
healthCheckPath: "/health",
// Tags
ownerTag: "Platform",
productTag: "my-product",
componentTag: "api",
environment: "prod",
// Approval for prod
approvers: "admin,platform-team"
)
```
## Parameters Reference
### Required Parameters
| Parameter | Description | Example |
| ------------------------- | ---------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------- |
| `jenkinsAwsCredentialsId` | Jenkins credential ID for AWS | `"aws-credentials"` |
| `region` | AWS region | `"ca-central-1"` |
| `stackName` | CloudFormation stack name | `"my-api-dev"` |
| `serviceName` | ECS service name | `"my-api"` |
| `clusterName` | ECS cluster name (required) - used to auto-import VPC stack name, VPC ID, and other details from cluster stack exports | `"my-cluster-dev"` |
| `clusterStackName` | Cluster stack name (defaults to `clusterName`) - used for CloudFormation imports | `"my-cluster-dev"` |
| `vpcStackName` | VPC stack name (auto-imported from `${clusterStackName}-VPCStackName`, or provide explicitly) | `"my-vpc"` |
| `albStackName` | ALB stack name (for individual ALB) - used to auto-import ALB ARN and listener ARNs from ALB stack | `"my-service-alb"` |
| `image` | Docker image URI | `"nexus.kodeniks.com/docker-hosted/my-api:latest"` |
| `containerPort` | Container port | `3000` |
| `ownerTag` | Owner tag | `"MyTeam"` |
| `productTag` | Product tag | `"my-product"` |
### Container Configuration
| Parameter | Default | Description |
| ------------------ | ------- | --------------------------------------- |
| `containerPort` | `3000` | Container port |
| `cpu` | `256` | CPU units (256 = 0.25 vCPU) |
| `memory` | `512` | Memory in MiB |
| `environment_vars` | - | Map of environment variables |
| `secrets` | - | Map of secret ARNs (env var name → ARN) |
### Capacity Provider Strategy
| Parameter | Default | Description |
| -------------------------- | --------------- | ---------------------------------- |
| `capacityProviderStrategy` | Cluster default | Array of capacity provider configs |
| `desiredCount` | `2` | Initial task count |
**Strategy Item Format:**
```groovy
[
capacityProvider: "provider-name", // EC2 provider name, "FARGATE", or "FARGATE_SPOT"
base: 0, // Minimum tasks on this provider
weight: 1 // Distribution weight
]
```
### Auto-Scaling
| Parameter | Default | Description |
| ------------------------- | ------- | ------------------------------------ |
| `minCapacity` | - | Minimum tasks (required for scaling) |
| `maxCapacity` | - | Maximum tasks (required for scaling) |
| `targetCpuUtilization` | - | Target CPU % (0-100) |
| `targetMemoryUtilization` | - | Target memory % (0-100) |
| `targetRequestsPerTarget` | - | Target requests per task per minute |
### ALB Routing
| Parameter | Default | Description |
| --------------------- | ------- | -------------------------------------------------------------------------------------------------- |
| `useClusterAlb` | `true` | Use cluster ALB (default). Set to `false` to use individual ALB per service |
| `albScheme` | - | ALB scheme: `"internet-facing"` or `"internal"` (required when `useClusterAlb=true`) |
| `albStackName` | - | ALB stack name for individual ALB (auto-derived from `{stackName}-alb` when `useClusterAlb=false`) |
| `certificateArn` | - | ACM certificate ARN (required for individual ALB when `useClusterAlb=false`) |
| `redirectHttpToHttps` | - | Redirect HTTP to HTTPS (for individual ALB) |
| `hostHeader` | - | Host header for routing |
| `pathPatterns` | - | Comma-separated path patterns |
| `priority` | `100` | Listener rule priority |
| `stickiness` | `false` | Enable session stickiness |
| `stickinessDuration` | `86400` | Stickiness duration (seconds) |
### Health Check
| Parameter | Default | Description |
| ----------------- | --------- | --------------------- |
| `healthCheckPath` | `/health` | Health check URL path |
### DNS (Route53) - Blue/Green Only
| Parameter | Default | Description |
| ------------------ | ------- | -------------------------------------------------------------------------- |
| `bgHostedZoneId` | - | Route53 hosted zone ID for blue/green DNS records (optional) |
| `hostName` | - | Simple hostname (e.g., "api.example.com") - auto-generates active/inactive |
| `activeHostname` | - | Active hostname (e.g., "api.example.com") - explicit hostname |
| `inactiveHostname` | - | Inactive hostname (e.g., "inactive-api.example.com") - explicit hostname |
### Deployment
| Parameter | Default | Description |
| ---------------------- | ------- | --------------------------------- |
| `circuitBreaker` | `true` | Enable deployment circuit breaker |
| `enableExecuteCommand` | `true` | Enable ECS Exec |
## Mixed Capacity Strategy Explained
### How Task Distribution Works
```
capacityProviderStrategy: [
[capacityProvider: "my-cluster-ec2", base: 2, weight: 3],
[capacityProvider: "FARGATE_SPOT", weight: 1],
]
```
**Distribution:**
1. **Base tasks** go to their designated provider first
2. **Additional tasks** are distributed by weight ratio
**Example with 10 tasks:**
```
Tasks 1-2: EC2 (base)
Tasks 3-8: EC2 (weight 3 = 75%)
Tasks 9-10: FARGATE_SPOT (weight 1 = 25%)
```
### Recommended Strategies
**Development (Cost Optimized):**
```groovy
capacityProviderStrategy: [
[capacityProvider: "FARGATE_SPOT", weight: 1],
]
```
**Staging (Balanced):**
```groovy
capacityProviderStrategy: [
[capacityProvider: "my-cluster-staging-ec2", base: 1, weight: 2],
[capacityProvider: "FARGATE_SPOT", weight: 1],
]
```
**Production (Reliability + Burst):**
```groovy
capacityProviderStrategy: [
[capacityProvider: "my-cluster-prod-ec2", base: 2, weight: 3],
[capacityProvider: "FARGATE_SPOT", weight: 1],
]
```
## Building Docker Images
The pipeline automatically builds and pushes Docker images if a `Dockerfile` exists:
```groovy
spicyECSService(
// ... other params ...
// Image will be built and pushed automatically
serviceName: "my-api", // Used as image name
imageTag: env.BUILD_NUMBER, // Or defaults to Git SHA
// Optional: customize build
dockerfile: "Dockerfile.prod",
dockerContext: "./app",
dockerBuildArgs: [
NODE_ENV: "production",
BUILD_DATE: new Date().format("yyyy-MM-dd")
],
// Or skip build and use existing image
buildImage: false,
image: "nexus.kodeniks.com/docker-hosted/my-api:v1.2.3",
)
```
## Environment Variables and Secrets
### Plain Environment Variables
```groovy
spicyECSService(
// ...
environment_vars: [
NODE_ENV: "production",
LOG_LEVEL: "info",
API_URL: "https://api.example.com"
],
)
```
### Secrets from Secrets Manager
```groovy
spicyECSService(
// ...
secrets: [
DATABASE_URL: "arn:aws:secretsmanager:ca-central-1:123456789:secret:my-db-creds",
API_KEY: "arn:aws:secretsmanager:ca-central-1:123456789:secret:api-keys::api_key",
],
)
```
**Format:** `"ENV_VAR_NAME": "secret-arn"` or `"ENV_VAR_NAME": "secret-arn::json-key"`
## Auto-Scaling Behavior
### CPU-Based Scaling
```groovy
spicyECSService(
// ...
minCapacity: 2,
maxCapacity: 20,
targetCpuUtilization: 70,
)
```
- Scales up when average CPU > 70%
- Scales down when average CPU < 70%
- Cooldown: 60s scale-out, 300s scale-in
### Request-Based Scaling
```groovy
spicyECSService(
// ...
minCapacity: 2,
maxCapacity: 20,
targetRequestsPerTarget: 1000, // 1000 requests/minute per task
)
```
### Combined Scaling
```groovy
spicyECSService(
// ...
minCapacity: 2,
maxCapacity: 20,
targetCpuUtilization: 70,
targetMemoryUtilization: 80,
targetRequestsPerTarget: 1000,
)
```
All policies run independently - whichever triggers first wins.
## Deployment Circuit Breaker
Enabled by default. If a deployment fails:
1. ECS detects unhealthy tasks
2. After threshold failures, deployment stops
3. Automatic rollback to previous version
4. CloudWatch alarm triggered
Disable if needed:
```groovy
spicyECSService(
// ...
circuitBreaker: false,
)
```
## ECS Exec (Debugging)
Enabled by default. Connect to running containers:
```bash
aws ecs execute-command \
--cluster my-cluster-dev \
--task <task-id> \
--container my-api \
--interactive \
--command "/bin/sh"
```
## Stack Outputs
| Output | Export Name | Description |
| ------------------- | --------------------------------- | -------------------- |
| `ServiceName` | `{stackName}-service-name` | ECS service name |
| `ServiceArn` | `{stackName}-service-arn` | ECS service ARN |
| `TaskDefinitionArn` | `{stackName}-task-definition-arn` | Task definition ARN |
| `LogGroupName` | `{stackName}-log-group` | CloudWatch log group |
| `TargetGroupArn` | `{stackName}-target-group-arn` | ALB target group ARN |
## Troubleshooting
### Tasks Stuck in PENDING
**Cause:** No capacity available
**Solutions:**
1. Check EC2 instances have room for tasks
2. Verify Fargate is in capacity strategy
3. Check task CPU/memory fits available resources
### Tasks Failing Health Checks
```bash
# Check container logs
aws logs tail /ecs/my-api --follow
# Check target group health
aws elbv2 describe-target-health --target-group-arn <arn>
```
### Deployment Rolling Back
```bash
# Check deployment events
aws ecs describe-services --cluster my-cluster --services my-api
# Check task stopped reasons
aws ecs describe-tasks --cluster my-cluster --tasks <task-id>
```
### ECS Exec Not Working
1. Verify `enableExecuteCommand: true`
2. Check task role has SSM permissions
3. Ensure SSM agent is running in container
## Blue/Green Deployments
Blue/Green deployments provide zero-downtime releases with instant rollback capability.
### How It Works
1. **Two Services**: `myapp-blue` and `myapp-green` run simultaneously
2. **DNS Records**: Both active and inactive hostnames point to the same ALB (DNS never changes)
3. **ALB Listener Rules**: Traffic routing is controlled by listener rule priorities, not DNS
4. **Deploy to Inactive**: New version deploys to inactive service with higher priority rule
5. **Test Inactive**: Run integration tests against inactive hostname (same ALB, different rule)
6. **Swap Hostnames**: Update listener rule priorities to swap active/inactive (no DNS changes)
7. **Keep for Rollback**: Old version stays running for rollback window
**Important**: DNS records are created once and never change. Both `api.example.com` and `inactive-api.example.com` always resolve to the same ALB. Traffic routing is controlled entirely by ALB listener rule priorities:
- Active service: Lower priority (e.g., 100) for active hostname
- Inactive service: Higher priority (e.g., 200) for inactive hostname
- When swapping: Only the listener rule priorities are updated via CDK deployment
### Architecture
```
┌─────────────────────────────────────┐
│ ALB │
│ ┌─────────────┐ ┌─────────────┐ │
│ │ api.example │ │ inactive-api│ │
│ │ Priority: │ │ Priority: │ │
│ │ 100 │ │ 200 │ │
│ └──────┬──────┘ └──────┬──────┘ │
└─────────┼────────────────┼──────────┘
│ │
┌───────────────▼──┐ ┌──────▼───────────────┐
│ BLUE Service │ │ GREEN Service │
│ (v1 - Active) │ │ (v2 - Inactive) │
│ ████████████ │ │ ████████████ │
│ ████████████ │ │ ████████████ │
└──────────────────┘ └──────────────────────┘
```
### Enable Blue/Green
```groovy
@Library(["spicy-automation@main"]) _
spicyECSService(
jenkinsAwsCredentialsId: "aws-credentials",
region: "ca-central-1",
stackName: "my-api-prod",
serviceName: "my-api",
// Cluster info - VPC details auto-import from cluster/VPC stack exports
clusterName: "my-cluster-prod",
vpcStackName: "my-vpc", // Optional: auto-imported from cluster stack if not provided
// Container
image: "nexus.kodeniks.com/docker-hosted/my-api:latest",
containerPort: 3000,
// Enable Blue/Green
blueGreen: true,
hostName: "api.example.com", // Auto-generates active/inactive hostnames
bgHostedZoneId: "Z1234567890", // Route53 hosted zone (optional, for automatic DNS)
// ALB Configuration
useClusterAlb: false, // Use individual ALB (required for blue/green with individual ALB)
albScheme: "internet-facing",
certificateArn: "arn:aws:acm:ca-central-1:123456789:certificate/xxx",
redirectHttpToHttps: true,
healthCheckPath: "/health",
priority: 100,
// Tags
ownerTag: "Platform",
productTag: "my-product",
componentTag: "api",
environment: "prod",
// Test inactive before swap
blueGreenTest: { args, buildInfo ->
sh "curl -f https://${buildInfo.inactiveHostname}/health"
sh "./run-integration-tests.sh ${buildInfo.inactiveHostname}"
},
// Test active after swap
smokeTest: { args, buildInfo ->
sh "curl -f https://${buildInfo.activeHostname}/health"
},
)
```
### Blue/Green Parameters
| Parameter | Default | Description |
| --------------------- | ------- | -------------------------------------------------------------------------- |
| `blueGreen` | `false` | Enable blue/green deployment |
| `hostName` | - | Simple hostname (e.g., "api.example.com") - auto-generates active/inactive |
| `activeHostname` | - | Active hostname (e.g., "api.example.com") - explicit hostname |
| `inactiveHostname` | - | Inactive hostname (e.g., "inactive-api.example.com") - explicit hostname |
| `bgHostedZoneId` | - | Route53 hosted zone ID for automatic DNS records (optional) |
| `rollbackWindowHours` | `2` | Hours to keep old version for rollback |
### Rollback
Instant rollback by swapping hostnames - no new deployment needed:
```groovy
@Library(["spicy-automation@main"]) _
spicyRollback(
jenkinsAwsCredentialsId: "aws-credentials",
region: "ca-central-1",
stackName: "my-api-prod",
serviceName: "my-api",
// Same params as deploy
clusterName: "my-cluster-prod",
vpcStackName: "my-vpc", // Optional: auto-imported from cluster stack if not provided
hostName: "api.example.com", // Or use activeHostname/inactiveHostname
bgHostedZoneId: "Z1234567890", // If using Route53
ownerTag: "Platform",
productTag: "my-product",
componentTag: "api",
environment: "prod",
)
```
**Rollback time: ~30 seconds** (just ALB rule updates, no DNS propagation delays)
**How it works:**
- DNS records for both active and inactive hostnames already exist and point to the same ALB
- Rollback simply updates ALB listener rule priorities (e.g., swap priority 100 ↔ 200)
- No DNS changes are needed, so rollback is instant
### State Management
Active color is stored in SSM Parameter Store:
```
/spicy/{serviceName}/active-color = "blue" | "green"
```
Check current state:
```bash
aws ssm get-parameter --name /spicy/my-api/active-color --query 'Parameter.Value'
```
### Testing Blue/Green DNS
To verify blue/green DNS configuration is working correctly:
1. **Verify DNS Records**:
```bash
# Both hostnames should resolve to the same ALB
dig api.example.com
dig inactive-api.example.com
# Both should return the same ALB DNS name
```
2. **Verify ALB Listener Rules**:
```bash
# Get ALB ARN from stack outputs
ALB_ARN=$(aws cloudformation describe-stacks --stack-name my-api-prod-alb \
--query 'Stacks[0].Outputs[?OutputKey==`LoadBalancerArn`].OutputValue' --output text)
# Get listener ARN
LISTENER_ARN=$(aws cloudformation describe-stacks --stack-name my-api-prod-alb \
--query 'Stacks[0].Outputs[?OutputKey==`HTTPSListenerArn`].OutputValue' --output text)
# Check listener rules
aws elbv2 describe-rules --listener-arn $LISTENER_ARN
# Active service should have priority 100 for api.example.com
# Inactive service should have priority 200 for inactive-api.example.com
```
3. **Test Traffic Routing**:
```bash
# Get ALB DNS name
ALB_DNS=$(aws cloudformation describe-stacks --stack-name my-api-prod-alb \
--query 'Stacks[0].Outputs[?OutputKey==`LoadBalancerDNS`].OutputValue' --output text)
# Test active hostname (should hit active service)
curl -H "Host: api.example.com" https://$ALB_DNS/health
# Test inactive hostname (should hit inactive service)
curl -H "Host: inactive-api.example.com" https://$ALB_DNS/health
```
4. **Verify After Swap**:
```bash
# After swapping, priorities should be reversed
# New active should have priority 100
# Old active should have priority 200
aws elbv2 describe-rules --listener-arn $LISTENER_ARN
```
**Important**: DNS records never change - they always point to the same ALB. Only listener rule priorities change during swaps.
---
## Pipeline Hooks
Customize pipeline behavior at every stage with Groovy closures.
### Hook Execution Order
```
┌─────────────┐
│ Checkout │
└──────┬──────┘
┌─────────────┐ ┌──────────────┐
│ Build Image │────▶│ onPostBuild │ ← Unit tests, linting
└──────┬──────┘ └──────────────┘
┌─────────────┐
│ onPreDeploy │ ← Setup, integration test prep
└──────┬──────┘
┌─────────────┐
│ Deploy │
└──────┬──────┘
▼ (Blue/Green only)
┌───────────────┐
│ blueGreenTest │ ← Test inactive stack
└───────┬───────┘
┌─────────────┐
│ Swap (B/G) │
└──────┬──────┘
┌──────────────┐
│ onPostDeploy │ ← Cleanup, notifications
└──────┬───────┘
┌─────────────┐
│ smokeTest │ ← Smoke tests
└─────────────┘
```
### Available Hooks
#### `buildCommand`
Custom build command (replaces default `docker build`):
```groovy
spicyECSService(
// ...
buildCommand: { args, buildInfo ->
sh "make build"
sh "docker tag my-app:latest ${args.image}"
},
)
```
#### `onPostBuild`
After build completes (linting, unit tests):
```groovy
spicyECSService(
// ...
onPostBuild: { args, buildInfo ->
sh "npm run lint"
sh "npm run test:unit"
junit 'coverage/junit.xml'
publishHTML(target: [
reportDir: 'coverage/lcov-report',
reportFiles: 'index.html',
reportName: 'Coverage'
])
},
)
```
#### `onPreDeploy`
Before deployment (setup, test prep):
```groovy
spicyECSService(
// ...
onPreDeploy: { args, buildInfo ->
sh "docker-compose -f docker-compose.test.yml up -d"
sh "./wait-for-services.sh"
},
)
```
#### `blueGreenTest`
After inactive stack is up (integration tests):
```groovy
spicyECSService(
// ...
blueGreen: true,
blueGreenTest: { args, buildInfo ->
echo "Testing inactive: ${buildInfo.inactiveHostname}"
sh "curl -f https://${buildInfo.inactiveHostname}/health"
sh "./run-integration-tests.sh ${buildInfo.inactiveHostname}"
},
)
```
**Available in `buildInfo`:**
- `inactiveHostname` - Inactive service hostname
- `activeHostname` - Active service hostname
- `currentActive` - Current active color (blue/green)
- `targetColor` - Deployment target color
#### `onPostDeploy`
After deployment succeeds (cleanup, notifications):
```groovy
spicyECSService(
// ...
onPostDeploy: { args, buildInfo ->
sh "docker-compose -f docker-compose.test.yml down"
slackSend(channel: '#deploys', message: "Deployed ${args.serviceName}")
},
)
```
#### `smokeTest`
After deployment or swap (smoke tests):
```groovy
spicyECSService(
// ...
smokeTest: { args, buildInfo ->
sh "curl -f https://${buildInfo.activeHostname}/health"
sh "curl -f https://${buildInfo.activeHostname}/api/status"
},
)
```
### Hook Parameters
All hooks receive:
| Parameter | Type | Description |
| ----------- | ---- | ------------------------- |
| `args` | Map | All pipeline arguments |
| `buildInfo` | Map | Build context (see below) |
**`buildInfo` contents:**
| Key | Description |
| ------------------ | ------------------------------- |
| `commitSha` | Git commit SHA |
| `branch` | Git branch name |
| `image` | Built Docker image URI |
| `imageTag` | Docker image tag |
| `activeHostname` | Active service hostname |
| `inactiveHostname` | Inactive service hostname (B/G) |
| `currentActive` | Current active color (B/G) |
| `targetColor` | Deployment target color (B/G) |
| `healthCheckPath` | Health check path |
---
## Log Streaming
The pipeline automatically streams ECS logs during deployment:
```groovy
spicyECSService(
// ...
streamLogs: true, // Default: true
)
```
Logs show:
- Container startup logs
- Health check results
- Recent ECS service events
Disable if not needed:
```groovy
spicyECSService(
// ...
streamLogs: false,
)
```
---
## Migration from Legacy
| Legacy Parameter | New Parameter |
| --------------------------------- | --------------------------------------------------- |
| `desiredCount` | `desiredCount` |
| `minCapacity` | `minCapacity` |
| `maxCapacity` | `maxCapacity` |
| `cpu` | `cpu` |
| `memory` | `memory` |
| `containerPort` | `containerPort` |
| `healthCheckUrl` | `healthCheckPath` |
| `albPriority` | `priority` |
| `albScheme: internet-facing` | `useClusterAlb: true, albScheme: "internet-facing"` |
| `albScheme: internal` | `useClusterAlb: true, albScheme: "internal"` |
| `targetGroupStickinessEnabled` | `stickiness` |
| `targetGroupLBCookieDurationSecs` | `stickinessDuration` |
| `vpcId`, `vpcCidrBlock`, etc. | `clusterName`, `vpcStackName` (auto-imports) |
### Key Differences
1. **CloudFormation imports** - VPC details auto-import from cluster/VPC stack exports (no explicit IDs needed)
2. **Individual ALB support** - Set `useClusterAlb: false` for dedicated ALB per service
3. **No Ansible** - Pure CDK deployment
4. **Capacity providers** - Native mixed EC2/Fargate support
5. **Circuit breaker** - Native deployment rollback
6. **ECS Exec** - Built-in debugging support
7. **Blue/Green hostnames** - Use `hostName` for simple setup or `activeHostname`/`inactiveHostname` for explicit control

View File

@@ -0,0 +1,46 @@
# Example Jenkinsfile for NPM Package Publishing
## Basic Usage
Create a `Jenkinsfile` in your repository root:
```groovy
@Library(["spicy-automation@main"]) _
publishNpmPackage()
```
## With Scoped Package
```groovy
@Library(["spicy-automation@main"]) _
publishNpmPackage(
packageName: '@kodeniks/my-package',
)
```
## With Custom Options
```groovy
@Library(["spicy-automation@main"]) _
publishNpmPackage(
packageName: '@kodeniks/my-package',
buildScript: 'build.sh',
versionScript: './scripts/docker-ensure-version.sh',
registry: 'nexus.kodeniks.com',
repository: 'npm-hosted',
credentialsId: 'kodeniks-nexus-repository',
agentLabel: 'docker',
nodeImage: 'node:24-alpine',
)
```
## Important Notes
1. **No comments at the top** - Jenkinsfiles should start directly with `@Library` or the function call
2. **Package name is optional** - It will be read from `package.json` if not provided
3. **Scoped packages are supported** - Use format `@scope/package-name`
4. **Requires Docker agent** - The agent must have Docker available to run Node containers

497
docs/JENKINS_UTILITIES.md Normal file
View File

@@ -0,0 +1,497 @@
# Jenkins Utilities Reference
All available utility functions in the `spicy-automation` Jenkins shared library.
## Table of Contents
- [cdkUtils](#cdkutils) - CDK command execution
- [dockerUtils](#dockerutils) - Docker/Nexus operations
- [gitUtils](#gitutils) - Git information
- [giteaUtils](#giteautils) - Gitea commit status
- [awsUtils](#awsutils) - AWS CLI operations
- [spicyUtils](#spicyutils) - Pipeline utilities
- [accounts](#accounts) - Account configurations
- [manualApproval](#manualapproval) - Approval gates
---
## cdkUtils
Execute AWS CDK commands from Jenkins pipelines.
### `cdkUtils.deploy(Map args)`
Deploy a CDK stack.
```groovy
cdkUtils.deploy(
account: [
region: "ca-central-1",
jenkinsAwsCredentialsId: "aws-credentials",
accountId: "123456789012" // optional
],
stackName: "my-stack",
stackType: "vpc", // vpc, ecs-cluster, ecs-service
context: [ // Additional CDK context
ownerTag: "MyTeam",
productTag: "myapp"
],
workDir: "." // optional, defaults to current dir
)
```
### `cdkUtils.diff(Map args)`
Show changes between deployed and local stack.
```groovy
cdkUtils.diff(
account: account,
stackName: "my-stack",
stackType: "vpc",
context: context
)
```
### `cdkUtils.synth(Map args)`
Synthesize CloudFormation template.
```groovy
cdkUtils.synth(
account: account,
stackName: "my-stack",
stackType: "vpc",
context: context
)
```
### `cdkUtils.destroy(Map args)`
Destroy a CDK stack.
```groovy
cdkUtils.destroy(
account: account,
stackName: "my-stack",
stackType: "vpc",
context: context
)
```
### `cdkUtils.install(Map args)`
Install CDK dependencies.
```groovy
cdkUtils.install(workDir: ".")
```
### `cdkUtils.build(Map args)`
Build CDK TypeScript (optional - ts-node runs directly).
```groovy
cdkUtils.build(workDir: ".")
```
### `cdkUtils.test(Map args)`
Run CDK tests.
```groovy
cdkUtils.test(workDir: ".")
```
---
## dockerUtils
Docker operations with Nexus registry support.
**Default Configuration:**
- Registry: `nexus.kodeniks.com`
- Repository: `docker-hosted`
- Credentials: `kodeniks-nexus-repository`
### `dockerUtils.buildAndPush(Map args)`
Build and push a Docker image to Nexus.
```groovy
def imageRef = dockerUtils.buildAndPush(
imageName: "my-service", // required
dockerfile: "Dockerfile", // default: Dockerfile
context: ".", // default: .
buildArgs: [ // optional
NODE_ENV: "production",
VERSION: "1.0.0"
],
tagLatest: true, // default: true on main branch
compareWithLatest: true, // default: true, skip if unchanged
registry: "nexus.kodeniks.com", // optional override
repository: "docker-hosted", // optional override
credentialsId: "kodeniks-nexus-repository" // optional override
)
echo "Pushed: ${imageRef}"
// Output: nexus.kodeniks.com/docker-hosted/my-service:abc1234
```
### `dockerUtils.buildImage(Map args)`
Build a Docker image without pushing.
```groovy
def containerName = dockerUtils.buildImage(
imageName: "my-service",
dockerfile: "Dockerfile",
context: ".",
buildArgs: [NODE_ENV: "production"]
)
```
### `dockerUtils.tagImage(Map args)`
Tag an image for Nexus registry.
```groovy
dockerUtils.tagImage(
imageName: "my-service",
tagLatest: true // also tag as 'latest'
)
```
### `dockerUtils.pushImage(Map args)`
Push image to Nexus with smart comparison.
```groovy
def pushed = dockerUtils.pushImage(
imageName: "my-service",
tagLatest: true,
compareWithLatest: true // skip if identical to remote latest
)
if (pushed) {
echo "Image was pushed"
} else {
echo "Image unchanged, push skipped"
}
```
### `dockerUtils.pullImage(Map args)`
Pull an image from Nexus.
```groovy
def imageRef = dockerUtils.pullImage(
imageName: "my-service",
tag: "latest" // default: latest
)
```
### `dockerUtils.login(Map args)`
Login to Nexus registry.
```groovy
dockerUtils.login()
// Or with custom registry:
dockerUtils.login(registry: "other-nexus.example.com")
```
### `dockerUtils.prune()`
Clean up dangling Docker images.
```groovy
dockerUtils.prune()
```
### `dockerUtils.copyFromImage(Map args)`
Copy files from a Docker image.
```groovy
dockerUtils.copyFromImage(
imageID: "my-image:latest",
dockerPath: "/app/dist",
jenkinsPath: "./dist"
)
```
### `dockerUtils.getImageRefSHA(Map args)`
Get full image reference with SHA tag.
```groovy
def ref = dockerUtils.getImageRefSHA(imageName: "my-service")
// Returns: nexus.kodeniks.com/docker-hosted/my-service:abc1234
```
### `dockerUtils.getImageRefLatest(Map args)`
Get full image reference with latest tag.
```groovy
def ref = dockerUtils.getImageRefLatest(imageName: "my-service")
// Returns: nexus.kodeniks.com/docker-hosted/my-service:latest
```
---
## gitUtils
Git repository information.
### `gitUtils.getSHA()`
Get full commit SHA.
```groovy
def sha = gitUtils.getSHA()
// Returns: abc123def456...
```
### `gitUtils.getShortSHA()`
Get short commit SHA (7 characters).
```groovy
def shortSha = gitUtils.getShortSHA()
// Returns: abc123d
```
### `gitUtils.getBranch()`
Get current branch name.
```groovy
def branch = gitUtils.getBranch()
// Returns: main, feature/xyz, etc.
```
### `gitUtils.isMain()`
Check if on main branch.
```groovy
if (gitUtils.isMain()) {
echo "On main branch - deploying!"
}
```
### `gitUtils.getRemoteURL()`
Get Git remote URL.
```groovy
def url = gitUtils.getRemoteURL()
// Returns: git@git.kodeniks.com:CORP/my-repo.git
```
---
## giteaUtils
Update Gitea commit status.
**Default Configuration:**
- Gitea URL: `https://git.kodeniks.com`
- Credentials: `kodeniks-gitea-token`
### `giteaUtils.setSuccess(context)`
Set commit status to success.
```groovy
giteaUtils.setSuccess("build")
giteaUtils.setSuccess("deploy")
```
### `giteaUtils.setPending(context)`
Set commit status to pending.
```groovy
giteaUtils.setPending("build")
```
### `giteaUtils.setFailed(context)`
Set commit status to failed.
```groovy
giteaUtils.setFailed("build")
```
### `giteaUtils.setError(context)`
Set commit status to error.
```groovy
giteaUtils.setError("build")
```
### `giteaUtils.updateStatus(Map args)`
Update commit status with full control.
```groovy
giteaUtils.updateStatus(
context: "build",
state: "success", // success, pending, failure, error
message: "Build passed",
giteaUrl: "https://git.kodeniks.com", // optional override
credentialsId: "kodeniks-gitea-token" // optional override
)
```
---
## awsUtils
AWS CLI operations.
### `awsUtils.assumeRole(Map args)`
Assume an IAM role.
```groovy
awsUtils.assumeRole(
roleArn: "arn:aws:iam::123456789012:role/MyRole",
sessionName: "jenkins-deploy"
)
```
### `awsUtils.getCallerIdentity(Map args)`
Get current AWS identity.
```groovy
def identity = awsUtils.getCallerIdentity(
jenkinsAwsCredentialsId: "aws-credentials",
region: "ca-central-1"
)
```
### `awsUtils.describeStacks(Map args)`
Describe CloudFormation stacks.
```groovy
def stacks = awsUtils.describeStacks(
jenkinsAwsCredentialsId: "aws-credentials",
region: "ca-central-1",
stackName: "my-stack"
)
```
---
## spicyUtils
Pipeline utility functions.
### `spicyUtils.stageWithFailure(name, closure)`
Run a stage that sets Gitea status on failure.
```groovy
spicyUtils.stageWithFailure("Build") {
sh "npm run build"
}
```
### `spicyUtils.withRetry(Map args, closure)`
Retry a block with exponential backoff.
```groovy
spicyUtils.withRetry(maxAttempts: 3, delay: 5) {
sh "flaky-command"
}
```
---
## accounts
Multi-account configuration management.
### `accounts.get()`
Get all account configurations.
```groovy
def allAccounts = accounts.get()
def prodAccount = allAccounts.SPICY_CA_CENRAL_1_PROD
def devAccount = allAccounts.SPICY_CA_CENRAL_1_DEV
```
### Account Object Properties
| Property | Description |
| ------------------------- | ------------------------------ |
| `accountId` | AWS Account ID |
| `region` | AWS Region |
| `jenkinsAwsCredentialsId` | Jenkins credential ID |
| `vpcStackName` | Default VPC stack name |
| `ecsClusterStackName` | Default ECS cluster stack name |
### Available Accounts
| Key | Description |
| --------------------------- | ----------------------- |
| `SPICY_CA_CENRAL_1` | Base account |
| `SPICY_CA_CENRAL_1_DEV` | Development environment |
| `SPICY_CA_CENRAL_1_SANDBOX` | Sandbox environment |
| `SPICY_CA_CENRAL_1_QA` | QA environment |
| `SPICY_CA_CENRAL_1_STAGING` | Staging environment |
| `SPICY_CA_CENRAL_1_PROD` | Production environment |
---
## manualApproval
Manual approval gates for pipelines.
### `manualApproval(Map args)`
Request manual approval before proceeding.
```groovy
manualApproval(
message: "Deploy to production?",
submitter: "admin,deployers", // optional: allowed approvers
timeout: 24, // optional: hours to wait
timeoutUnit: "HOURS" // optional: HOURS, MINUTES, DAYS
)
```
---
## spicyDefaults
Apply default pipeline configuration.
### `spicyDefaults(Map args)`
Merge defaults into pipeline arguments.
```groovy
def call(Map args) {
args = spicyDefaults(args)
// args now has default pipelineProperties, etc.
}
```
Default properties applied:
- Build discarder (keep 10 builds)
- Disable concurrent builds
- GitHub project URL (if applicable)

208
docs/PERSISTENT_ALB.md Normal file
View File

@@ -0,0 +1,208 @@
# Persistent ALB Pattern (bg-common)
The persistent ALB pattern ensures that ALB DNS names remain constant even when service stacks are deleted and recreated. This matches the legacy `bg-common` pattern.
## How It Works
The bg-common ALB stack is **always created separately**. Services reference it via `albLoadBalancerArn`:
1. **bg-common ALB stack is deployed separately** (`{serviceStackName}-alb` or via `spicyALB`)
2. **The ALB stack persists** across service deployments
3. **Service stacks reference the existing ALB** via `albLoadBalancerArn` (maps to `existingALB` in construct)
4. **ALB DNS name stays constant** even when service stacks are deleted/recreated
### Stack Naming
- **ALB Stack**: `{serviceStackName}-alb` (shared between blue/green)
- **Service Stack (Rolling)**: `{serviceStackName}`
- **Service Stack (Blue/Green)**: `{serviceStackName}-blue` and `{serviceStackName}-green`
For blue/green deployments, both blue and green services share the same ALB stack.
## Usage
### Rolling Deployment (Non-Blue/Green)
```groovy
@Library(["spicy-automation@main"]) _
spicyECSService(
jenkinsAwsCredentialsId: "aws-credentials",
region: "ca-central-1",
stackName: "my-api-dev",
serviceName: "my-api",
// ... cluster/VPC config ...
// Container
image: "nexus.kodeniks.com/docker-hosted/my-api:latest",
containerPort: 3000,
// Reference bg-common ALB (always created separately) - auto-imports from ALB stack
albStackName: "my-api-dev-alb", // Auto-imports ALB ARN and listener ARNs
// Routing
hostHeader: "api-dev.example.com",
priority: 100,
// Tags
ownerTag: "MyTeam",
productTag: "my-product",
componentTag: "api",
environment: "dev",
)
```
**What happens:**
1. Pipeline deploys `my-api-dev-alb` stack (ALB)
2. Pipeline gets ALB outputs (DNS, listener ARNs)
3. Pipeline deploys `my-api-dev` stack (service) referencing the ALB
4. ALB DNS name stays constant: `my-api-dev-alb-123456789.ca-central-1.elb.amazonaws.com`
### Blue/Green Deployment
```groovy
@Library(["spicy-automation@main"]) _
spicyECSService(
jenkinsAwsCredentialsId: "aws-credentials",
region: "ca-central-1",
stackName: "my-api-prod", // Base name (blue/green suffixes added automatically)
serviceName: "my-api",
// ... cluster/VPC config ...
// Container
image: "nexus.kodeniks.com/docker-hosted/my-api:latest",
containerPort: 3000,
// Blue/Green
blueGreen: true,
activeHostname: "api.example.com",
inactiveHostname: "inactive-api.example.com",
bgHostedZoneId: "Z1234567890", // Route53 hosted zone
// Reference bg-common ALB (always created separately) - auto-imports from ALB stack
albStackName: "my-api-prod-alb", // Auto-imports ALB ARN and listener ARNs
healthCheckPath: "/health",
// Tags
ownerTag: "MyTeam",
productTag: "my-product",
componentTag: "api",
environment: "prod",
)
```
**What happens:**
1. Pipeline deploys `my-api-prod-alb` stack (ALB) - **only once, shared by blue/green**
2. Pipeline gets ALB outputs (or uses `albStackName` for auto-import)
3. Pipeline deploys `my-api-prod-green` stack (inactive service) referencing the ALB
4. Service creates listener rules on **both** HTTP and HTTPS listeners (if both available)
5. Pipeline swaps hostname routing via ALB listener rule priorities (no DNS changes)
6. ALB DNS name stays constant across all deployments
**How Blue/Green Routing Works:**
- **DNS Records**: Both `api.example.com` and `inactive-api.example.com` DNS records are created and always point to the same ALB
- **Traffic Routing**: Controlled by ALB listener rule priorities, not DNS:
- Active service: Listener rule with priority 100 for `api.example.com`
- Inactive service: Listener rule with priority 200 for `inactive-api.example.com`
- **Swapping**: When swapping, only the listener rule priorities are updated (via CDK deployment). DNS records never change.
- **Result**: Zero-downtime swaps in ~30 seconds (just ALB rule updates, no DNS propagation delays)
## Benefits
1. **Static DNS**: ALB DNS name never changes (unless ALB stack is deleted)
2. **No DNS Updates**: Cloudflare/Route53 records set once and never need updating
3. **Faster Deployments**: ALB stack only updates when ALB config changes
4. **Cost Efficient**: ALB persists, avoiding recreation costs
## ALB Stack Lifecycle
- **Created**: Deployed separately via `spicyALB` pipeline function or manually
- **Updated**: When ALB configuration changes (certificate, scheme, etc.)
- **Deleted**: Only when explicitly destroyed (not deleted with service stacks)
## Stack Outputs
The ALB stack exports these outputs (used by service stacks via CloudFormation imports):
| Output Key | Description | Export Name |
| ------------------ | ------------------ | -------------------------------------------- |
| `LoadBalancerDNS` | ALB DNS name | `{stackName}-internet-facing-url` |
| `LoadBalancerArn` | ALB ARN | `{stackName}-internet-facing-arn` |
| `HTTPListenerArn` | HTTP listener ARN | `{stackName}-internet-facing-http-listener` |
| `HTTPSListenerArn` | HTTPS listener ARN | `{stackName}-internet-facing-https-listener` |
**Minimal Props:** When using `albStackName` in service stack, these are auto-imported via CloudFormation `Fn::ImportValue`. The service creates listener rules on **both** HTTP and HTTPS listeners (if both are available), matching the old template behavior.
| `SecurityGroupId` | ALB security group ID | `{stackName}-internet-facing-security-group` |
## Deploying the bg-common ALB Stack
The bg-common ALB stack must be deployed separately before deploying services:
```groovy
@Library(["spicy-automation@main"]) _
// Deploy bg-common ALB stack
spicyALB(
jenkinsAwsCredentialsId: "aws-credentials",
region: "ca-central-1",
stackName: "my-api-dev-alb",
vpcId: "vpc-12345678",
availabilityZones: "ca-central-1a,ca-central-1b",
subnetIds: "subnet-xxx,subnet-yyy",
scheme: "internet-facing",
certificateArn: "arn:aws:acm:...",
ownerTag: "MyTeam",
productTag: "my-product",
componentTag: "alb",
environment: "dev",
)
// Then deploy service referencing the ALB
spicyECSService(
// ... config ...
albLoadBalancerArn: "arn:aws:elasticloadbalancing:...", // From ALB stack outputs
albHttpsListenerArn: "arn:aws:elasticloadbalancing:...", // From ALB stack outputs
)
```
## Comparison with Legacy bg-common
| Feature | Legacy bg-common | New Persistent ALB |
| ------------------- | ---------------------- | ------------------------------------------ |
| **Stack Name** | `{envName}-bg-common` | `{serviceStackName}-alb` |
| **Deployment** | Separate pipeline step | Separate pipeline step (via `spicyALB`) |
| **Blue/Green** | Shared ALB | Shared ALB |
| **DNS Management** | Manual | Automatic (Route53) or Manual (Cloudflare) |
| **Stack Lifecycle** | Manual | Manual (deployed separately) |
## Troubleshooting
### ALB Stack Not Found
If the ALB stack doesn't exist, the pipeline will create it automatically. If you see errors:
1. Check the ALB stack name: `{serviceStackName}-alb`
2. Verify the stack exists: `aws cloudformation describe-stacks --stack-name {stackName}-alb`
3. Check stack outputs: `aws cloudformation describe-stacks --stack-name {stackName}-alb --query 'Stacks[0].Outputs'`
### ALB DNS Name Changed
This should never happen with the persistent ALB pattern. If it does:
1. Check if the ALB stack was deleted/recreated
2. Verify the ALB stack name is consistent
3. Check CloudFormation stack events for ALB replacement
### Service Can't Find ALB
If the service stack can't reference the ALB:
1. Verify ALB stack outputs are available
2. Check that `albLoadBalancerArn` is set in the service context
3. Verify the ALB ARN is correct

317
docs/VPC.md Normal file
View File

@@ -0,0 +1,317 @@
# VPC Deployment Guide
Deploy production-ready VPCs with `spicyVPC`.
## Minimal Setup
```groovy
@Library(["spicy-automation@main"]) _
spicyVPC(
jenkinsAwsCredentialsId: "aws-credentials",
region: "ca-central-1",
stackName: "my-vpc",
ownerTag: "MyTeam",
productTag: "myproduct",
componentTag: "vpc",
)
```
This creates a VPC with all defaults - see [What Gets Created](#what-gets-created).
## Full Configuration
```groovy
@Library(["spicy-automation@main"]) _
spicyVPC(
// Required
jenkinsAwsCredentialsId: "aws-credentials",
region: "ca-central-1",
stackName: "production-vpc",
ownerTag: "Platform",
productTag: "myapp",
componentTag: "network",
// Optional - VPC Configuration
vpcCidr: "10.0.0.0/16", // Default: 172.1.0.0/16
vpcTenancy: "default", // default | dedicated
numberOfAzs: 4, // 2, 3, or 4
availabilityZones: "ca-central-1a,ca-central-1b,ca-central-1c,ca-central-1d",
// Optional - Subnet Creation
createPrivateSubnets: true, // Default: true
createAdditionalPrivateSubnets: true, // Default: true (creates NACL-protected subnets)
// Optional - Subnet Tags
publicSubnetTag: "Network=Public",
privateSubnetATag: "Network=Private",
privateSubnetBTag: "Network=Private",
// Optional - Custom CIDRs (see Custom CIDR Configuration below)
// Optional - Pipeline Behavior
showDiff: true, // Show diff before deploy (default: true)
showDiffOnPR: true, // Show diff on non-main branches (default: true)
// Optional - Hooks
onPreDeploy: { args, ctx -> }, // Called before deployment
onPostDeploy: { args, ctx -> }, // Called after deployment
)
```
## Parameters Reference
### Required Parameters
| Parameter | Type | Description |
| ------------------------- | ------ | ------------------------------------------------------------- |
| `jenkinsAwsCredentialsId` | string | Jenkins credential ID for AWS access |
| `region` | string | AWS region (e.g., `ca-central-1`) |
| `stackName` | string | CloudFormation stack name (must be unique per account/region) |
| `ownerTag` | string | Owner tag applied to all resources |
| `productTag` | string | Product tag applied to all resources |
| `componentTag` | string | Component tag applied to all resources |
### VPC Configuration
| Parameter | Type | Default | Description |
| ------------------- | ------ | -------------- | ------------------------------------------- |
| `vpcCidr` | string | `172.1.0.0/16` | VPC CIDR block |
| `vpcTenancy` | string | `default` | Instance tenancy (`default` or `dedicated`) |
| `numberOfAzs` | number | `4` | Number of availability zones (2, 3, or 4) |
| `availabilityZones` | string | auto | Comma-separated AZ list |
### Subnet Configuration
| Parameter | Type | Default | Description |
| -------------------------------- | ------- | ------- | ------------------------------------------- |
| `createPrivateSubnets` | boolean | `true` | Create private subnets with NAT Gateways |
| `createAdditionalPrivateSubnets` | boolean | `true` | Create secondary private subnets with NACLs |
### Subnet Tags
| Parameter | Type | Default | Description |
| ------------------- | ------ | ----------------- | --------------------------------- |
| `publicSubnetTag` | string | `Network=Public` | Tag for public subnets |
| `privateSubnetATag` | string | `Network=Private` | Tag for primary private subnets |
| `privateSubnetBTag` | string | `Network=Private` | Tag for secondary private subnets |
## What Gets Created
With default settings, `spicyVPC` creates:
### Network Resources
- **1 VPC** with DNS hostnames and DNS support enabled
- **1 Internet Gateway** attached to the VPC
- **DHCP Options** configured for the region
### Public Subnets (4 AZs)
- 4 public subnets (one per AZ)
- 1 shared public route table with route to Internet Gateway
- Auto-assign public IP enabled
### Private Subnets Type 1 (4 AZs)
- 4 private subnets (one per AZ)
- 4 NAT Gateways (one per AZ) with Elastic IPs
- 4 route tables with routes to NAT Gateways
### Private Subnets Type 2 (4 AZs)
- 4 additional private subnets (one per AZ)
- 4 Network ACLs (allow all by default, customizable)
- 4 route tables with routes to NAT Gateways
### VPC Endpoints
- S3 Gateway Endpoint (free, attached to all private route tables)
## Default CIDR Blocks
| Subnet | AZ A | AZ B | AZ C | AZ D |
| --------- | -------------- | -------------- | -------------- | -------------- |
| Public | 172.1.128.0/20 | 172.1.144.0/20 | 172.1.160.0/20 | 172.1.176.0/20 |
| Private 1 | 172.1.0.0/19 | 172.1.32.0/19 | 172.1.64.0/19 | 172.1.96.0/19 |
| Private 2 | 172.1.192.0/21 | 172.1.200.0/21 | 172.1.208.0/21 | 172.1.216.0/21 |
## Custom CIDR Configuration
Override individual subnet CIDRs:
```groovy
spicyVPC(
// ... required params ...
// Public subnets
publicSubnetACidr: "10.0.0.0/20",
publicSubnetBCidr: "10.0.16.0/20",
publicSubnetCCidr: "10.0.32.0/20",
publicSubnetDCidr: "10.0.48.0/20",
// Private subnets (type 1)
privateSubnetA1Cidr: "10.0.64.0/19",
privateSubnetB1Cidr: "10.0.96.0/19",
privateSubnetC1Cidr: "10.0.128.0/19",
privateSubnetD1Cidr: "10.0.160.0/19",
// Private subnets (type 2, with NACLs)
privateSubnetA2Cidr: "10.0.192.0/21",
privateSubnetB2Cidr: "10.0.200.0/21",
privateSubnetC2Cidr: "10.0.208.0/21",
privateSubnetD2Cidr: "10.0.216.0/21",
)
```
## CloudFormation Outputs
The stack exports these values for cross-stack references:
| Export Name | Description |
| --------------------------------------- | ------------------------- |
| `{stackName}-VPCID` | VPC ID |
| `{stackName}-VPCCIDR` | VPC CIDR block |
| `{stackName}-PublicSubnetAID` | Public Subnet A ID |
| `{stackName}-PublicSubnetBID` | Public Subnet B ID |
| `{stackName}-PublicSubnetCID` | Public Subnet C ID |
| `{stackName}-PublicSubnetDID` | Public Subnet D ID |
| `{stackName}-PrivateSubnetA1ID` | Private Subnet A1 ID |
| `{stackName}-PrivateSubnetB1ID` | Private Subnet B1 ID |
| `{stackName}-PrivateSubnetC1ID` | Private Subnet C1 ID |
| `{stackName}-PrivateSubnetD1ID` | Private Subnet D1 ID |
| `{stackName}-PrivateSubnetA2ID` | Private Subnet A2 ID |
| `{stackName}-PrivateSubnetB2ID` | Private Subnet B2 ID |
| `{stackName}-PrivateSubnetC2ID` | Private Subnet C2 ID |
| `{stackName}-PrivateSubnetD2ID` | Private Subnet D2 ID |
| `{stackName}-NATZoneAEIP` | NAT Gateway A Elastic IP |
| `{stackName}-NATZoneBEIP` | NAT Gateway B Elastic IP |
| `{stackName}-NATZoneCEIP` | NAT Gateway C Elastic IP |
| `{stackName}-NATZoneDEIP` | NAT Gateway D Elastic IP |
| `{stackName}-PublicSubnetRouteTable` | Public Route Table ID |
| `{stackName}-PrivateSubnetA1RouteTable` | Private A1 Route Table ID |
| `{stackName}-PrivateSubnetB1RouteTable` | Private B1 Route Table ID |
| `{stackName}-PrivateSubnetC1RouteTable` | Private C1 Route Table ID |
| `{stackName}-PrivateSubnetD1RouteTable` | Private D1 Route Table ID |
## Using Account Configurations
For multi-environment deployments:
```groovy
@Library(["spicy-automation@main"]) _
def account = accounts.get().SPICY_CA_CENRAL_1_PROD
spicyVPC(
jenkinsAwsCredentialsId: account.jenkinsAwsCredentialsId,
region: account.region,
accountId: account.accountId,
stackName: "production-vpc",
ownerTag: "Platform",
productTag: "spicy",
componentTag: "vpc",
)
```
## Pipeline Hooks
### Pre-Deploy Hook
```groovy
spicyVPC(
// ... params ...
onPreDeploy: { args, ctx ->
echo "About to deploy ${args.stackName}"
// Run custom validation, notifications, etc.
}
)
```
### Post-Deploy Hook
```groovy
spicyVPC(
// ... params ...
onPostDeploy: { args, ctx ->
echo "Deployed stack: ${ctx.stackName}"
// Update DNS, notify Slack, etc.
}
)
```
## Examples
### Development VPC (2 AZs, No NACL Subnets)
```groovy
spicyVPC(
jenkinsAwsCredentialsId: "aws-dev",
region: "ca-central-1",
stackName: "dev-vpc",
ownerTag: "Dev",
productTag: "myapp",
componentTag: "vpc",
numberOfAzs: 2,
createAdditionalPrivateSubnets: false,
)
```
### Production VPC (Full HA)
```groovy
spicyVPC(
jenkinsAwsCredentialsId: "aws-prod",
region: "ca-central-1",
stackName: "prod-vpc",
ownerTag: "Platform",
productTag: "myapp",
componentTag: "vpc",
vpcCidr: "10.0.0.0/16",
numberOfAzs: 4,
createPrivateSubnets: true,
createAdditionalPrivateSubnets: true,
)
```
### Public-Only VPC (No NAT Gateways)
```groovy
spicyVPC(
jenkinsAwsCredentialsId: "aws-credentials",
region: "ca-central-1",
stackName: "public-vpc",
ownerTag: "Dev",
productTag: "myapp",
componentTag: "vpc",
createPrivateSubnets: false,
)
```
## Local Testing
Test VPC synthesis locally:
```bash
cd spicy-automation
# Synthesize with minimal config
npx cdk synth \
-c stackType=vpc \
-c stackName=test-vpc \
-c ownerTag=Test \
-c productTag=test \
-c componentTag=vpc
# With custom options
npx cdk synth \
-c stackType=vpc \
-c stackName=test-vpc \
-c ownerTag=Test \
-c productTag=test \
-c componentTag=vpc \
-c vpcCidr=10.0.0.0/16 \
-c numberOfAzs=2 \
-c createAdditionalPrivateSubnets=false
```

30
resources/.dockerignore Normal file
View File

@@ -0,0 +1,30 @@
# Dependencies
node_modules/
# Build output
cdk.out/
*.js
*.d.ts
!jest.config.js
# IDE
.idea/
.vscode/
*.swp
*.swo
# OS
.DS_Store
Thumbs.db
# Test
coverage/
# Git
.git/
.gitignore
# Misc
*.log
*.tmp

6
resources/.npmignore Normal file
View File

@@ -0,0 +1,6 @@
*.ts
!*.d.ts
# CDK asset staging directory
.cdk.staging
cdk.out

42
resources/Dockerfile Normal file
View File

@@ -0,0 +1,42 @@
# Spicy CDK Docker Image for Jenkins Runners
# This image contains everything needed to deploy CDK stacks from Jenkins pipelines
FROM node:24-alpine
# Update package index and upgrade all system packages to patch vulnerabilities
RUN apk update && apk upgrade
# Install AWS CLI and other dependencies
RUN apk add \
python3 \
py3-pip \
git \
bash \
curl \
jq \
&& pip3 install --break-system-packages awscli
# Install pnpm globally
RUN corepack enable && corepack prepare pnpm@latest --activate
# Install AWS CDK CLI globally
RUN npm install -g aws-cdk
# Set working directory
WORKDIR /app
# Copy package files first for better layer caching
COPY package.json pnpm-lock.yaml ./
# Install dependencies
RUN pnpm install --frozen-lockfile
# Copy the rest of the application
COPY . .
# Verify CDK is working
RUN cdk --version
# Default command shows help
CMD ["cdk", "--help"]

279
resources/README.md Normal file
View File

@@ -0,0 +1,279 @@
# Spicy Automation
AWS CDK infrastructure and Jenkins shared library for Spicy automation. This replaces the legacy Ansible/CloudFormation approach with type-safe TypeScript and integrated Jenkins pipelines.
## Quick Start
### Minimal VPC Deployment
Create a `Jenkinsfile` in your VPC repository:
```groovy
@Library(["spicy-automation@main"]) _
spicyVPC(
jenkinsAwsCredentialsId: "aws-credentials",
region: "ca-central-1",
stackName: "my-vpc",
ownerTag: "MyTeam",
productTag: "myproduct",
componentTag: "vpc",
)
```
### Minimal ECS Cluster Deployment - Using CloudFormation Imports
**Minimal props:** Only `vpcStackName` and `numberOfAzs` required. VPC info auto-imports from VPC stack exports.
```groovy
@Library(["spicy-automation@main"]) _
spicyECSCluster(
jenkinsAwsCredentialsId: "aws-credentials",
region: "ca-central-1",
stackName: "my-ecs-cluster",
vpcStackName: "my-vpc", // Auto-imports VPC ID, CIDR, subnets, and AZs
numberOfAzs: 3,
ownerTag: "MyTeam",
productTag: "myproduct",
componentTag: "ecs-cluster",
environment: "dev"
)
```
**Explicit IDs (backward compatible):**
```groovy
spicyECSCluster(
jenkinsAwsCredentialsId: "aws-credentials",
region: "ca-central-1",
stackName: "my-ecs-cluster",
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",
ownerTag: "MyTeam",
productTag: "myproduct",
componentTag: "ecs-cluster",
environment: "dev"
)
```
### ECS Service with Mixed Capacity (EC2 + Fargate Burst) - Minimal Props
**Minimal props:** Only `clusterName` required. VPC info auto-imports from CloudFormation exports.
```groovy
@Library(["spicy-automation@main"]) _
spicyECSService(
jenkinsAwsCredentialsId: "aws-credentials",
region: "ca-central-1",
stackName: "my-api-dev",
serviceName: "my-api",
clusterName: "my-ecs-cluster", // VPC stack name and VPC ID auto-import from cluster stack exports
image: "nexus.kodeniks.com/docker-hosted/my-api:latest",
containerPort: 3000,
capacityProviderStrategy: [
[capacityProvider: "my-ecs-cluster-ec2", base: 2, weight: 3],
[capacityProvider: "FARGATE_SPOT", weight: 1],
],
ownerTag: "MyTeam",
productTag: "myproduct",
componentTag: "api",
environment: "dev"
)
```
**Explicit IDs (backward compatible):**
```groovy
spicyECSService(
// ... other params ...
clusterName: "my-ecs-cluster",
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",
)
```
### Blue/Green Deployment with Instant Rollback
```groovy
@Library(["spicy-automation@main"]) _
spicyECSService(
jenkinsAwsCredentialsId: "aws-credentials",
region: "ca-central-1",
stackName: "my-api-prod",
serviceName: "my-api",
clusterName: "my-ecs-cluster-prod",
vpcId: "vpc-12345678",
vpcCidrBlock: "10.0.0.0/16",
availabilityZones: "ca-central-1a,ca-central-1b",
privateSubnetIds: "subnet-aaa,subnet-bbb",
image: "nexus.kodeniks.com/docker-hosted/my-api:latest",
containerPort: 3000,
// Enable Blue/Green
blueGreen: true,
activeHostname: "api.example.com",
inactiveHostname: "inactive-api.example.com",
// ALB routing - listener ARN auto-imports from ${clusterName}-internet-facing-https-listener
priority: 100,
useExternalALB: true,
// externalListenerArn auto-imports when useExternalALB=true
// Test before swap
blueGreenTest: { args, buildInfo ->
sh "curl -f https://${buildInfo.inactiveHostname}/health"
},
// Test after swap
smokeTest: { args, buildInfo ->
sh "curl -f https://${buildInfo.activeHostname}/health"
},
ownerTag: "Platform",
productTag: "myproduct",
componentTag: "api",
environment: "prod"
)
```
### Instant Rollback (30 seconds)
```groovy
@Library(["spicy-automation@main"]) _
spicyRollback(
jenkinsAwsCredentialsId: "aws-credentials",
region: "ca-central-1",
stackName: "my-api-prod",
serviceName: "my-api",
// ... same params as deploy ...
)
```
## Documentation
| Document | Description |
| ------------------------------------------------------ | ---------------------------------------------- |
| [VPC Guide](docs/VPC.md) | Complete VPC deployment guide with all options |
| [ECS Cluster Guide](docs/ECS_CLUSTER.md) | ECS cluster with Spot, Capacity Providers |
| [ECS Service Guide](docs/ECS_SERVICE.md) | ECS service with mixed EC2/Fargate strategy |
| [Persistent ALB](docs/PERSISTENT_ALB.md) | Persistent ALB pattern (1:1 with service) |
| [Cost Optimization](docs/COST_OPTIMIZATION_TESTING.md) | Cost optimization for testing environments |
| [CDK Synth Examples](docs/CDK_SYNTH_EXAMPLES.md) | Complete CDK synth command examples |
| [Jenkins Utilities](docs/JENKINS_UTILITIES.md) | All available Jenkins utility functions |
| [Accounts Configuration](docs/ACCOUNTS.md) | Multi-account setup guide |
| [Local Development](docs/DEVELOPMENT.md) | Running CDK locally |
| [NPM Example](docs/JENKINSFILE_EXAMPLE_NPM.md) | Example Jenkinsfile for NPM publishing |
## Features
### Infrastructure (CDK)
- **SpicyVpc**: Multi-AZ VPC with public/private subnets, NAT gateways, NACLs, VPC endpoints
- **SpicyEcsCluster**: ECS cluster with EC2 Capacity Providers, Mixed Instances Policy for Spot, ALBs
- **SpicyEcsService**: ECS service with mixed capacity strategy, auto-scaling, circuit breaker
### Blue/Green Deployments
- **Zero-downtime releases** with hostname-based routing
- **Instant rollback** (~30 seconds) via hostname swap
- **Test before swap** with `blueGreenTest` hook
- **Rollback window** - old version kept for 2+ hours
### Pipeline Hooks
- **buildCommand**: Custom build logic
- **onPostBuild**: Unit tests, linting, coverage
- **onPreDeploy**: Setup, test preparation
- **blueGreenTest**: Integration tests on inactive stack
- **onPostDeploy**: Cleanup, notifications
- **smokeTest**: Post-deployment verification
### Jenkins Pipelines
- **spicyVPC**: Deploy VPCs
- **spicyECSCluster**: Deploy ECS clusters with Spot support
- **spicyECSService**: Deploy ECS services with EC2 + Fargate burst, blue/green
- **spicyRollback**: Instant rollback for blue/green deployments
- **buildAndPushDockerImage**: Build and push Docker images to Nexus
## Jenkins Setup
### 1. Configure Global Library
In Jenkins → **Manage Jenkins****Configure System****Global Pipeline Libraries**:
| Setting | Value |
| ------------------ | ------------------------------------------------ |
| Name | `spicy-automation` |
| Default version | `main` |
| Retrieval method | Modern SCM → Git |
| Project Repository | `git@git.kodeniks.com:CORP/spicy-automation.git` |
| Library Path | `jenkins/` |
### 2. Configure Credentials
| Credential ID | Type | Description |
| --------------------------- | ----------------- | --------------------------------- |
| `aws-credentials` | Username/Password | AWS Access Key ID / Secret |
| `kodeniks-gitea-token` | Secret text | Gitea personal access token |
| `kodeniks-nexus-repository` | Username/Password | Nexus Docker registry credentials |
## Project Structure
```
spicy-automation/
├── bin/spicy-cdk.ts # CDK app entry point
├── lib/
│ ├── constructs/ # Reusable CDK constructs
│ │ ├── spicy-vpc.ts # VPC construct
│ │ ├── spicy-ecs-cluster.ts # ECS cluster construct
│ │ └── spicy-ecs-service.ts # ECS service construct
│ └── stacks/ # CDK stacks
│ ├── spicy-vpc-stack.ts
│ ├── spicy-ecs-cluster-stack.ts
│ └── spicy-ecs-service-stack.ts
├── vars/ # Jenkins shared library
│ ├── spicyVPC.groovy # VPC pipeline
│ ├── spicyECSCluster.groovy # ECS cluster pipeline
│ ├── spicyECSService.groovy # ECS service pipeline
│ ├── buildAndPushDockerImage.groovy # Docker build pipeline
│ ├── cdkUtils.groovy # CDK commands
│ ├── dockerUtils.groovy # Docker/Nexus utilities
│ ├── gitUtils.groovy # Git utilities
│ ├── giteaUtils.groovy # Commit status updates
│ └── accounts.groovy # Account configurations
├── docs/ # Documentation
├── test/ # Jest tests
└── Dockerfile # Jenkins agent image
```
## Useful Commands
```bash
# No build step required - runs directly with ts-node!
npx cdk synth -c stackType=vpc -c stackName=my-vpc ...
# Or use pnpm scripts
pnpm run test # Run Jest tests
pnpm run build # Compile TypeScript (optional)
```
## Migration from spicy-automation-legacy
| Feature | spicy-automation-legacy | spicy-automation |
| --------------- | ----------------------- | ---------------- |
| Infrastructure | CloudFormation YAML | TypeScript CDK |
| Provisioning | Ansible | CDK CLI |
| Testing | None | Jest |
| Type Safety | None | Full TypeScript |
| Docker Registry | AWS ECR | Nexus |
CloudFormation outputs are identical, so existing stacks referencing VPC exports continue to work.

View File

@@ -0,0 +1,61 @@
#!/usr/bin/env node
import * as cdk from 'aws-cdk-lib/core';
import { SpicyAlbStack, SpicyEcsClusterStack, SpicyEcsServiceStack, SpicyVpcStack } from '../lib/stacks';
const app = new cdk.App();
// Get the stack type from context (default to vpc for backwards compatibility)
const stackType = app.node.tryGetContext('stackType');
const stackName = app.node.tryGetContext('stackName');
// Environment configuration from context or environment variables
const env: cdk.Environment = {
account: app.node.tryGetContext('account') ?? process.env.CDK_DEFAULT_ACCOUNT,
region: app.node.tryGetContext('region') ?? process.env.CDK_DEFAULT_REGION,
};
// Create stacks based on stackType
// If no stackType is provided, create an empty stack to satisfy CDK's requirement (e.g., for bootstrap)
if (stackType) {
switch (stackType) {
case 'vpc':
// Create VPC stack from context
// Usage: npx cdk deploy -c stackType=vpc -c stackName=spicy-vpc -c ownerTag=SpicyTeam ...
SpicyVpcStack.fromContext(app, stackName ?? 'SpicyVpcStack', { env });
break;
case 'ecs-cluster':
// Create ECS Cluster stack from context
// Usage: npx cdk deploy -c stackType=ecs-cluster -c stackName=spicy-ecs -c vpcId=vpc-xxx ...
SpicyEcsClusterStack.fromContext(app, stackName ?? 'SpicyEcsClusterStack', { env });
break;
case 'ecs-service':
// Create ECS Service stack from context
// Usage: npx cdk deploy -c stackType=ecs-service -c stackName=my-service -c clusterName=spicy-ecs ...
SpicyEcsServiceStack.fromContext(app, stackName ?? 'SpicyEcsServiceStack', { env });
break;
case 'alb':
// Create persistent ALB stack from context
// Usage: npx cdk deploy -c stackType=alb -c stackName=my-alb -c vpcId=vpc-xxx ...
SpicyAlbStack.fromContext(app, stackName ?? 'SpicyAlbStack', { env });
break;
default:
console.error(`Unknown stack type: ${stackType}`);
console.error('Valid stack types: vpc, ecs-cluster, ecs-service, alb');
process.exit(1);
}
} else {
// Create an empty placeholder stack when no stackType is provided
// This allows bootstrap and other CDK commands to work without requiring a stack type
// This stack is never deployed - it's only used to satisfy CDK's requirement for at least one stack
new cdk.Stack(app, 'PlaceholderStack', {
stackName: 'spicy-cdk-placeholder',
env,
});
}
app.synth();

View File

@@ -0,0 +1,6 @@
{
"cli-telemetry": false,
"acknowledged-issue-numbers": [
34892
]
}

98
resources/cdk.json Normal file
View File

@@ -0,0 +1,98 @@
{
"app": "npx ts-node --prefer-ts-exts bin/spicy-cdk.ts",
"watch": {
"include": ["**"],
"exclude": [
"README.md",
"cdk*.json",
"**/*.d.ts",
"**/*.js",
"tsconfig.json",
"package*.json",
"yarn.lock",
"node_modules",
"test"
]
},
"context": {
"@aws-cdk/core:bootstrapQualifier": "hnb659fds",
"@aws-cdk/core:disableBootstrapVersionCheck": true,
"@aws-cdk/aws-signer:signingProfileNamePassedToCfn": true,
"@aws-cdk/aws-ecs-patterns:secGroupsDisablesImplicitOpenListener": true,
"@aws-cdk/aws-lambda:recognizeLayerVersion": true,
"@aws-cdk/core:checkSecretUsage": true,
"@aws-cdk/core:target-partitions": ["aws", "aws-cn"],
"@aws-cdk-containers/ecs-service-extensions:enableDefaultLogDriver": true,
"@aws-cdk/aws-ec2:uniqueImdsv2TemplateName": true,
"@aws-cdk/aws-ecs:arnFormatIncludesClusterName": true,
"@aws-cdk/aws-iam:minimizePolicies": true,
"@aws-cdk/core:validateSnapshotRemovalPolicy": true,
"@aws-cdk/aws-codepipeline:crossAccountKeyAliasStackSafeResourceName": true,
"@aws-cdk/aws-s3:createDefaultLoggingPolicy": true,
"@aws-cdk/aws-sns-subscriptions:restrictSqsDescryption": true,
"@aws-cdk/aws-apigateway:disableCloudWatchRole": true,
"@aws-cdk/core:enablePartitionLiterals": true,
"@aws-cdk/aws-events:eventsTargetQueueSameAccount": true,
"@aws-cdk/aws-ecs:disableExplicitDeploymentControllerForCircuitBreaker": true,
"@aws-cdk/aws-iam:importedRoleStackSafeDefaultPolicyName": true,
"@aws-cdk/aws-s3:serverAccessLogsUseBucketPolicy": true,
"@aws-cdk/aws-route53-patters:useCertificate": true,
"@aws-cdk/customresources:installLatestAwsSdkDefault": false,
"@aws-cdk/aws-rds:databaseProxyUniqueResourceName": true,
"@aws-cdk/aws-codedeploy:removeAlarmsFromDeploymentGroup": true,
"@aws-cdk/aws-apigateway:authorizerChangeDeploymentLogicalId": true,
"@aws-cdk/aws-ec2:launchTemplateDefaultUserData": true,
"@aws-cdk/aws-secretsmanager:useAttachedSecretResourcePolicyForSecretTargetAttachments": true,
"@aws-cdk/aws-redshift:columnId": true,
"@aws-cdk/aws-stepfunctions-tasks:enableEmrServicePolicyV2": true,
"@aws-cdk/aws-ec2:restrictDefaultSecurityGroup": true,
"@aws-cdk/aws-apigateway:requestValidatorUniqueId": true,
"@aws-cdk/aws-kms:aliasNameRef": true,
"@aws-cdk/aws-kms:applyImportedAliasPermissionsToPrincipal": true,
"@aws-cdk/aws-autoscaling:generateLaunchTemplateInsteadOfLaunchConfig": true,
"@aws-cdk/core:includePrefixInUniqueNameGeneration": true,
"@aws-cdk/aws-efs:denyAnonymousAccess": true,
"@aws-cdk/aws-opensearchservice:enableOpensearchMultiAzWithStandby": true,
"@aws-cdk/aws-lambda-nodejs:useLatestRuntimeVersion": true,
"@aws-cdk/aws-efs:mountTargetOrderInsensitiveLogicalId": true,
"@aws-cdk/aws-rds:auroraClusterChangeScopeOfInstanceParameterGroupWithEachParameters": true,
"@aws-cdk/aws-appsync:useArnForSourceApiAssociationIdentifier": true,
"@aws-cdk/aws-rds:preventRenderingDeprecatedCredentials": true,
"@aws-cdk/aws-codepipeline-actions:useNewDefaultBranchForCodeCommitSource": true,
"@aws-cdk/aws-cloudwatch-actions:changeLambdaPermissionLogicalIdForLambdaAction": true,
"@aws-cdk/aws-codepipeline:crossAccountKeysDefaultValueToFalse": true,
"@aws-cdk/aws-codepipeline:defaultPipelineTypeToV2": true,
"@aws-cdk/aws-kms:reduceCrossAccountRegionPolicyScope": true,
"@aws-cdk/aws-eks:nodegroupNameAttribute": true,
"@aws-cdk/aws-ec2:ebsDefaultGp3Volume": true,
"@aws-cdk/aws-ecs:removeDefaultDeploymentAlarm": true,
"@aws-cdk/custom-resources:logApiResponseDataPropertyTrueDefault": false,
"@aws-cdk/aws-s3:keepNotificationInImportedBucket": false,
"@aws-cdk/core:explicitStackTags": true,
"@aws-cdk/aws-ecs:enableImdsBlockingDeprecatedFeature": false,
"@aws-cdk/aws-ecs:disableEcsImdsBlocking": true,
"@aws-cdk/aws-ecs:reduceEc2FargateCloudWatchPermissions": true,
"@aws-cdk/aws-dynamodb:resourcePolicyPerReplica": true,
"@aws-cdk/aws-ec2:ec2SumTImeoutEnabled": true,
"@aws-cdk/aws-appsync:appSyncGraphQLAPIScopeLambdaPermission": true,
"@aws-cdk/aws-rds:setCorrectValueForDatabaseInstanceReadReplicaInstanceResourceId": true,
"@aws-cdk/core:cfnIncludeRejectComplexResourceUpdateCreatePolicyIntrinsics": true,
"@aws-cdk/aws-lambda-nodejs:sdkV3ExcludeSmithyPackages": true,
"@aws-cdk/aws-stepfunctions-tasks:fixRunEcsTaskPolicy": true,
"@aws-cdk/aws-ec2:bastionHostUseAmazonLinux2023ByDefault": true,
"@aws-cdk/aws-route53-targets:userPoolDomainNameMethodWithoutCustomResource": true,
"@aws-cdk/aws-elasticloadbalancingV2:albDualstackWithoutPublicIpv4SecurityGroupRulesDefault": true,
"@aws-cdk/aws-iam:oidcRejectUnauthorizedConnections": true,
"@aws-cdk/core:enableAdditionalMetadataCollection": true,
"@aws-cdk/aws-lambda:createNewPoliciesWithAddToRolePolicy": false,
"@aws-cdk/aws-s3:setUniqueReplicationRoleName": true,
"@aws-cdk/aws-events:requireEventBusPolicySid": true,
"@aws-cdk/core:aspectPrioritiesMutating": true,
"@aws-cdk/aws-dynamodb:retainTableReplica": true,
"@aws-cdk/aws-stepfunctions:useDistributedMapResultWriterV2": true,
"@aws-cdk/s3-notifications:addS3TrustKeyPolicyForSnsSubscriptions": true,
"@aws-cdk/aws-ec2:requirePrivateSubnetsForEgressOnlyInternetGateway": true,
"@aws-cdk/aws-s3:publicAccessBlockedByDefault": true,
"@aws-cdk/aws-lambda:useCdkManagedLogGroup": true
}
}

8
resources/jest.config.js Normal file
View File

@@ -0,0 +1,8 @@
module.exports = {
testEnvironment: 'node',
roots: ['<rootDir>/test'],
testMatch: ['**/*.test.ts'],
transform: {
'^.+\\.tsx?$': 'ts-jest'
}
};

View File

@@ -0,0 +1,5 @@
// Export all constructs
export * from './spicy-vpc';
export * from './spicy-ecs-cluster';
export * from './spicy-ecs-service';
export * from './spicy-alb';

View File

@@ -0,0 +1,304 @@
import * as ec2 from 'aws-cdk-lib/aws-ec2';
import * as elbv2 from 'aws-cdk-lib/aws-elasticloadbalancingv2';
import * as route53 from 'aws-cdk-lib/aws-route53';
import * as route53Targets from 'aws-cdk-lib/aws-route53-targets';
import * as s3 from 'aws-cdk-lib/aws-s3';
import * as cdk from 'aws-cdk-lib/core';
import { Construct } from 'constructs';
/**
* Tags for the ALB
*/
export interface SpicyAlbTags {
owner: string;
product: string;
component: string;
environment: string;
build?: string;
}
/**
* Blue/Green DNS configuration for persistent ALB
*/
export interface PersistentAlbBlueGreenDnsConfig {
/** Active hostname (e.g., api.example.com) */
activeHostname: string;
/** Inactive hostname (e.g., inactive-api.example.com) */
inactiveHostname: string;
/** Route53 hosted zone ID */
hostedZoneId: string;
}
/**
* Properties for the SpicyAlb construct
*/
export interface SpicyAlbProps {
/** The VPC to deploy the ALB into */
readonly vpc: ec2.IVpc;
/** VPC CIDR block (required for internal ALB security group rules) */
readonly vpcCidrBlock?: string;
/** ALB scheme: "internet-facing" or "internal" */
readonly scheme: 'internet-facing' | 'internal';
/** Subnets for the ALB (either subnet objects or subnet IDs) */
readonly subnets: ec2.ISubnet[] | string[];
/** Availability zones (required if subnets are provided as IDs) */
readonly availabilityZones?: string[];
/** SSL certificate ARN for HTTPS listener */
readonly certificateArn?: string;
/** Idle timeout in seconds */
readonly idleTimeout?: number;
/** Additional security groups for the ALB */
readonly additionalSecurityGroups?: ec2.ISecurityGroup[];
/** S3 bucket for ALB access logs */
readonly logsBucket?: s3.IBucket;
/** S3 prefix for ALB access logs */
readonly logsPrefix?: string;
/** Enable HTTP→HTTPS redirect */
readonly redirectHttpToHttps?: boolean;
/** Blue/Green DNS configuration */
readonly blueGreenDns?: PersistentAlbBlueGreenDnsConfig;
/** Required tags */
readonly tags: SpicyAlbTags;
}
/**
* SpicyAlb - A persistent ALB construct for blue/green deployments.
*
* This ALB is designed to be deployed in a separate, long-lived stack
* (like the old bg-common pattern) so the ALB DNS name stays constant
* even when service stacks are deleted and recreated.
*/
export class SpicyAlb extends Construct {
/** The Application Load Balancer */
public readonly loadBalancer: elbv2.ApplicationLoadBalancer;
/** HTTP listener */
public readonly httpListener: elbv2.ApplicationListener;
/** HTTPS listener (if certificate provided) */
public readonly httpsListener?: elbv2.ApplicationListener;
/** ALB security group */
public readonly securityGroup: ec2.SecurityGroup;
/** ALB scheme */
private readonly scheme: 'internet-facing' | 'internal';
constructor(scope: Construct, id: string, props: SpicyAlbProps) {
super(scope, id);
const stack = cdk.Stack.of(this);
// Resolve subnets - handle both subnet objects and subnet IDs
let subnets: ec2.ISubnet[];
if (props.subnets.length > 0 && typeof props.subnets[0] === 'string') {
// Subnet IDs provided - create subnet references
const subnetIds = props.subnets as string[];
const availabilityZones = props.availabilityZones ?? props.vpc.availabilityZones;
subnets = subnetIds.map((subnetId, index) =>
ec2.Subnet.fromSubnetAttributes(this, `ALBSubnet${index}`, {
subnetId,
availabilityZone: availabilityZones[index % availabilityZones.length],
})
);
} else {
// Subnet objects provided
subnets = props.subnets as ec2.ISubnet[];
}
// ALB Security Group
this.securityGroup = new ec2.SecurityGroup(this, 'ALBSecurityGroup', {
vpc: props.vpc,
description: `Security group for persistent ALB ${stack.stackName}`,
allowAllOutbound: true,
});
(this.securityGroup.node.defaultChild as ec2.CfnSecurityGroup).overrideLogicalId('ALBSecurityGroup');
// Add ingress rules based on scheme
if (props.scheme === 'internet-facing') {
this.securityGroup.addIngressRule(ec2.Peer.anyIpv4(), ec2.Port.tcp(80), 'Allow HTTP from internet');
this.securityGroup.addIngressRule(ec2.Peer.anyIpv4(), ec2.Port.tcp(443), 'Allow HTTPS from internet');
} else {
// Internal ALB - allow from VPC
const vpcCidrBlock = props.vpcCidrBlock ?? props.vpc.vpcCidrBlock ?? '10.0.0.0/16';
this.securityGroup.addIngressRule(ec2.Peer.ipv4(vpcCidrBlock), ec2.Port.tcp(80), 'Allow HTTP from VPC');
this.securityGroup.addIngressRule(ec2.Peer.ipv4(vpcCidrBlock), ec2.Port.tcp(443), 'Allow HTTPS from VPC');
}
// Add additional security groups if provided
const securityGroups = [this.securityGroup, ...(props.additionalSecurityGroups ?? [])];
// Store scheme
this.scheme = props.scheme;
// Create ALB
this.loadBalancer = new elbv2.ApplicationLoadBalancer(this, 'LoadBalancer', {
vpc: props.vpc,
internetFacing: props.scheme === 'internet-facing',
securityGroup: this.securityGroup,
vpcSubnets: { subnets },
idleTimeout: cdk.Duration.seconds(props.idleTimeout ?? 60),
});
(this.loadBalancer.node.defaultChild as elbv2.CfnLoadBalancer).overrideLogicalId('LoadBalancer');
// Enable access logs if bucket provided
if (props.logsBucket) {
const prefix = props.logsPrefix ?? `${stack.stackName}`;
this.loadBalancer.logAccessLogs(props.logsBucket, prefix);
}
// HTTP Listener
this.httpListener = this.loadBalancer.addListener('HTTPListener', {
port: 80,
protocol: elbv2.ApplicationProtocol.HTTP,
});
// HTTPS Listener (if certificate provided)
if (props.certificateArn) {
this.httpsListener = this.loadBalancer.addListener('HTTPSListener', {
port: 443,
protocol: elbv2.ApplicationProtocol.HTTPS,
certificates: [elbv2.ListenerCertificate.fromArn(props.certificateArn)],
defaultAction: elbv2.ListenerAction.fixedResponse(404, {
contentType: 'text/plain',
messageBody: 'No target groups configured',
}),
});
// HTTP→HTTPS redirect (if enabled, default true)
if (props.redirectHttpToHttps !== false) {
this.httpListener.addAction('RedirectToHTTPS', {
action: elbv2.ListenerAction.redirect({
protocol: 'HTTPS',
port: '443',
permanent: true,
}),
});
}
}
// Blue/Green DNS configuration
if (props.blueGreenDns) {
this.configureBlueGreenDns(props.blueGreenDns);
}
// Apply tags
this.applyTags(props.tags);
// Add outputs
this.addOutputs();
}
/**
* Configure Blue/Green DNS (active and inactive hostnames)
*/
private configureBlueGreenDns(dns: PersistentAlbBlueGreenDnsConfig): void {
// Extract domain from hostname (e.g., "api.example.com" -> "example.com")
const domainParts = dns.activeHostname.split('.');
const zoneName = domainParts.slice(-2).join('.');
const activeRecordName = domainParts.slice(0, -2).join('.') || '@';
const inactiveRecordName = dns.inactiveHostname.split('.').slice(0, -2).join('.') || '@';
const hostedZone = route53.HostedZone.fromHostedZoneAttributes(this, 'HostedZone', {
hostedZoneId: dns.hostedZoneId,
zoneName: zoneName,
});
// Active hostname DNS record
new route53.ARecord(this, 'ActiveDnsRecord', {
zone: hostedZone,
recordName: activeRecordName === '@' ? undefined : activeRecordName,
target: route53.RecordTarget.fromAlias(new route53Targets.LoadBalancerTarget(this.loadBalancer)),
});
// Inactive hostname DNS record
new route53.ARecord(this, 'InactiveDnsRecord', {
zone: hostedZone,
recordName: inactiveRecordName === '@' ? undefined : inactiveRecordName,
target: route53.RecordTarget.fromAlias(new route53Targets.LoadBalancerTarget(this.loadBalancer)),
});
}
/**
* Apply tags to all resources
*/
private applyTags(tags: SpicyAlbTags): void {
const stack = cdk.Stack.of(this);
cdk.Tags.of(this).add('Name', stack.stackName);
cdk.Tags.of(this).add('Owner', tags.owner);
cdk.Tags.of(this).add('Product', tags.product);
cdk.Tags.of(this).add('Component', tags.component);
cdk.Tags.of(this).add('Environment', tags.environment);
if (tags.build) {
cdk.Tags.of(this).add('Build', tags.build);
}
}
/**
* Add CloudFormation outputs
*/
private addOutputs(): void {
const stack = cdk.Stack.of(this);
const scheme = this.scheme;
const prefix = scheme === 'internet-facing' ? 'internet-facing' : 'internal';
const albDnsOutput = new cdk.CfnOutput(this, 'LoadBalancerDNSOutput', {
value: this.loadBalancer.loadBalancerDnsName,
description: `The DNS name of the ${scheme} load balancer`,
exportName: `${stack.stackName}-${prefix}-url`,
});
albDnsOutput.overrideLogicalId('LoadBalancerDNS');
const albArnOutput = new cdk.CfnOutput(this, 'LoadBalancerArnOutput', {
value: this.loadBalancer.loadBalancerArn,
description: `The ARN of the ${scheme} load balancer`,
exportName: `${stack.stackName}-${prefix}-arn`,
});
albArnOutput.overrideLogicalId('LoadBalancerArn');
const albHostedZoneOutput = new cdk.CfnOutput(this, 'LoadBalancerHostedZoneIdOutput', {
value: this.loadBalancer.loadBalancerCanonicalHostedZoneId,
description: `The hosted zone ID of the ${scheme} load balancer`,
exportName: `${stack.stackName}-${prefix}-hosted-zone-id`,
});
albHostedZoneOutput.overrideLogicalId('LoadBalancerHostedZoneId');
const httpListenerOutput = new cdk.CfnOutput(this, 'HTTPListenerArnOutput', {
value: this.httpListener.listenerArn,
description: `The ARN of the ${scheme} HTTP listener`,
exportName: `${stack.stackName}-${prefix}-http-listener`,
});
httpListenerOutput.overrideLogicalId('HTTPListenerArn');
if (this.httpsListener) {
const httpsListenerOutput = new cdk.CfnOutput(this, 'HTTPSListenerArnOutput', {
value: this.httpsListener.listenerArn,
description: `The ARN of the ${scheme} HTTPS listener`,
exportName: `${stack.stackName}-${prefix}-https-listener`,
});
httpsListenerOutput.overrideLogicalId('HTTPSListenerArn');
}
const securityGroupOutput = new cdk.CfnOutput(this, 'SecurityGroupIdOutput', {
value: this.securityGroup.securityGroupId,
description: `The security group ID of the ${scheme} load balancer`,
exportName: `${stack.stackName}-${prefix}-security-group`,
});
securityGroupOutput.overrideLogicalId('SecurityGroupId');
}
}

View File

@@ -0,0 +1,970 @@
import * as autoscaling from 'aws-cdk-lib/aws-autoscaling';
import * as ec2 from 'aws-cdk-lib/aws-ec2';
import * as ecs from 'aws-cdk-lib/aws-ecs';
import * as elbv2 from 'aws-cdk-lib/aws-elasticloadbalancingv2';
import * as iam from 'aws-cdk-lib/aws-iam';
import * as lambda from 'aws-cdk-lib/aws-lambda';
import * as logs from 'aws-cdk-lib/aws-logs';
import * as s3 from 'aws-cdk-lib/aws-s3';
import * as sns from 'aws-cdk-lib/aws-sns';
import * as subscriptions from 'aws-cdk-lib/aws-sns-subscriptions';
import * as cdk from 'aws-cdk-lib/core';
import { Construct } from 'constructs';
/**
* Configuration for VPC tagging
*/
export interface SpicyEcsClusterTags {
owner: string;
product: string;
component: string;
environment: string;
build?: string;
}
/**
* Scaling configuration for the ECS cluster
*/
export interface ClusterScalingConfig {
/** Minimum number of instances */
minCapacity?: number;
/** Maximum number of instances */
maxCapacity?: number;
/** Target capacity utilization percentage for managed scaling (0-100) */
targetCapacityPercent?: number;
/** Cooldown period in seconds after scale up */
scaleUpCooldown?: number;
/** Cooldown period in seconds after scale down */
scaleDownCooldown?: number;
}
/**
* Spot instance configuration
*/
export interface SpotConfig {
/** Enable Spot instances */
enabled: boolean;
/** Percentage of On-Demand instances to maintain (0-100) */
onDemandPercentage?: number;
/** Spot allocation strategy */
spotAllocationStrategy?: 'lowest-price' | 'capacity-optimized' | 'capacity-optimized-prioritized';
/** Maximum Spot price (leave undefined for on-demand price) */
maxSpotPrice?: string;
}
/**
* Load balancer configuration
*/
export interface LoadBalancerConfig {
/** Create an internet-facing load balancer */
createExternal?: boolean;
/** Create an internal load balancer */
createInternal?: boolean;
/** SSL certificate ARN for HTTPS listeners */
certificateArn?: string;
/** Idle timeout in seconds */
idleTimeout?: number;
/** Enable access logging */
enableAccessLogs?: boolean;
}
/**
* Properties for the SpicyEcsCluster construct
*/
export interface SpicyEcsClusterProps {
/**
* The VPC to deploy the cluster into
*/
readonly vpc: ec2.IVpc;
/**
* VPC CIDR block (required for imported VPCs)
* @default Uses vpc.vpcCidrBlock if available
*/
readonly vpcCidrBlock?: string;
/**
* Subnets for EC2 instances (private subnets recommended)
*/
readonly instanceSubnets?: ec2.SubnetSelection;
/**
* Subnets for external load balancer (public subnets)
*/
readonly externalSubnets?: ec2.SubnetSelection;
/**
* Subnets for internal load balancer (private subnets)
*/
readonly internalSubnets?: ec2.SubnetSelection;
/**
* EC2 instance type for the cluster
* @default m5a.large
*/
readonly instanceType?: ec2.InstanceType;
/**
* Additional instance types for mixed instances policy
* Used with Spot instances for better availability
*/
readonly additionalInstanceTypes?: ec2.InstanceType[];
/**
* EC2 Key Pair name for SSH access
*/
readonly keyName?: string;
/**
* EBS volume size in GB
* @default 100
*/
readonly ebsVolumeSize?: number;
/**
* Enable Container Insights
* @default true
*/
readonly containerInsights?: boolean;
/**
* Scaling configuration
*/
readonly scaling?: ClusterScalingConfig;
/**
* Spot instance configuration
*/
readonly spot?: SpotConfig;
/**
* Load balancer configuration
*/
readonly loadBalancer?: LoadBalancerConfig;
/**
* Additional security groups for EC2 instances
*/
readonly additionalSecurityGroups?: ec2.ISecurityGroup[];
/**
* Enable Fargate capacity providers (adds both FARGATE and FARGATE_SPOT)
* @default false
*/
readonly enableFargate?: boolean;
/**
* Timeout in seconds for draining tasks before termination
* @default 900
*/
readonly drainingTimeout?: number;
/**
* Maximum instance lifetime in seconds (for instance refresh)
* @default 604800 (7 days)
*/
readonly maxInstanceLifetime?: number;
/**
* ELB Account ID for access logs (region-specific)
* See: https://docs.aws.amazon.com/elasticloadbalancing/latest/classic/enable-access-logs.html
*/
readonly elbAccountId?: string;
/**
* Custom S3 bucket name for ALB logs
*/
readonly logsBucketName?: string;
/**
* VPC stack name (for exporting to other stacks)
* Used to export the VPC stack name so other stacks can import VPC details
*/
readonly vpcStackName?: string;
/**
* Required tags for the cluster and resources
*/
readonly tags: SpicyEcsClusterTags;
}
/**
* ELB Account IDs by region for access logging
*/
const ELB_ACCOUNT_IDS: Record<string, string> = {
'us-east-1': '127311923021',
'us-east-2': '033677994240',
'us-west-1': '027434742980',
'us-west-2': '797873946194',
'ca-central-1': '985666609251',
'eu-west-1': '156460612806',
'eu-west-2': '652711504416',
'eu-west-3': '009996457667',
'eu-central-1': '054676820928',
'ap-northeast-1': '582318560864',
'ap-northeast-2': '600734575887',
'ap-southeast-1': '114774131450',
'ap-southeast-2': '783225319266',
'ap-south-1': '718504428378',
'sa-east-1': '507241528517',
};
/**
* SpicyEcsCluster - A production-ready ECS cluster with:
* - EC2 Capacity Provider with managed scaling
* - Optional Fargate capacity providers
* - Mixed instances policy for Spot support
* - Instance draining on termination
* - Optional internal/external ALBs
* - Container Insights
* - Launch Templates with IMDSv2 and gp3 volumes
*/
export class SpicyEcsCluster extends Construct {
/** The ECS cluster */
public readonly cluster: ecs.Cluster;
/** The Auto Scaling Group */
public readonly autoScalingGroup: autoscaling.AutoScalingGroup;
/** EC2 Capacity Provider */
public readonly ec2CapacityProvider: ecs.AsgCapacityProvider;
/** External (internet-facing) load balancer */
public readonly externalLoadBalancer?: elbv2.ApplicationLoadBalancer;
/** Internal load balancer */
public readonly internalLoadBalancer?: elbv2.ApplicationLoadBalancer;
/** External HTTPS listener */
public readonly externalHttpsListener?: elbv2.ApplicationListener;
/** External HTTP listener */
public readonly externalHttpListener?: elbv2.ApplicationListener;
/** Internal HTTPS listener */
public readonly internalHttpsListener?: elbv2.ApplicationListener;
/** Internal HTTP listener */
public readonly internalHttpListener?: elbv2.ApplicationListener;
/** ECS Host security group */
public readonly ecsHostSecurityGroup: ec2.SecurityGroup;
/** Load balancer security group */
public readonly loadBalancerSecurityGroup?: ec2.SecurityGroup;
/** S3 bucket for ALB access logs */
public readonly logsBucket?: s3.Bucket;
constructor(scope: Construct, id: string, props: SpicyEcsClusterProps) {
super(scope, id);
const stack = cdk.Stack.of(this);
const region = stack.region;
// Default values
const instanceType = props.instanceType ?? ec2.InstanceType.of(ec2.InstanceClass.M5A, ec2.InstanceSize.LARGE);
const ebsVolumeSize = props.ebsVolumeSize ?? 100;
const containerInsights = props.containerInsights ?? true;
const drainingTimeout = props.drainingTimeout ?? 900;
const maxInstanceLifetime = props.maxInstanceLifetime ?? 604800;
const scaling = {
minCapacity: props.scaling?.minCapacity ?? 2,
maxCapacity: props.scaling?.maxCapacity ?? 4,
targetCapacityPercent: props.scaling?.targetCapacityPercent ?? 100,
scaleUpCooldown: props.scaling?.scaleUpCooldown ?? 60,
scaleDownCooldown: props.scaling?.scaleDownCooldown ?? 300,
};
const spot = {
enabled: props.spot?.enabled ?? false,
onDemandPercentage: props.spot?.onDemandPercentage ?? 100,
spotAllocationStrategy: props.spot?.spotAllocationStrategy ?? 'capacity-optimized',
};
const loadBalancer = {
createExternal: props.loadBalancer?.createExternal ?? false,
createInternal: props.loadBalancer?.createInternal ?? false,
certificateArn: props.loadBalancer?.certificateArn,
idleTimeout: props.loadBalancer?.idleTimeout ?? 60,
enableAccessLogs: props.loadBalancer?.enableAccessLogs ?? true,
};
// Create ECS Cluster
this.cluster = new ecs.Cluster(this, 'Cluster', {
vpc: props.vpc,
clusterName: stack.stackName,
enableFargateCapacityProviders: false,
});
// Enable Container Insights if requested
if (containerInsights) {
const cfnCluster = this.cluster.node.defaultChild as ecs.CfnCluster;
cfnCluster.configuration = {
executeCommandConfiguration: {
logging: 'DEFAULT',
},
};
cfnCluster.addPropertyOverride('ClusterSettings', [
{
Name: 'containerInsights',
Value: 'enabled',
},
]);
}
(this.cluster.node.defaultChild as ecs.CfnCluster).overrideLogicalId('Cluster');
// Security Groups
this.ecsHostSecurityGroup = new ec2.SecurityGroup(this, 'ECSHostSecurityGroup', {
vpc: props.vpc,
description: 'Security group for ECS container instances',
allowAllOutbound: true,
});
(this.ecsHostSecurityGroup.node.defaultChild as ec2.CfnSecurityGroup).overrideLogicalId('ECSHostSecurityGroup');
// Allow internal VPC traffic
const vpcCidrBlock = props.vpcCidrBlock ?? props.vpc.vpcCidrBlock;
if (vpcCidrBlock) {
this.ecsHostSecurityGroup.addIngressRule(
ec2.Peer.ipv4(vpcCidrBlock),
ec2.Port.allTraffic(),
'Allow internal VPC traffic'
);
}
// IAM Role for EC2 instances
const instanceRole = new iam.Role(this, 'InstanceRole', {
assumedBy: new iam.ServicePrincipal('ec2.amazonaws.com'),
managedPolicies: [
iam.ManagedPolicy.fromAwsManagedPolicyName('service-role/AmazonEC2ContainerServiceforEC2Role'),
iam.ManagedPolicy.fromAwsManagedPolicyName('AmazonSSMManagedInstanceCore'),
],
});
(instanceRole.node.defaultChild as iam.CfnRole).overrideLogicalId('InstanceRole');
// Additional permissions for ECS instances
instanceRole.addToPolicy(
new iam.PolicyStatement({
actions: [
'logs:CreateLogGroup',
'logs:CreateLogStream',
'logs:PutLogEvents',
'logs:DescribeLogGroups',
'logs:DescribeLogStreams',
],
resources: ['*'],
})
);
instanceRole.addToPolicy(
new iam.PolicyStatement({
actions: ['ec2:DescribeVolumes', 'ec2:CreateTags'],
resources: ['*'],
})
);
instanceRole.addToPolicy(
new iam.PolicyStatement({
actions: ['ecs:UpdateContainerInstancesState'],
resources: ['*'],
})
);
// User data script
const userData = ec2.UserData.forLinux();
userData.addCommands(
'#!/bin/bash',
'set -e',
'',
'# Install SSM agent and other utilities',
'yum install -y amazon-ssm-agent',
'systemctl enable amazon-ssm-agent',
'systemctl start amazon-ssm-agent',
'',
`# Configure ECS agent`,
`echo "ECS_CLUSTER=${stack.stackName}" >> /etc/ecs/ecs.config`,
'echo "ECS_ENABLE_SPOT_INSTANCE_DRAINING=true" >> /etc/ecs/ecs.config',
'echo "ECS_ENABLE_CONTAINER_METADATA=true" >> /etc/ecs/ecs.config',
'echo \'ECS_AVAILABLE_LOGGING_DRIVERS=["json-file","awslogs","splunk"]\' >> /etc/ecs/ecs.config',
'',
'# Signal success',
`/opt/aws/bin/cfn-signal -e $? --stack ${stack.stackName} --resource AutoScalingGroup --region ${region}`
);
// Create Auto Scaling Group with Launch Template
this.autoScalingGroup = new autoscaling.AutoScalingGroup(this, 'AutoScalingGroup', {
vpc: props.vpc,
vpcSubnets: props.instanceSubnets ?? { subnetType: ec2.SubnetType.PRIVATE_WITH_EGRESS },
instanceType: instanceType,
machineImage: ecs.EcsOptimizedImage.amazonLinux2(),
role: instanceRole,
minCapacity: scaling.minCapacity,
maxCapacity: scaling.maxCapacity,
maxInstanceLifetime: cdk.Duration.seconds(maxInstanceLifetime),
healthChecks: autoscaling.HealthChecks.ec2({
gracePeriod: cdk.Duration.minutes(5),
}),
updatePolicy: autoscaling.UpdatePolicy.rollingUpdate({
maxBatchSize: 1,
minInstancesInService: 1,
pauseTime: cdk.Duration.minutes(5),
}),
signals: autoscaling.Signals.waitForMinCapacity({
timeout: cdk.Duration.minutes(15),
}),
userData: userData,
blockDevices: [
{
deviceName: '/dev/xvda',
volume: autoscaling.BlockDeviceVolume.ebs(ebsVolumeSize, {
volumeType: autoscaling.EbsDeviceVolumeType.GP3,
encrypted: true,
}),
},
],
keyPair: props.keyName ? ec2.KeyPair.fromKeyPairName(this, 'KeyPair', props.keyName) : undefined,
securityGroup: this.ecsHostSecurityGroup,
});
(this.autoScalingGroup.node.defaultChild as autoscaling.CfnAutoScalingGroup).overrideLogicalId('AutoScalingGroup');
// Add additional security groups
if (props.additionalSecurityGroups) {
for (const sg of props.additionalSecurityGroups) {
this.autoScalingGroup.addSecurityGroup(sg);
}
}
// Configure Mixed Instances Policy for Spot support
if (spot.enabled) {
const cfnAsg = this.autoScalingGroup.node.defaultChild as autoscaling.CfnAutoScalingGroup;
// Get the launch template from the ASG
const launchTemplate = this.autoScalingGroup.node.findChild('LaunchTemplate') as ec2.LaunchTemplate;
const cfnLaunchTemplate = launchTemplate.node.defaultChild as ec2.CfnLaunchTemplate;
// Build instance type overrides
const instanceTypes = [instanceType, ...(props.additionalInstanceTypes ?? [])];
const overrides = instanceTypes.map((it) => ({
instanceType: it.toString(),
}));
cfnAsg.mixedInstancesPolicy = {
launchTemplate: {
launchTemplateSpecification: {
launchTemplateId: cfnLaunchTemplate.ref,
version: cfnLaunchTemplate.attrLatestVersionNumber,
},
overrides: overrides,
},
instancesDistribution: {
onDemandBaseCapacity: 0,
onDemandPercentageAboveBaseCapacity: spot.onDemandPercentage,
spotAllocationStrategy: spot.spotAllocationStrategy,
spotMaxPrice: props.spot?.maxSpotPrice,
},
};
// Remove the direct launch template reference since we're using mixed instances
cfnAsg.launchTemplate = undefined;
cfnAsg.launchConfigurationName = undefined;
}
// Enable IMDSv2
const asgLaunchTemplate = this.autoScalingGroup.node.tryFindChild('LaunchTemplate');
if (asgLaunchTemplate) {
const cfnLt = asgLaunchTemplate.node.defaultChild as ec2.CfnLaunchTemplate;
cfnLt.addPropertyOverride('LaunchTemplateData.MetadataOptions', {
HttpTokens: 'required',
HttpPutResponseHopLimit: 2,
HttpEndpoint: 'enabled',
});
}
// Create EC2 Capacity Provider with managed scaling
this.ec2CapacityProvider = new ecs.AsgCapacityProvider(this, 'EC2CapacityProvider', {
autoScalingGroup: this.autoScalingGroup,
enableManagedScaling: true,
enableManagedTerminationProtection: true,
targetCapacityPercent: scaling.targetCapacityPercent,
capacityProviderName: `${stack.stackName}-ec2`,
});
// Override capacity provider logical ID (the child is named 'EC2CapacityProvider')
const cfnCapacityProvider = this.ec2CapacityProvider.node.tryFindChild(
'EC2CapacityProvider'
) as ecs.CfnCapacityProvider;
if (cfnCapacityProvider) {
cfnCapacityProvider.overrideLogicalId('EC2CapacityProvider');
}
// Override Launch Template and Instance Profile logical IDs
if (asgLaunchTemplate) {
const cfnLaunchTemplate = asgLaunchTemplate.node.defaultChild as ec2.CfnLaunchTemplate;
cfnLaunchTemplate.overrideLogicalId('LaunchTemplate');
}
const instanceProfile = this.autoScalingGroup.node.tryFindChild('InstanceProfile');
if (instanceProfile) {
(instanceProfile as iam.CfnInstanceProfile).overrideLogicalId('InstanceProfile');
}
// Add EC2 capacity provider to cluster
this.cluster.addAsgCapacityProvider(this.ec2CapacityProvider);
// Add Fargate capacity providers if enabled
if (props.enableFargate) {
this.cluster.enableFargateCapacityProviders();
}
// Instance draining Lambda
this.createDrainingLambda(props, drainingTimeout);
// Load Balancers
if (loadBalancer.createExternal || loadBalancer.createInternal) {
this.loadBalancerSecurityGroup = new ec2.SecurityGroup(this, 'LoadBalancerSecurityGroup', {
vpc: props.vpc,
description: 'Security group for ALBs',
allowAllOutbound: true,
});
(this.loadBalancerSecurityGroup.node.defaultChild as ec2.CfnSecurityGroup).overrideLogicalId(
'LoadBalancerSecurityGroup'
);
// Allow ALB to communicate with ECS hosts
this.ecsHostSecurityGroup.addIngressRule(
this.loadBalancerSecurityGroup,
ec2.Port.allTraffic(),
'Allow traffic from ALB'
);
// Create logs bucket if access logs enabled
if (loadBalancer.enableAccessLogs) {
const elbAccountId = props.elbAccountId ?? ELB_ACCOUNT_IDS[region] ?? ELB_ACCOUNT_IDS['ca-central-1'];
this.logsBucket = new s3.Bucket(this, 'LogsBucket', {
bucketName: props.logsBucketName ?? `${stack.stackName}-${region}-alb-logs`,
encryption: s3.BucketEncryption.S3_MANAGED,
blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL,
lifecycleRules: [
{
expiration: cdk.Duration.days(365),
},
],
removalPolicy: cdk.RemovalPolicy.RETAIN,
});
(this.logsBucket.node.defaultChild as s3.CfnBucket).overrideLogicalId('LogsBucket');
this.logsBucket.addToResourcePolicy(
new iam.PolicyStatement({
actions: ['s3:PutObject'],
resources: [`${this.logsBucket.bucketArn}/*`],
principals: [new iam.AccountPrincipal(elbAccountId)],
})
);
}
if (loadBalancer.createExternal) {
this.createExternalLoadBalancer(props, loadBalancer);
}
if (loadBalancer.createInternal) {
this.createInternalLoadBalancer(props, loadBalancer);
}
}
// Apply tags
this.applyTags(props.tags);
// Add outputs
this.addOutputs(props);
}
/**
* Create instance draining Lambda function
*/
private createDrainingLambda(props: SpicyEcsClusterProps, drainingTimeout: number): void {
const stack = cdk.Stack.of(this);
// SNS Topic for ASG lifecycle events
const lifecycleTopic = new sns.Topic(this, 'LifecycleTopic', {
displayName: `${stack.stackName}-lifecycle`,
});
(lifecycleTopic.node.defaultChild as sns.CfnTopic).overrideLogicalId('LifecycleTopic');
// Lambda execution role
const lambdaRole = new iam.Role(this, 'DrainingLambdaRole', {
assumedBy: new iam.ServicePrincipal('lambda.amazonaws.com'),
managedPolicies: [iam.ManagedPolicy.fromAwsManagedPolicyName('service-role/AWSLambdaBasicExecutionRole')],
});
(lambdaRole.node.defaultChild as iam.CfnRole).overrideLogicalId('DrainingLambdaRole');
lambdaRole.addToPolicy(
new iam.PolicyStatement({
actions: [
'autoscaling:CompleteLifecycleAction',
'autoscaling:RecordLifecycleActionHeartbeat',
'ecs:ListContainerInstances',
'ecs:DescribeContainerInstances',
'ecs:UpdateContainerInstancesState',
'sns:Publish',
],
resources: ['*'],
})
);
// Draining Lambda function
const drainingLambda = new lambda.Function(this, 'DrainingLambda', {
runtime: lambda.Runtime.PYTHON_3_11,
handler: 'index.lambda_handler',
role: lambdaRole,
timeout: cdk.Duration.seconds(60),
memorySize: 128,
description: 'Gracefully drain ECS tasks before instance termination',
logGroup: new logs.LogGroup(this, 'DrainingLambdaLogGroup', {
retention: logs.RetentionDays.ONE_WEEK,
removalPolicy: cdk.RemovalPolicy.DESTROY,
}),
code: lambda.Code.fromInline(`
import datetime
import json
import time
import boto3
CLUSTER = '${stack.stackName}'
TIMEOUT = ${drainingTimeout}
REGION = '${stack.region}'
def aws(svc):
return boto3.client(svc, region_name=REGION)
ASG = aws('autoscaling')
ECS = aws('ecs')
SNS = aws('sns')
def lookup_instance(msg):
res = ECS.list_container_instances(cluster=CLUSTER, filter='ec2InstanceId == %s' % (msg['EC2InstanceId']))
if not res['containerInstanceArns']:
return None, None, 0
res = ECS.describe_container_instances(cluster=CLUSTER, containerInstances=res['containerInstanceArns'])
ret = (res['containerInstances'][0]['containerInstanceArn'], res['containerInstances'][0]['status'], res['containerInstances'][0]['runningTasksCount'])
print('Found: %s %s' % (str(ret), msg))
return ret
def can_terminate(msg):
(arn, status, count) = lookup_instance(msg)
if arn is None:
print('Cannot lookup: %s' % (msg))
return True
if status != 'DRAINING':
print('Draining: %s' % (msg))
ECS.update_container_instances_state(cluster=CLUSTER, containerInstances=[arn], status='DRAINING')
return False
if count == 0:
print('Finished draining: %s' % (msg))
return True
now = datetime.datetime.now().timestamp()
if msg['instance_timeout'] < now:
print('Timed out: %s' % (msg))
return True
return False
def lambda_handler(event, context):
msg = json.loads(event['Records'][0]['Sns']['Message'])
if 'instance_timeout' not in msg:
msg['instance_timeout'] = (datetime.datetime.now() + datetime.timedelta(seconds=TIMEOUT)).timestamp()
if 'LifecycleTransition' not in msg.keys() or msg['LifecycleTransition'].find('autoscaling:EC2_INSTANCE_TERMINATING') == -1:
print('Unknown transition: %s' % (msg))
return
if can_terminate(msg):
print('ASG complete: %s' % (msg))
ASG.complete_lifecycle_action(LifecycleHookName=msg['LifecycleHookName'], AutoScalingGroupName=msg['AutoScalingGroupName'], LifecycleActionResult='CONTINUE', InstanceId=msg['EC2InstanceId'])
return
print('Tasks are still running: %s' % (msg))
time.sleep(20)
ASG.record_lifecycle_action_heartbeat(LifecycleHookName=msg['LifecycleHookName'], AutoScalingGroupName=msg['AutoScalingGroupName'], LifecycleActionToken=msg['LifecycleActionToken'], InstanceId=msg['EC2InstanceId'])
SNS.publish(TopicArn=event['Records'][0]['Sns']['TopicArn'], Message=json.dumps(msg), Subject='Retry')
`),
});
(drainingLambda.node.defaultChild as lambda.CfnFunction).overrideLogicalId('DrainingLambda');
// Subscribe Lambda to SNS topic
lifecycleTopic.addSubscription(new subscriptions.LambdaSubscription(drainingLambda));
// Lifecycle hook role
const lifecycleRole = new iam.Role(this, 'LifecycleRole', {
assumedBy: new iam.ServicePrincipal('autoscaling.amazonaws.com'),
managedPolicies: [iam.ManagedPolicy.fromAwsManagedPolicyName('service-role/AutoScalingNotificationAccessRole')],
});
(lifecycleRole.node.defaultChild as iam.CfnRole).overrideLogicalId('LifecycleRole');
// Add lifecycle hook
this.autoScalingGroup.addLifecycleHook('TerminationHook', {
lifecycleTransition: autoscaling.LifecycleTransition.INSTANCE_TERMINATING,
notificationTarget: new (class implements autoscaling.ILifecycleHookTarget {
bind(_scope: Construct, _options: autoscaling.BindHookTargetOptions): autoscaling.LifecycleHookTargetConfig {
return {
notificationTargetArn: lifecycleTopic.topicArn,
createdRole: lifecycleRole,
};
}
})(),
defaultResult: autoscaling.DefaultResult.ABANDON,
heartbeatTimeout: cdk.Duration.seconds(120),
});
}
/**
* Create external (internet-facing) load balancer
*/
private createExternalLoadBalancer(
props: SpicyEcsClusterProps,
config: { certificateArn?: string; idleTimeout: number; enableAccessLogs: boolean }
): void {
this.loadBalancerSecurityGroup!.addIngressRule(ec2.Peer.anyIpv4(), ec2.Port.tcp(80), 'Allow HTTP');
this.loadBalancerSecurityGroup!.addIngressRule(ec2.Peer.anyIpv4(), ec2.Port.tcp(443), 'Allow HTTPS');
(this as { externalLoadBalancer: elbv2.ApplicationLoadBalancer }).externalLoadBalancer =
new elbv2.ApplicationLoadBalancer(this, 'ExternalLoadBalancer', {
vpc: props.vpc,
internetFacing: true,
securityGroup: this.loadBalancerSecurityGroup,
vpcSubnets: props.externalSubnets ?? { subnetType: ec2.SubnetType.PUBLIC },
idleTimeout: cdk.Duration.seconds(config.idleTimeout),
});
(this.externalLoadBalancer!.node.defaultChild as elbv2.CfnLoadBalancer).overrideLogicalId('ExternalLoadBalancer');
if (config.enableAccessLogs && this.logsBucket) {
this.externalLoadBalancer!.logAccessLogs(this.logsBucket, 'external');
}
// HTTP Listener
(this as { externalHttpListener: elbv2.ApplicationListener }).externalHttpListener =
this.externalLoadBalancer!.addListener('ExternalHTTP', {
port: 80,
protocol: elbv2.ApplicationProtocol.HTTP,
defaultAction: elbv2.ListenerAction.fixedResponse(404, {
contentType: 'text/plain',
messageBody: 'Not Found',
}),
});
// HTTPS Listener (if certificate provided)
if (config.certificateArn) {
(this as { externalHttpsListener: elbv2.ApplicationListener }).externalHttpsListener =
this.externalLoadBalancer!.addListener('ExternalHTTPS', {
port: 443,
protocol: elbv2.ApplicationProtocol.HTTPS,
certificates: [elbv2.ListenerCertificate.fromArn(config.certificateArn)],
defaultAction: elbv2.ListenerAction.fixedResponse(404, {
contentType: 'text/plain',
messageBody: 'Not Found',
}),
});
}
}
/**
* Create internal load balancer
*/
private createInternalLoadBalancer(
props: SpicyEcsClusterProps,
config: { certificateArn?: string; idleTimeout: number; enableAccessLogs: boolean }
): void {
(this as { internalLoadBalancer: elbv2.ApplicationLoadBalancer }).internalLoadBalancer =
new elbv2.ApplicationLoadBalancer(this, 'InternalLoadBalancer', {
vpc: props.vpc,
internetFacing: false,
securityGroup: this.loadBalancerSecurityGroup,
vpcSubnets: props.internalSubnets ?? { subnetType: ec2.SubnetType.PRIVATE_WITH_EGRESS },
idleTimeout: cdk.Duration.seconds(config.idleTimeout),
});
(this.internalLoadBalancer!.node.defaultChild as elbv2.CfnLoadBalancer).overrideLogicalId('InternalLoadBalancer');
if (config.enableAccessLogs && this.logsBucket) {
this.internalLoadBalancer!.logAccessLogs(this.logsBucket, 'internal');
}
// HTTP Listener
(this as { internalHttpListener: elbv2.ApplicationListener }).internalHttpListener =
this.internalLoadBalancer!.addListener('InternalHTTP', {
port: 80,
protocol: elbv2.ApplicationProtocol.HTTP,
defaultAction: elbv2.ListenerAction.fixedResponse(404, {
contentType: 'text/plain',
messageBody: 'Not Found',
}),
});
// HTTPS Listener (if certificate provided)
if (config.certificateArn) {
(this as { internalHttpsListener: elbv2.ApplicationListener }).internalHttpsListener =
this.internalLoadBalancer!.addListener('InternalHTTPS', {
port: 443,
protocol: elbv2.ApplicationProtocol.HTTPS,
certificates: [elbv2.ListenerCertificate.fromArn(config.certificateArn)],
defaultAction: elbv2.ListenerAction.fixedResponse(404, {
contentType: 'text/plain',
messageBody: 'Not Found',
}),
});
}
}
/**
* Apply tags to all resources
*/
private applyTags(tags: SpicyEcsClusterTags): void {
const stack = cdk.Stack.of(this);
cdk.Tags.of(this).add('Name', stack.stackName);
cdk.Tags.of(this).add('Owner', tags.owner);
cdk.Tags.of(this).add('Product', tags.product);
cdk.Tags.of(this).add('Component', tags.component);
cdk.Tags.of(this).add('Environment', tags.environment);
if (tags.build) {
cdk.Tags.of(this).add('Build', tags.build);
}
}
/**
* Add CloudFormation outputs
*/
private addOutputs(props: SpicyEcsClusterProps): void {
const stack = cdk.Stack.of(this);
const clusterNameOutput = new cdk.CfnOutput(this, 'ClusterNameOutput', {
value: this.cluster.clusterName,
description: 'ECS Cluster Name',
exportName: `${stack.stackName}-cluster-name`,
});
clusterNameOutput.overrideLogicalId('ClusterName');
const clusterArnOutput = new cdk.CfnOutput(this, 'ClusterArnOutput', {
value: this.cluster.clusterArn,
description: 'ECS Cluster ARN',
exportName: `${stack.stackName}-cluster-arn`,
});
clusterArnOutput.overrideLogicalId('ClusterArn');
const vpcOutput = new cdk.CfnOutput(this, 'VPCOutput', {
value: props.vpc.vpcId,
description: 'VPC ID',
exportName: `${stack.stackName}-VPC`,
});
vpcOutput.overrideLogicalId('VPC');
// Export VPC stack name if provided (for other stacks to import VPC details)
if (props.vpcStackName) {
const vpcStackNameOutput = new cdk.CfnOutput(this, 'VPCStackNameOutput', {
value: props.vpcStackName,
description: 'VPC Stack Name',
exportName: `${stack.stackName}-VPCStackName`,
});
vpcStackNameOutput.overrideLogicalId('VPCStackName');
}
const sgOutput = new cdk.CfnOutput(this, 'ECSHostSecurityGroupIdOutput', {
value: this.ecsHostSecurityGroup.securityGroupId,
description: 'ECS Host Security Group',
exportName: `${stack.stackName}-ecs-host-security-group`,
});
sgOutput.overrideLogicalId('ECSHostSecurityGroupId');
const asgOutput = new cdk.CfnOutput(this, 'AutoScalingGroupNameOutput', {
value: this.autoScalingGroup.autoScalingGroupName,
description: 'Auto Scaling Group Name',
exportName: `${stack.stackName}-auto-scaling-group`,
});
asgOutput.overrideLogicalId('AutoScalingGroupName');
if (this.externalLoadBalancer) {
const extDnsOutput = new cdk.CfnOutput(this, 'ExternalLoadBalancerDNSOutput', {
value: this.externalLoadBalancer.loadBalancerDnsName,
description: 'External Load Balancer DNS',
exportName: `${stack.stackName}-internet-facing-url`,
});
extDnsOutput.overrideLogicalId('ExternalLoadBalancerDNS');
const extArnOutput = new cdk.CfnOutput(this, 'ExternalLoadBalancerArnOutput', {
value: this.externalLoadBalancer.loadBalancerArn,
description: 'External Load Balancer ARN',
exportName: `${stack.stackName}-internet-facing-arn`,
});
extArnOutput.overrideLogicalId('ExternalLoadBalancerArn');
const extHzOutput = new cdk.CfnOutput(this, 'ExternalLoadBalancerHostedZoneIdOutput', {
value: this.externalLoadBalancer.loadBalancerCanonicalHostedZoneId,
description: 'External Load Balancer Hosted Zone ID',
exportName: `${stack.stackName}-internet-facing-hosted-zone-id`,
});
extHzOutput.overrideLogicalId('ExternalLoadBalancerHostedZoneId');
if (this.externalHttpListener) {
const extHttpOutput = new cdk.CfnOutput(this, 'ExternalHTTPListenerArnOutput', {
value: this.externalHttpListener.listenerArn,
description: 'External HTTP Listener ARN',
exportName: `${stack.stackName}-internet-facing-http-listener`,
});
extHttpOutput.overrideLogicalId('ExternalHTTPListenerArn');
}
if (this.externalHttpsListener) {
const extHttpsOutput = new cdk.CfnOutput(this, 'ExternalHTTPSListenerArnOutput', {
value: this.externalHttpsListener.listenerArn,
description: 'External HTTPS Listener ARN',
exportName: `${stack.stackName}-internet-facing-https-listener`,
});
extHttpsOutput.overrideLogicalId('ExternalHTTPSListenerArn');
}
}
if (this.internalLoadBalancer) {
const intDnsOutput = new cdk.CfnOutput(this, 'InternalLoadBalancerDNSOutput', {
value: this.internalLoadBalancer.loadBalancerDnsName,
description: 'Internal Load Balancer DNS',
exportName: `${stack.stackName}-internal-url`,
});
intDnsOutput.overrideLogicalId('InternalLoadBalancerDNS');
const intArnOutput = new cdk.CfnOutput(this, 'InternalLoadBalancerArnOutput', {
value: this.internalLoadBalancer.loadBalancerArn,
description: 'Internal Load Balancer ARN',
exportName: `${stack.stackName}-internal-arn`,
});
intArnOutput.overrideLogicalId('InternalLoadBalancerArn');
const intHzOutput = new cdk.CfnOutput(this, 'InternalLoadBalancerHostedZoneIdOutput', {
value: this.internalLoadBalancer.loadBalancerCanonicalHostedZoneId,
description: 'Internal Load Balancer Hosted Zone ID',
exportName: `${stack.stackName}-internal-hosted-zone-id`,
});
intHzOutput.overrideLogicalId('InternalLoadBalancerHostedZoneId');
if (this.internalHttpListener) {
const intHttpOutput = new cdk.CfnOutput(this, 'InternalHTTPListenerArnOutput', {
value: this.internalHttpListener.listenerArn,
description: 'Internal HTTP Listener ARN',
exportName: `${stack.stackName}-internal-http-listener`,
});
intHttpOutput.overrideLogicalId('InternalHTTPListenerArn');
}
if (this.internalHttpsListener) {
const intHttpsOutput = new cdk.CfnOutput(this, 'InternalHTTPSListenerArnOutput', {
value: this.internalHttpsListener.listenerArn,
description: 'Internal HTTPS Listener ARN',
exportName: `${stack.stackName}-internal-https-listener`,
});
intHttpsOutput.overrideLogicalId('InternalHTTPSListenerArn');
}
}
if (this.logsBucket) {
const logsOutput = new cdk.CfnOutput(this, 'LogsBucketNameOutput', {
value: this.logsBucket.bucketName,
description: 'ALB Logs S3 Bucket',
exportName: `${stack.stackName}-logs-s3-bucket`,
});
logsOutput.overrideLogicalId('LogsBucketName');
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,677 @@
import * as ec2 from 'aws-cdk-lib/aws-ec2';
import * as cdk from 'aws-cdk-lib/core';
import { Construct } from 'constructs';
/**
* Subnet CIDR configuration for each availability zone
*/
export interface SubnetCidrConfig {
/** CIDR for the public subnet */
publicCidr: string;
/** CIDR for the primary private subnet */
private1Cidr: string;
/** CIDR for the secondary private subnet (with NACL) */
private2Cidr?: string;
}
/**
* Configuration for VPC tagging
*/
export interface SpicyVpcTags {
owner: string;
product: string;
component: string;
build?: string;
}
/**
* Properties for the SpicyVpc construct
*/
export interface SpicyVpcProps {
/**
* The CIDR block for the VPC
* @default "172.1.0.0/16"
*/
readonly vpcCidr?: string;
/**
* The tenancy of instances launched into the VPC
* @default "default"
*/
readonly vpcTenancy?: 'default' | 'dedicated';
/**
* List of availability zones to use
* If not provided, will use the first N AZs in the region based on numberOfAzs
*/
readonly availabilityZones?: string[];
/**
* Number of availability zones to use (2, 3, or 4)
* @default 4
*/
readonly numberOfAzs?: 2 | 3 | 4;
/**
* Whether to create private subnets
* @default true
*/
readonly createPrivateSubnets?: boolean;
/**
* Whether to create additional private subnets with dedicated NACLs
* Only applies if createPrivateSubnets is true
* @default true
*/
readonly createAdditionalPrivateSubnets?: boolean;
/**
* Subnet CIDR configurations per AZ
* Keys should be 'a', 'b', 'c', 'd' for each AZ
*/
readonly subnetCidrs?: {
a?: SubnetCidrConfig;
b?: SubnetCidrConfig;
c?: SubnetCidrConfig;
d?: SubnetCidrConfig;
};
/**
* Tag to add to public subnets (Key=Value format)
* @default "Network=Public"
*/
readonly publicSubnetTag?: string;
/**
* Tag to add to primary private subnets (Key=Value format)
* @default "Network=Private"
*/
readonly privateSubnetATag?: string;
/**
* Tag to add to secondary private subnets with NACLs (Key=Value format)
* @default "Network=Private"
*/
readonly privateSubnetBTag?: string;
/**
* Required tags for the VPC and resources
*/
readonly tags: SpicyVpcTags;
/**
* Whether to create VPC endpoints for AWS services
* @default true
*/
readonly createVpcEndpoints?: boolean;
}
/**
* Default CIDR configurations matching the original CloudFormation template
*/
const DEFAULT_SUBNET_CIDRS: Required<SpicyVpcProps['subnetCidrs']> = {
a: {
publicCidr: '172.1.128.0/20',
private1Cidr: '172.1.0.0/19',
private2Cidr: '172.1.192.0/21',
},
b: {
publicCidr: '172.1.144.0/20',
private1Cidr: '172.1.32.0/19',
private2Cidr: '172.1.200.0/21',
},
c: {
publicCidr: '172.1.160.0/20',
private1Cidr: '172.1.64.0/19',
private2Cidr: '172.1.208.0/21',
},
d: {
publicCidr: '172.1.176.0/20',
private1Cidr: '172.1.96.0/19',
private2Cidr: '172.1.216.0/21',
},
};
/**
* SpicyVpc - A multi-AZ VPC construct with public and private subnets,
* NAT gateways, and optional VPC endpoints.
*
* This construct mirrors the functionality of the original CloudFormation
* template with explicit logical IDs for idempotent deployments.
*/
export class SpicyVpc extends Construct {
/** The VPC created by this construct */
public readonly vpc: ec2.IVpc;
/** Public subnets indexed by AZ letter (a, b, c, d) */
public readonly publicSubnets: Map<string, ec2.ISubnet> = new Map();
/** Primary private subnets indexed by AZ letter */
public readonly privateSubnets1: Map<string, ec2.ISubnet> = new Map();
/** Secondary private subnets (with NACLs) indexed by AZ letter */
public readonly privateSubnets2: Map<string, ec2.ISubnet> = new Map();
/** NAT Gateway EIPs indexed by AZ letter */
public readonly natEips: Map<string, ec2.CfnEIP> = new Map();
/** S3 Gateway Endpoint */
public readonly s3Endpoint?: ec2.GatewayVpcEndpoint;
constructor(scope: Construct, id: string, props: SpicyVpcProps) {
super(scope, id);
const vpcCidr = props.vpcCidr ?? '172.1.0.0/16';
const numberOfAzs = props.numberOfAzs ?? 4;
const createPrivateSubnets = props.createPrivateSubnets ?? true;
const createAdditionalPrivateSubnets = props.createAdditionalPrivateSubnets ?? true;
const createVpcEndpoints = props.createVpcEndpoints ?? true;
// Merge provided CIDRs with defaults
const subnetCidrs = {
...DEFAULT_SUBNET_CIDRS,
...props.subnetCidrs,
};
// Parse subnet tags
const publicSubnetTagParsed = this.parseTag(props.publicSubnetTag ?? 'Network=Public');
const privateSubnetATagParsed = this.parseTag(props.privateSubnetATag ?? 'Network=Private');
const privateSubnetBTagParsed = this.parseTag(props.privateSubnetBTag ?? 'Network=Private');
// Create the VPC using L1 constructs with explicit logical IDs
const cfnVpc = new ec2.CfnVPC(this, 'VPC', {
cidrBlock: vpcCidr,
enableDnsHostnames: true,
enableDnsSupport: true,
instanceTenancy: props.vpcTenancy ?? 'default',
tags: [
{ key: 'Name', value: cdk.Stack.of(this).stackName },
{ key: 'Owner', value: props.tags.owner },
{ key: 'Product', value: props.tags.product },
{ key: 'Component', value: props.tags.component },
...(props.tags.build ? [{ key: 'Build', value: props.tags.build }] : []),
],
});
((cfnVpc.node.defaultChild as cdk.CfnResource) ?? cfnVpc).overrideLogicalId('VPC');
// Create Internet Gateway
const igw = new ec2.CfnInternetGateway(this, 'InternetGateway', {
tags: [{ key: 'Name', value: cdk.Stack.of(this).stackName }],
});
((igw.node.defaultChild as cdk.CfnResource) ?? igw).overrideLogicalId('InternetGateway');
const vpcGatewayAttachment = new ec2.CfnVPCGatewayAttachment(this, 'VPCGatewayAttachment', {
vpcId: cfnVpc.ref,
internetGatewayId: igw.ref,
});
((vpcGatewayAttachment.node.defaultChild as cdk.CfnResource) ?? vpcGatewayAttachment).overrideLogicalId(
'VPCGatewayAttachment'
);
// DHCP Options
const dhcpOptions = new ec2.CfnDHCPOptions(this, 'DHCPOptions', {
domainName:
cdk.Stack.of(this).region === 'ca-central-1' ? 'ec2.internal' : `${cdk.Stack.of(this).region}.compute.internal`,
domainNameServers: ['AmazonProvidedDNS'],
});
((dhcpOptions.node.defaultChild as cdk.CfnResource) ?? dhcpOptions).overrideLogicalId('DHCPOptions');
const dhcpAssociation = new ec2.CfnVPCDHCPOptionsAssociation(this, 'VPCDHCPOptionsAssociation', {
vpcId: cfnVpc.ref,
dhcpOptionsId: dhcpOptions.ref,
});
((dhcpAssociation.node.defaultChild as cdk.CfnResource) ?? dhcpAssociation).overrideLogicalId(
'VPCDHCPOptionsAssociation'
);
// Public Route Table (shared across all public subnets)
const publicRouteTable = new ec2.CfnRouteTable(this, 'PublicSubnetRouteTable', {
vpcId: cfnVpc.ref,
tags: [
{ key: 'Name', value: 'Public Subnets' },
{ key: 'Network', value: 'Public' },
],
});
((publicRouteTable.node.defaultChild as cdk.CfnResource) ?? publicRouteTable).overrideLogicalId(
'PublicSubnetRouteTable'
);
const publicRoute = new ec2.CfnRoute(this, 'PublicSubnetRoute', {
routeTableId: publicRouteTable.ref,
destinationCidrBlock: '0.0.0.0/0',
gatewayId: igw.ref,
});
((publicRoute.node.defaultChild as cdk.CfnResource) ?? publicRoute).overrideLogicalId('PublicSubnetRoute');
// Get AZ letters based on numberOfAzs
const azLetters = ['a', 'b', 'c', 'd'].slice(0, numberOfAzs);
// Determine availability zones
const availabilityZones =
props.availabilityZones ?? azLetters.map((letter) => `${cdk.Stack.of(this).region}${letter}`);
// Track route tables for VPC endpoints
const privateRouteTables: ec2.CfnRouteTable[] = [];
// Track private subnet 1 route tables for VPC import
const privateSubnet1RouteTables: ec2.CfnRouteTable[] = [];
// Create subnets for each AZ
azLetters.forEach((azLetter, index) => {
const azName = availabilityZones[index];
const azUpper = azLetter.toUpperCase();
const cidrs = subnetCidrs[azLetter as keyof typeof subnetCidrs]!;
// Public Subnet
const publicSubnet = new ec2.CfnSubnet(this, `PublicSubnet${azUpper}`, {
vpcId: cfnVpc.ref,
cidrBlock: cidrs.publicCidr,
availabilityZone: azName,
mapPublicIpOnLaunch: true,
tags: [
{ key: 'Name', value: `Public Subnet ${azUpper}` },
...(publicSubnetTagParsed ? [{ key: publicSubnetTagParsed.key, value: publicSubnetTagParsed.value }] : []),
],
});
((publicSubnet.node.defaultChild as cdk.CfnResource) ?? publicSubnet).overrideLogicalId(`PublicSubnet${azUpper}`);
const publicSubnetRtAssoc = new ec2.CfnSubnetRouteTableAssociation(
this,
`PublicSubnet${azUpper}RouteTableAssociation`,
{
subnetId: publicSubnet.ref,
routeTableId: publicRouteTable.ref,
}
);
((publicSubnetRtAssoc.node.defaultChild as cdk.CfnResource) ?? publicSubnetRtAssoc).overrideLogicalId(
`PublicSubnet${azUpper}RouteTableAssociation`
);
// Store reference
this.publicSubnets.set(
azLetter,
ec2.Subnet.fromSubnetAttributes(this, `PublicSubnet${azUpper}Import`, {
subnetId: publicSubnet.ref,
availabilityZone: azName,
routeTableId: publicRouteTable.ref,
})
);
if (createPrivateSubnets) {
// NAT Gateway EIP
const natEip = new ec2.CfnEIP(this, `NATZone${azUpper}EIP`, {
domain: 'vpc',
});
natEip.addDependency(igw);
((natEip.node.defaultChild as cdk.CfnResource) ?? natEip).overrideLogicalId(`NATZone${azUpper}EIP`);
this.natEips.set(azLetter, natEip);
// NAT Gateway
const natGateway = new ec2.CfnNatGateway(this, `NATGatewayZone${azUpper}`, {
allocationId: natEip.attrAllocationId,
subnetId: publicSubnet.ref,
});
natGateway.addDependency(igw);
((natGateway.node.defaultChild as cdk.CfnResource) ?? natGateway).overrideLogicalId(`NATGatewayZone${azUpper}`);
// Private Subnet 1 Route Table
const privateRouteTable1 = new ec2.CfnRouteTable(this, `PrivateSubnet${azUpper}1RouteTable`, {
vpcId: cfnVpc.ref,
tags: [
{ key: 'Name', value: `Private Subnet ${azUpper}1` },
{ key: 'Network', value: 'Private' },
],
});
((privateRouteTable1.node.defaultChild as cdk.CfnResource) ?? privateRouteTable1).overrideLogicalId(
`PrivateSubnet${azUpper}1RouteTable`
);
privateRouteTables.push(privateRouteTable1);
privateSubnet1RouteTables.push(privateRouteTable1);
const privateRoute1 = new ec2.CfnRoute(this, `PrivateSubnet${azUpper}1Route`, {
routeTableId: privateRouteTable1.ref,
destinationCidrBlock: '0.0.0.0/0',
natGatewayId: natGateway.ref,
});
((privateRoute1.node.defaultChild as cdk.CfnResource) ?? privateRoute1).overrideLogicalId(
`PrivateSubnet${azUpper}1Route`
);
// Private Subnet 1
const privateSubnet1 = new ec2.CfnSubnet(this, `PrivateSubnet${azUpper}1`, {
vpcId: cfnVpc.ref,
cidrBlock: cidrs.private1Cidr,
availabilityZone: azName,
tags: [
{ key: 'Name', value: `Private Subnet ${azUpper}1` },
...(privateSubnetATagParsed
? [
{
key: privateSubnetATagParsed.key,
value: privateSubnetATagParsed.value,
},
]
: []),
],
});
((privateSubnet1.node.defaultChild as cdk.CfnResource) ?? privateSubnet1).overrideLogicalId(
`PrivateSubnet${azUpper}1`
);
const privateSubnet1RtAssoc = new ec2.CfnSubnetRouteTableAssociation(
this,
`PrivateSubnet${azUpper}1RouteTableAssociation`,
{
subnetId: privateSubnet1.ref,
routeTableId: privateRouteTable1.ref,
}
);
((privateSubnet1RtAssoc.node.defaultChild as cdk.CfnResource) ?? privateSubnet1RtAssoc).overrideLogicalId(
`PrivateSubnet${azUpper}1RouteTableAssociation`
);
this.privateSubnets1.set(
azLetter,
ec2.Subnet.fromSubnetAttributes(this, `PrivateSubnet${azUpper}1Import`, {
subnetId: privateSubnet1.ref,
availabilityZone: azName,
routeTableId: privateRouteTable1.ref,
})
);
// Private Subnet 2 (with NACL) - only if enabled
if (createAdditionalPrivateSubnets && cidrs.private2Cidr) {
// Private Subnet 2 Route Table
const privateRouteTable2 = new ec2.CfnRouteTable(this, `PrivateSubnet${azUpper}2RouteTable`, {
vpcId: cfnVpc.ref,
tags: [
{ key: 'Name', value: `Private Subnet ${azUpper}2` },
{ key: 'Network', value: 'Private' },
],
});
((privateRouteTable2.node.defaultChild as cdk.CfnResource) ?? privateRouteTable2).overrideLogicalId(
`PrivateSubnet${azUpper}2RouteTable`
);
privateRouteTables.push(privateRouteTable2);
const privateRoute2 = new ec2.CfnRoute(this, `PrivateSubnet${azUpper}2Route`, {
routeTableId: privateRouteTable2.ref,
destinationCidrBlock: '0.0.0.0/0',
natGatewayId: natGateway.ref,
});
((privateRoute2.node.defaultChild as cdk.CfnResource) ?? privateRoute2).overrideLogicalId(
`PrivateSubnet${azUpper}2Route`
);
// Private Subnet 2
const privateSubnet2 = new ec2.CfnSubnet(this, `PrivateSubnet${azUpper}2`, {
vpcId: cfnVpc.ref,
cidrBlock: cidrs.private2Cidr,
availabilityZone: azName,
tags: [
{ key: 'Name', value: `Private Subnet ${azUpper}2` },
...(privateSubnetBTagParsed
? [
{
key: privateSubnetBTagParsed.key,
value: privateSubnetBTagParsed.value,
},
]
: []),
],
});
((privateSubnet2.node.defaultChild as cdk.CfnResource) ?? privateSubnet2).overrideLogicalId(
`PrivateSubnet${azUpper}2`
);
const privateSubnet2RtAssoc = new ec2.CfnSubnetRouteTableAssociation(
this,
`PrivateSubnet${azUpper}2RouteTableAssociation`,
{
subnetId: privateSubnet2.ref,
routeTableId: privateRouteTable2.ref,
}
);
((privateSubnet2RtAssoc.node.defaultChild as cdk.CfnResource) ?? privateSubnet2RtAssoc).overrideLogicalId(
`PrivateSubnet${azUpper}2RouteTableAssociation`
);
// Network ACL for Private Subnet 2
const nacl = new ec2.CfnNetworkAcl(this, `PrivateSubnet${azUpper}2NetworkACL`, {
vpcId: cfnVpc.ref,
tags: [
{ key: 'Name', value: `NACL Protected subnet ${azUpper}` },
{ key: 'Network', value: 'NACL Protected' },
],
});
((nacl.node.defaultChild as cdk.CfnResource) ?? nacl).overrideLogicalId(`PrivateSubnet${azUpper}2NetworkACL`);
// Allow all inbound
const naclInbound = new ec2.CfnNetworkAclEntry(this, `PrivateSubnet${azUpper}2NetworkACLEntryInbound`, {
networkAclId: nacl.ref,
ruleNumber: 100,
protocol: -1,
ruleAction: 'allow',
egress: false,
cidrBlock: '0.0.0.0/0',
});
((naclInbound.node.defaultChild as cdk.CfnResource) ?? naclInbound).overrideLogicalId(
`PrivateSubnet${azUpper}2NetworkACLEntryInbound`
);
// Allow all outbound
const naclOutbound = new ec2.CfnNetworkAclEntry(this, `PrivateSubnet${azUpper}2NetworkACLEntryOutbound`, {
networkAclId: nacl.ref,
ruleNumber: 100,
protocol: -1,
ruleAction: 'allow',
egress: true,
cidrBlock: '0.0.0.0/0',
});
((naclOutbound.node.defaultChild as cdk.CfnResource) ?? naclOutbound).overrideLogicalId(
`PrivateSubnet${azUpper}2NetworkACLEntryOutbound`
);
const naclAssociation = new ec2.CfnSubnetNetworkAclAssociation(
this,
`PrivateSubnet${azUpper}2NetworkACLAssociation`,
{
subnetId: privateSubnet2.ref,
networkAclId: nacl.ref,
}
);
((naclAssociation.node.defaultChild as cdk.CfnResource) ?? naclAssociation).overrideLogicalId(
`PrivateSubnet${azUpper}2NetworkACLAssociation`
);
this.privateSubnets2.set(
azLetter,
ec2.Subnet.fromSubnetAttributes(this, `PrivateSubnet${azUpper}2Import`, {
subnetId: privateSubnet2.ref,
availabilityZone: azName,
routeTableId: privateRouteTable2.ref,
})
);
}
}
});
// Create VPC from the L1 construct for use with L2 constructs
// Build route table IDs arrays matching the subnet order
const publicSubnetRouteTableIds = Array.from(this.publicSubnets.values()).map(() => publicRouteTable.ref);
const privateSubnetRouteTableIds = privateSubnet1RouteTables.map((rt) => rt.ref);
this.vpc = ec2.Vpc.fromVpcAttributes(this, 'VpcImport', {
vpcId: cfnVpc.ref,
availabilityZones: availabilityZones,
publicSubnetIds: Array.from(this.publicSubnets.values()).map((s) => s.subnetId),
privateSubnetIds: Array.from(this.privateSubnets1.values()).map((s) => s.subnetId),
publicSubnetRouteTableIds: publicSubnetRouteTableIds,
privateSubnetRouteTableIds: privateSubnetRouteTableIds,
});
// S3 VPC Endpoint (Gateway type - free)
if (createPrivateSubnets && createVpcEndpoints) {
const s3Endpoint = new ec2.CfnVPCEndpoint(this, 'S3VPCEndpoint', {
vpcId: cfnVpc.ref,
serviceName: `com.amazonaws.${cdk.Stack.of(this).region}.s3`,
routeTableIds: privateRouteTables.map((rt) => rt.ref),
policyDocument: {
Version: '2012-10-17',
Statement: [
{
Action: '*',
Effect: 'Allow',
Resource: '*',
Principal: '*',
},
],
},
});
((s3Endpoint.node.defaultChild as cdk.CfnResource) ?? s3Endpoint).overrideLogicalId('S3VPCEndpoint');
}
// Add CloudFormation Outputs matching the original template
this.addOutputs(
cfnVpc,
azLetters,
publicRouteTable,
privateRouteTables,
createPrivateSubnets,
createAdditionalPrivateSubnets
);
}
/**
* Parse a tag string in "Key=Value" format
*/
private parseTag(tagString: string): { key: string; value: string } | null {
if (!tagString || !tagString.includes('=')) {
return null;
}
const [key, ...valueParts] = tagString.split('=');
return { key, value: valueParts.join('=') };
}
/**
* Add CloudFormation outputs for cross-stack references
* Output logical IDs match the original CloudFormation template
*/
private addOutputs(
cfnVpc: ec2.CfnVPC,
azLetters: string[],
publicRouteTable: ec2.CfnRouteTable,
privateRouteTables: ec2.CfnRouteTable[],
createPrivateSubnets: boolean,
createAdditionalPrivateSubnets: boolean
): void {
const stack = cdk.Stack.of(this);
// VPC outputs
const vpcIdOutput = new cdk.CfnOutput(this, 'VPCID', {
value: cfnVpc.ref,
description: 'VPC ID',
exportName: `${stack.stackName}-VPCID`,
});
vpcIdOutput.overrideLogicalId('VPCID');
const vpcCidrOutput = new cdk.CfnOutput(this, 'VPCCIDR', {
value: cfnVpc.cidrBlock!,
description: 'VPC CIDR',
exportName: `${stack.stackName}-VPCCIDR`,
});
vpcCidrOutput.overrideLogicalId('VPCCIDR');
// Number of AZs output (for importing by other stacks)
const numberOfAzsOutput = new cdk.CfnOutput(this, 'NumberOfAZs', {
value: azLetters.length.toString(),
description: 'Number of Availability Zones',
exportName: `${stack.stackName}-NumberOfAZs`,
});
numberOfAzsOutput.overrideLogicalId('NumberOfAZs');
// Public Route Table output
const publicRtOutput = new cdk.CfnOutput(this, 'PublicSubnetRouteTableOutput', {
value: publicRouteTable.ref,
description: 'Public Subnet Route Table',
exportName: `${stack.stackName}-PublicSubnetRouteTable`,
});
publicRtOutput.overrideLogicalId('PublicSubnetRouteTableOutput');
// Public subnet outputs
azLetters.forEach((azLetter) => {
const azUpper = azLetter.toUpperCase();
const subnet = this.publicSubnets.get(azLetter);
if (subnet) {
const output = new cdk.CfnOutput(this, `PublicSubnet${azUpper}ID`, {
value: subnet.subnetId,
description: `Public Subnet ${azUpper} ID`,
exportName: `${stack.stackName}-PublicSubnet${azUpper}ID`,
});
output.overrideLogicalId(`PublicSubnet${azUpper}ID`);
}
});
// Private subnet outputs
if (createPrivateSubnets) {
azLetters.forEach((azLetter, index) => {
const azUpper = azLetter.toUpperCase();
// Private Subnet 1
const subnet1 = this.privateSubnets1.get(azLetter);
if (subnet1) {
const output = new cdk.CfnOutput(this, `PrivateSubnet${azUpper}1ID`, {
value: subnet1.subnetId,
description: `Private Subnet 1 ID in Availability Zone ${azUpper}`,
exportName: `${stack.stackName}-PrivateSubnet${azUpper}1ID`,
});
output.overrideLogicalId(`PrivateSubnet${azUpper}1ID`);
}
// NAT EIP Output
const natEip = this.natEips.get(azLetter);
if (natEip) {
const output = new cdk.CfnOutput(this, `NATZone${azUpper}EIPOutput`, {
value: natEip.ref,
description: `NAT ${azUpper} IP address`,
exportName: `${stack.stackName}-NATZone${azUpper}EIP`,
});
output.overrideLogicalId(`NATZone${azUpper}EIPOutput`);
}
// Private Route Table 1 Output
const rtIndex = index * (createAdditionalPrivateSubnets ? 2 : 1);
if (privateRouteTables[rtIndex]) {
const output = new cdk.CfnOutput(this, `PrivateSubnet${azUpper}1RouteTableOutput`, {
value: privateRouteTables[rtIndex].ref,
description: `Private Subnet ${azUpper}1 Route Table`,
exportName: `${stack.stackName}-PrivateSubnet${azUpper}1RouteTable`,
});
output.overrideLogicalId(`PrivateSubnet${azUpper}1RouteTableOutput`);
}
// Private Subnet 2
if (createAdditionalPrivateSubnets) {
const subnet2 = this.privateSubnets2.get(azLetter);
if (subnet2) {
const output = new cdk.CfnOutput(this, `PrivateSubnet${azUpper}2ID`, {
value: subnet2.subnetId,
description: `Private Subnet 2 ID in Availability Zone ${azUpper}`,
exportName: `${stack.stackName}-PrivateSubnet${azUpper}2ID`,
});
output.overrideLogicalId(`PrivateSubnet${azUpper}2ID`);
}
// Private Route Table 2 Output
if (privateRouteTables[rtIndex + 1]) {
const output = new cdk.CfnOutput(this, `PrivateSubnet${azUpper}2RouteTableOutput`, {
value: privateRouteTables[rtIndex + 1].ref,
description: `Private Subnet ${azUpper}2 Route Table`,
exportName: `${stack.stackName}-PrivateSubnet${azUpper}2RouteTable`,
});
output.overrideLogicalId(`PrivateSubnet${azUpper}2RouteTableOutput`);
}
}
});
}
}
}

5
resources/lib/index.ts Normal file
View File

@@ -0,0 +1,5 @@
// Main entry point for spicy-automation library
// Export all constructs and stacks for use as a library
export * from './constructs';
export * from './stacks';

View File

@@ -0,0 +1,6 @@
// Export all stacks
export * from './spicy-vpc-stack';
export * from './spicy-ecs-cluster-stack';
export * from './spicy-ecs-service-stack';
export * from './spicy-alb-stack';

View File

@@ -0,0 +1,210 @@
import * as ec2 from 'aws-cdk-lib/aws-ec2';
import * as s3 from 'aws-cdk-lib/aws-s3';
import * as cdk from 'aws-cdk-lib/core';
import { Construct } from 'constructs';
import { PersistentAlbBlueGreenDnsConfig, SpicyAlb, SpicyAlbTags } from '../constructs/spicy-alb';
/**
* Props for SpicyAlbStack
*/
export interface SpicyAlbStackProps extends Omit<cdk.StackProps, 'tags'> {
/** VPC ID */
readonly vpcId: string;
/** VPC CIDR block */
readonly vpcCidrBlock?: string;
/** Number of availability zones (2-4) */
readonly numberOfAzs: number;
/** Availability zones */
readonly availabilityZones: string[];
/** Subnet IDs for the ALB */
readonly subnetIds: string[];
/** ALB scheme: "internet-facing" or "internal" */
readonly scheme: 'internet-facing' | 'internal';
/** SSL certificate ARN for HTTPS listener */
readonly certificateArn?: string;
/** Idle timeout in seconds */
readonly idleTimeout?: number;
/** S3 bucket name for ALB access logs */
readonly logsBucketName?: string;
/** S3 prefix for ALB access logs */
readonly logsPrefix?: string;
/** Enable HTTP→HTTPS redirect */
readonly redirectHttpToHttps?: boolean;
/** Blue/Green DNS configuration */
readonly blueGreenDns?: PersistentAlbBlueGreenDnsConfig;
/** Required tags */
readonly tags: SpicyAlbTags;
}
/**
* Stack for deploying a persistent ALB (bg-common pattern)
*
* This stack should be deployed once and kept running. The ALB DNS name
* will remain constant even when service stacks are deleted and recreated.
*/
export class SpicyAlbStack extends cdk.Stack {
public readonly alb: SpicyAlb;
constructor(scope: Construct, id: string, props: SpicyAlbStackProps) {
const { tags, ...stackProps } = props;
super(scope, id, stackProps);
// Import VPC (using fromVpcAttributes to avoid requiring AWS credentials for synth)
// For ALB, we need to provide subnet IDs - use the subnets we're deploying the ALB to
// For internal ALB, these are typically private subnets; for internet-facing, they're public
const vpc = ec2.Vpc.fromVpcAttributes(this, 'ImportedVpc', {
vpcId: props.vpcId,
availabilityZones: props.availabilityZones,
// Provide subnet IDs based on scheme (internal = private, internet-facing = public)
// We provide them in both arrays to satisfy CDK's requirements, but only the relevant ones are used
...(props.scheme === 'internal' ? { privateSubnetIds: props.subnetIds } : { publicSubnetIds: props.subnetIds }),
});
// Create subnet references
const subnets = props.subnetIds.map((subnetId, index) =>
ec2.Subnet.fromSubnetAttributes(this, `Subnet${index}`, {
subnetId,
availabilityZone: props.availabilityZones[index % props.availabilityZones.length],
})
);
// Create logs bucket if specified
let logsBucket: s3.IBucket | undefined;
if (props.logsBucketName) {
logsBucket = s3.Bucket.fromBucketName(this, 'LogsBucket', props.logsBucketName);
}
// Create ALB construct
this.alb = new SpicyAlb(this, 'ALB', {
vpc,
vpcCidrBlock: props.vpcCidrBlock,
scheme: props.scheme,
subnets,
availabilityZones: props.availabilityZones,
certificateArn: props.certificateArn,
idleTimeout: props.idleTimeout,
logsBucket,
logsPrefix: props.logsPrefix,
redirectHttpToHttps: props.redirectHttpToHttps,
blueGreenDns: props.blueGreenDns,
tags: props.tags,
});
}
/**
* Create stack from CDK context
* Only requires clusterStackName - VPC stack name imported from cluster export, then all VPC details imported from VPC stack
*/
static fromContext(scope: Construct, id: string, stackProps?: cdk.StackProps): SpicyAlbStack {
const app = scope.node.root as cdk.App;
if (!app) {
throw new Error('SpicyAlbStack.fromContext must be called from within a CDK App');
}
// Required: clusterStackName (VPC stack name imported from cluster export, then VPC details imported from VPC stack)
const clusterStackName = app.node.tryGetContext('clusterStackName') || app.node.tryGetContext('clusterName');
if (!clusterStackName) {
throw new Error(
'clusterStackName is required. Provide clusterStackName to import VPC stack name from cluster export, then import VPC details from VPC stack.'
);
}
// Import VPC stack name from cluster stack (cluster exports: ${clusterStackName}-VPCStackName)
const vpcStackName = cdk.Fn.importValue(`${clusterStackName}-VPCStackName`).toString();
// Import VPC ID from cluster stack (cluster exports: ${clusterStackName}-VPC)
const vpcId = cdk.Fn.importValue(`${clusterStackName}-VPC`);
// Import all VPC details from VPC stack exports
const vpcCidrBlock = cdk.Fn.importValue(`${vpcStackName}-VPCCIDR`).toString();
const numberOfAzsRaw = app.node.tryGetContext('numberOfAzs');
const numberOfAzs = numberOfAzsRaw ? parseInt(numberOfAzsRaw, 10) : NaN;
if (!numberOfAzs || Number.isNaN(numberOfAzs)) {
throw new Error('numberOfAzs is required in context (2-4) to import VPC subnets for ALB.');
}
const azs = ['A', 'B', 'C', 'D'].slice(0, Math.min(Math.max(numberOfAzs, 1), 4));
// Import subnets based on scheme (public for internet-facing, private for internal)
const scheme = app.node.tryGetContext('scheme') ?? 'internet-facing';
const subnetIds = azs.map((az) => {
if (scheme === 'internet-facing') {
return cdk.Fn.importValue(`${vpcStackName}-PublicSubnet${az}ID`).toString();
} else {
return cdk.Fn.importValue(`${vpcStackName}-PrivateSubnet${az}1ID`).toString();
}
});
// Derive availability zones from region
const region = cdk.Stack.of(scope).region;
const availabilityZones = azs.map((az) => `${region}${az.toLowerCase()}`);
const certificateArn = app.node.tryGetContext('certificateArn');
const idleTimeout = app.node.tryGetContext('idleTimeout')
? parseInt(app.node.tryGetContext('idleTimeout'))
: undefined;
const logsBucketName = app.node.tryGetContext('logsBucketName');
const logsPrefix = app.node.tryGetContext('logsPrefix');
const redirectHttpToHttps = app.node.tryGetContext('redirectHttpToHttps') !== 'false';
// Blue/Green DNS
const activeHostname = app.node.tryGetContext('activeHostname');
const inactiveHostname = app.node.tryGetContext('inactiveHostname');
const bgHostedZoneId = app.node.tryGetContext('bgHostedZoneId');
let blueGreenDns: PersistentAlbBlueGreenDnsConfig | undefined;
if (activeHostname && inactiveHostname && bgHostedZoneId) {
blueGreenDns = {
activeHostname,
inactiveHostname,
hostedZoneId: bgHostedZoneId,
};
}
// Tags
const ownerTag = app.node.tryGetContext('ownerTag');
const productTag = app.node.tryGetContext('productTag');
const componentTag = app.node.tryGetContext('componentTag');
const environmentTag = app.node.tryGetContext('environmentTag') ?? app.node.tryGetContext('environment');
const buildTag = app.node.tryGetContext('buildTag') ?? app.node.tryGetContext('build');
if (!ownerTag || !productTag || !componentTag) {
throw new Error('ownerTag, productTag, and componentTag context are required');
}
const tags: SpicyAlbTags = {
owner: ownerTag,
product: productTag,
component: componentTag,
environment: environmentTag ?? 'dev',
build: buildTag,
};
return new SpicyAlbStack(scope, id, {
...stackProps,
vpcId: vpcId.toString(),
vpcCidrBlock,
numberOfAzs,
availabilityZones,
subnetIds,
scheme: scheme as 'internet-facing' | 'internal',
certificateArn,
idleTimeout,
logsBucketName,
logsPrefix,
redirectHttpToHttps,
blueGreenDns,
tags: tags,
});
}
}

View File

@@ -0,0 +1,332 @@
import * as ec2 from 'aws-cdk-lib/aws-ec2';
import * as cdk from 'aws-cdk-lib/core';
import { Construct } from 'constructs';
import {
ClusterScalingConfig,
LoadBalancerConfig,
SpicyEcsCluster,
SpicyEcsClusterTags,
SpotConfig,
} from '../constructs/spicy-ecs-cluster';
/**
* Props for SpicyEcsClusterStack
*/
export interface SpicyEcsClusterStackProps extends cdk.StackProps {
/**
* VPC stack name to import VPC details from (required)
* All VPC details (VPC ID, CIDR, subnets, AZs) will be imported from VPC stack exports
*/
readonly vpcStackName: string;
/**
* Number of availability zones (2-4) used by the VPC
* Must match the VPC stack export to correctly import subnet IDs.
*/
readonly numberOfAzs: number;
/**
* EC2 instance type
* @default m5a.large
*/
readonly instanceType?: string;
/**
* Additional instance types for mixed instances policy
*/
readonly additionalInstanceTypes?: string[];
/**
* EC2 Key Pair name
*/
readonly keyName?: string;
/**
* EBS volume size in GB
* @default 100
*/
readonly ebsVolumeSize?: number;
/**
* Enable Container Insights
* @default true
*/
readonly containerInsights?: boolean;
/**
* Minimum cluster size
* @default 2
*/
readonly minClusterSize?: number;
/**
* Maximum cluster size
* @default 4
*/
readonly maxClusterSize?: number;
/**
* Target capacity utilization percentage
* @default 100
*/
readonly targetCapacityPercent?: number;
/**
* Enable Spot instances
* @default false
*/
readonly spotEnabled?: boolean;
/**
* On-Demand percentage when Spot is enabled
* @default 100
*/
readonly onDemandPercentage?: number;
/**
* Spot allocation strategy
* @default capacity-optimized
*/
readonly spotAllocationStrategy?: 'lowest-price' | 'capacity-optimized' | 'capacity-optimized-prioritized';
/**
* Create external (internet-facing) load balancer
* @default false
*/
readonly createExternalLoadBalancer?: boolean;
/**
* Create internal load balancer
* @default false
*/
readonly createInternalLoadBalancer?: boolean;
/**
* SSL certificate ARN for HTTPS
*/
readonly certificateArn?: string;
/**
* Enable Fargate capacity providers (adds both FARGATE and FARGATE_SPOT)
* @default false
*/
readonly enableFargate?: boolean;
/**
* Draining timeout in seconds
* @default 900
*/
readonly drainingTimeout?: number;
/**
* Max instance lifetime in seconds
* @default 604800 (7 days)
*/
readonly maxInstanceLifetime?: number;
/**
* Required cluster tags
*/
readonly clusterTags: SpicyEcsClusterTags;
}
/**
* Stack for deploying a SpicyEcsCluster
*/
export class SpicyEcsClusterStack extends cdk.Stack {
public readonly cluster: SpicyEcsCluster;
constructor(scope: Construct, id: string, props: SpicyEcsClusterStackProps) {
super(scope, id, props);
// Validate required inputs
if (!props.vpcStackName) {
throw new Error('vpcStackName is required for ECS Cluster stack.');
}
// Import all VPC details from VPC stack exports
// VPC stack exports: ${vpcStackName}-VPCID, ${vpcStackName}-VPCCIDR, ${vpcStackName}-NumberOfAZs, etc.
const vpcId = cdk.Fn.importValue(`${props.vpcStackName}-VPCID`);
const vpcCidrBlock = cdk.Fn.importValue(`${props.vpcStackName}-VPCCIDR`).toString();
if (!props.numberOfAzs || Number.isNaN(props.numberOfAzs)) {
throw new Error('numberOfAzs is required and must be a number (2-4) to import subnets.');
}
const azs = ['A', 'B', 'C', 'D'].slice(0, Math.min(Math.max(props.numberOfAzs, 1), 4));
// Import private subnet IDs (always needed for ECS instances)
const privateSubnetIds = azs.map((az) =>
cdk.Fn.importValue(`${props.vpcStackName}-PrivateSubnet${az}1ID`).toString()
);
// Import public subnet IDs if external load balancer is requested
let publicSubnetIds: string[] | undefined;
if (props.createExternalLoadBalancer) {
publicSubnetIds = azs.map((az) => cdk.Fn.importValue(`${props.vpcStackName}-PublicSubnet${az}ID`).toString());
}
// Derive availability zones from region
const region = cdk.Stack.of(this).region;
const availabilityZones = azs.map((az) => `${region}${az.toLowerCase()}`);
// Build VPC attributes for importing
const vpcAttributes: ec2.VpcAttributes = {
vpcId: vpcId.toString(),
availabilityZones,
privateSubnetIds,
...(publicSubnetIds && publicSubnetIds.length > 0 ? { publicSubnetIds } : {}),
};
const vpc = ec2.Vpc.fromVpcAttributes(this, 'ImportedVpc', vpcAttributes);
// Parse instance types
const instanceType = props.instanceType
? new ec2.InstanceType(props.instanceType)
: ec2.InstanceType.of(ec2.InstanceClass.M5A, ec2.InstanceSize.LARGE);
const additionalInstanceTypes = props.additionalInstanceTypes?.map((t) => new ec2.InstanceType(t));
// Build scaling config
const scaling: ClusterScalingConfig = {
minCapacity: props.minClusterSize ?? 2,
maxCapacity: props.maxClusterSize ?? 4,
targetCapacityPercent: props.targetCapacityPercent ?? 100,
};
// Build spot config
const spot: SpotConfig = {
enabled: props.spotEnabled ?? false,
onDemandPercentage: props.onDemandPercentage ?? 100,
spotAllocationStrategy: props.spotAllocationStrategy ?? 'capacity-optimized',
};
// Build load balancer config
const loadBalancer: LoadBalancerConfig = {
createExternal: props.createExternalLoadBalancer ?? false,
createInternal: props.createInternalLoadBalancer ?? false,
certificateArn: props.certificateArn,
};
// Create the cluster
this.cluster = new SpicyEcsCluster(this, 'EcsCluster', {
vpc,
vpcCidrBlock,
vpcStackName: props.vpcStackName,
instanceType,
additionalInstanceTypes,
keyName: props.keyName,
ebsVolumeSize: props.ebsVolumeSize,
containerInsights: props.containerInsights,
scaling,
spot,
loadBalancer,
enableFargate: props.enableFargate,
drainingTimeout: props.drainingTimeout,
maxInstanceLifetime: props.maxInstanceLifetime,
tags: props.clusterTags,
instanceSubnets: { subnetType: ec2.SubnetType.PRIVATE_WITH_EGRESS },
externalSubnets: publicSubnetIds ? { subnetType: ec2.SubnetType.PUBLIC } : undefined,
internalSubnets: { subnetType: ec2.SubnetType.PRIVATE_WITH_EGRESS },
});
}
/**
* Create stack from CDK context
* Only requires vpcStackName - all VPC details are imported from VPC stack exports
*/
static fromContext(scope: Construct, id: string, stackProps?: cdk.StackProps): SpicyEcsClusterStack {
const app = scope.node.root as cdk.App;
// Required: VPC stack name (all VPC details imported from exports)
const vpcStackName = app.node.tryGetContext('vpcStackName');
if (!vpcStackName) {
throw new Error(
'vpcStackName is required. Provide vpcStackName to import all VPC details from VPC stack exports.'
);
}
const numberOfAzsRaw = app.node.tryGetContext('numberOfAzs');
const numberOfAzs = numberOfAzsRaw ? parseInt(numberOfAzsRaw, 10) : NaN;
if (!numberOfAzs || Number.isNaN(numberOfAzs)) {
throw new Error('numberOfAzs is required in context (2-4) to import subnets from the VPC stack.');
}
// Tags
const tags: SpicyEcsClusterTags = {
owner: app.node.tryGetContext('ownerTag') ?? 'Unknown',
product: app.node.tryGetContext('productTag') ?? 'Unknown',
component: app.node.tryGetContext('componentTag') ?? 'ecs-cluster',
environment: app.node.tryGetContext('environment') ?? 'dev',
build: app.node.tryGetContext('build'),
};
// Optional context values
const instanceType = app.node.tryGetContext('instanceType');
const additionalInstanceTypes = app.node.tryGetContext('additionalInstanceTypes')?.split(',');
const keyName = app.node.tryGetContext('keyName');
const ebsVolumeSize = app.node.tryGetContext('ebsVolumeSize')
? parseInt(app.node.tryGetContext('ebsVolumeSize'))
: undefined;
const containerInsights = app.node.tryGetContext('containerInsights') !== 'false';
// Scaling
const minClusterSize = app.node.tryGetContext('minClusterSize')
? parseInt(app.node.tryGetContext('minClusterSize'))
: undefined;
const maxClusterSize = app.node.tryGetContext('maxClusterSize')
? parseInt(app.node.tryGetContext('maxClusterSize'))
: undefined;
const targetCapacityPercent = app.node.tryGetContext('targetCapacityPercent')
? parseInt(app.node.tryGetContext('targetCapacityPercent'))
: undefined;
// Spot
const spotEnabled = app.node.tryGetContext('spotEnabled') === 'true';
const onDemandPercentage = app.node.tryGetContext('onDemandPercentage')
? parseInt(app.node.tryGetContext('onDemandPercentage'))
: undefined;
const spotAllocationStrategy = app.node.tryGetContext('spotAllocationStrategy') as
| 'lowest-price'
| 'capacity-optimized'
| 'capacity-optimized-prioritized'
| undefined;
// Load balancers
const createExternalLoadBalancer = app.node.tryGetContext('createExternalLoadBalancer') === 'true';
const createInternalLoadBalancer = app.node.tryGetContext('createInternalLoadBalancer') === 'true';
const certificateArn = app.node.tryGetContext('certificateArn');
// Fargate
const enableFargate = app.node.tryGetContext('enableFargate') === 'true';
// Timeouts
const drainingTimeout = app.node.tryGetContext('drainingTimeout')
? parseInt(app.node.tryGetContext('drainingTimeout'))
: undefined;
const maxInstanceLifetime = app.node.tryGetContext('maxInstanceLifetime')
? parseInt(app.node.tryGetContext('maxInstanceLifetime'))
: undefined;
return new SpicyEcsClusterStack(scope, id, {
...stackProps,
vpcStackName,
instanceType,
additionalInstanceTypes,
keyName,
ebsVolumeSize,
containerInsights,
minClusterSize,
maxClusterSize,
targetCapacityPercent,
spotEnabled,
onDemandPercentage,
spotAllocationStrategy,
createExternalLoadBalancer,
createInternalLoadBalancer,
certificateArn,
enableFargate,
drainingTimeout,
maxInstanceLifetime,
numberOfAzs,
clusterTags: tags,
});
}
}

View File

@@ -0,0 +1,467 @@
import * as ec2 from 'aws-cdk-lib/aws-ec2';
import * as ecs from 'aws-cdk-lib/aws-ecs';
import * as cdk from 'aws-cdk-lib/core';
import { Construct } from 'constructs';
import {
CapacityProviderStrategyItem,
ContainerConfig,
DeploymentConfig,
DnsConfig,
HealthCheckConfig,
LoadBalancerRoutingConfig,
ServiceScalingConfig,
SpicyEcsService,
SpicyEcsServiceTags,
} from '../constructs/spicy-ecs-service';
/**
* Props for SpicyEcsServiceStack
*/
export interface SpicyEcsServiceStackProps extends cdk.StackProps {
/** ECS Cluster name to import */
readonly clusterName: string;
/** VPC ID */
readonly vpcId: string;
/** VPC CIDR block */
readonly vpcCidrBlock?: string;
/** Number of availability zones (2-4) */
readonly numberOfAzs: number;
/** Availability zones */
readonly availabilityZones: string[];
/** Private subnet IDs */
readonly privateSubnetIds: string[];
/** Service name */
readonly serviceName: string;
/** Docker image URI */
readonly image: string;
/** Container port */
readonly containerPort: number;
/** CPU units */
readonly cpu?: number;
/** Memory in MiB */
readonly memory?: number;
/** Environment variables (JSON string) */
readonly environment?: string;
/** Secrets (JSON string: { "ENV_VAR": "secret-arn" }) */
readonly secrets?: string;
/** Desired task count */
readonly desiredCount?: number;
/** Capacity provider strategy (JSON string) */
readonly capacityProviderStrategy?: string;
/** Scaling configuration */
readonly scaling?: ServiceScalingConfig;
/** Health check path */
readonly healthCheckPath?: string;
/** Routing configuration */
readonly routing?: LoadBalancerRoutingConfig;
/** External ALB listener ARN (for cluster ALB mode) */
readonly externalListenerArn?: string;
/** Internal ALB listener ARN (for cluster ALB mode) */
readonly internalListenerArn?: string;
/** Public subnet IDs (for individual ALB) */
readonly publicSubnetIds?: string[];
/** Cluster logs bucket name (for individual ALB access logs) */
readonly clusterLogsBucketName?: string;
/** DNS configuration */
readonly dns?: DnsConfig;
/** Deployment configuration */
readonly deployment?: DeploymentConfig;
/** Service tags */
readonly serviceTags: SpicyEcsServiceTags;
}
/**
* Stack for deploying an ECS Service
*/
export class SpicyEcsServiceStack extends cdk.Stack {
public readonly service: SpicyEcsService;
constructor(scope: Construct, id: string, props: SpicyEcsServiceStackProps) {
super(scope, id, props);
// Import VPC
const vpc = ec2.Vpc.fromVpcAttributes(this, 'ImportedVpc', {
vpcId: props.vpcId,
availabilityZones: props.availabilityZones,
privateSubnetIds: props.privateSubnetIds,
...(props.publicSubnetIds && { publicSubnetIds: props.publicSubnetIds }),
});
// Import Cluster
const cluster = ecs.Cluster.fromClusterAttributes(this, 'ImportedCluster', {
clusterName: props.clusterName,
vpc,
securityGroups: [],
});
// Parse JSON configs
const environment = props.environment ? JSON.parse(props.environment) : undefined;
const secrets = props.secrets ? JSON.parse(props.secrets) : undefined;
const capacityProviderStrategy: CapacityProviderStrategyItem[] | undefined = props.capacityProviderStrategy
? JSON.parse(props.capacityProviderStrategy)
: undefined;
// Build container config
const container: ContainerConfig = {
image: props.image,
port: props.containerPort,
cpu: props.cpu,
memory: props.memory,
environment,
secrets,
};
// Build health check config
const healthCheck: HealthCheckConfig | undefined = props.healthCheckPath
? { path: props.healthCheckPath }
: undefined;
// Create the service
this.service = new SpicyEcsService(this, 'EcsService', {
cluster,
vpc,
serviceName: props.serviceName,
container,
capacityProviderStrategy,
desiredCount: props.desiredCount,
scaling: props.scaling,
healthCheck,
routing: props.routing,
externalListenerArn: props.externalListenerArn,
internalListenerArn: props.internalListenerArn,
dns: props.dns,
deployment: props.deployment,
tags: props.serviceTags,
});
}
/**
* Create stack from CDK context
* Only requires clusterStackName - VPC stack name imported from cluster export, then all VPC details imported from VPC stack
*/
static fromContext(scope: Construct, id: string, stackProps?: cdk.StackProps): SpicyEcsServiceStack {
const app = scope.node.root as cdk.App;
// Required: clusterStackName (VPC stack name imported from cluster export, then VPC details imported from VPC stack)
const clusterStackName = app.node.tryGetContext('clusterStackName') || app.node.tryGetContext('clusterName');
if (!clusterStackName) {
throw new Error(
'clusterStackName is required. Provide clusterStackName to import VPC stack name from cluster export, then import VPC details from VPC stack.'
);
}
// Required service config
const serviceName = app.node.tryGetContext('serviceName');
const image = app.node.tryGetContext('image');
const containerPort = parseInt(app.node.tryGetContext('containerPort') ?? '3000');
// Import VPC stack name from cluster stack (cluster exports: ${clusterStackName}-VPCStackName)
const vpcStackName = cdk.Fn.importValue(`${clusterStackName}-VPCStackName`).toString();
// Import VPC ID from cluster stack (cluster exports: ${clusterStackName}-VPC)
const vpcId = cdk.Fn.importValue(`${clusterStackName}-VPC`);
// Import all VPC details from VPC stack exports
const vpcCidrBlock = cdk.Fn.importValue(`${vpcStackName}-VPCCIDR`).toString();
const numberOfAzsRaw = app.node.tryGetContext('numberOfAzs');
const numberOfAzs = numberOfAzsRaw ? parseInt(numberOfAzsRaw, 10) : NaN;
if (!numberOfAzs || Number.isNaN(numberOfAzs)) {
throw new Error('numberOfAzs is required in context (2-4) to import VPC subnets.');
}
const azs = ['A', 'B', 'C', 'D'].slice(0, Math.min(Math.max(numberOfAzs, 1), 4));
// Import private subnet IDs from VPC stack
const privateSubnetIds = azs.map((az) => cdk.Fn.importValue(`${vpcStackName}-PrivateSubnet${az}1ID`).toString());
// Derive availability zones from region
const region = cdk.Stack.of(scope).region;
const availabilityZones = azs.map((az) => `${region}${az.toLowerCase()}`);
// ALB stack name is derived from service stackName: ${baseStackName}-alb
// Remove -blue/-green suffix and add -alb
const serviceStackName = app.node.tryGetContext('stackName') || id;
const baseStackName = serviceStackName.replace(/-blue$|-green$/, '');
const albStackName = `${baseStackName}-alb`;
// Tags
const serviceTags: SpicyEcsServiceTags = {
owner: app.node.tryGetContext('ownerTag') ?? 'Unknown',
product: app.node.tryGetContext('productTag') ?? 'Unknown',
component: app.node.tryGetContext('componentTag') ?? serviceName,
environment: app.node.tryGetContext('environment') ?? 'dev',
build: app.node.tryGetContext('build'),
};
// Optional container config
const cpu = app.node.tryGetContext('cpu') ? parseInt(app.node.tryGetContext('cpu')) : undefined;
const memory = app.node.tryGetContext('memory') ? parseInt(app.node.tryGetContext('memory')) : undefined;
const environment = app.node.tryGetContext('environment_vars'); // JSON string
const secrets = app.node.tryGetContext('secrets'); // JSON string
const desiredCount = app.node.tryGetContext('desiredCount')
? parseInt(app.node.tryGetContext('desiredCount'))
: undefined;
// Capacity provider strategy (JSON string)
const capacityProviderStrategy = app.node.tryGetContext('capacityProviderStrategy');
// Scaling
const minCapacity = app.node.tryGetContext('minCapacity')
? parseInt(app.node.tryGetContext('minCapacity'))
: undefined;
const maxCapacity = app.node.tryGetContext('maxCapacity')
? parseInt(app.node.tryGetContext('maxCapacity'))
: undefined;
const targetCpuUtilization = app.node.tryGetContext('targetCpuUtilization')
? parseInt(app.node.tryGetContext('targetCpuUtilization'))
: undefined;
const targetMemoryUtilization = app.node.tryGetContext('targetMemoryUtilization')
? parseInt(app.node.tryGetContext('targetMemoryUtilization'))
: undefined;
const targetRequestsPerTarget = app.node.tryGetContext('targetRequestsPerTarget')
? parseInt(app.node.tryGetContext('targetRequestsPerTarget'))
: undefined;
const scaling: ServiceScalingConfig | undefined =
minCapacity && maxCapacity
? {
minCapacity,
maxCapacity,
targetCpuUtilization,
targetMemoryUtilization,
targetRequestsPerTarget,
}
: undefined;
// Health check
const healthCheckPath = app.node.tryGetContext('healthCheckPath');
// Routing - ALB details (resolved by pipeline from cluster or ALB stack)
// Pipeline sets: albLoadBalancerArn, albHttpsListenerArn, albHttpListenerArn in context
// If not provided in context, fall back to importing from exports (backward compatibility)
const useClusterAlb = app.node.tryGetContext('useClusterAlb') !== 'false'; // default true
let existingAlbArn: string | undefined = app.node.tryGetContext('albLoadBalancerArn');
let existingAlbHttpsListenerArn: string | undefined = app.node.tryGetContext('albHttpsListenerArn');
let existingAlbHttpListenerArn: string | undefined = app.node.tryGetContext('albHttpListenerArn');
// If not provided in context, import from exports (backward compatibility)
if (!existingAlbArn && !useClusterAlb) {
const albScheme = app.node.tryGetContext('albScheme') || 'internet-facing';
const prefix = albScheme === 'internet-facing' ? 'internet-facing' : 'internal';
existingAlbArn = cdk.Fn.importValue(`${albStackName}-${prefix}-arn`).toString();
existingAlbHttpsListenerArn = cdk.Fn.importValue(`${albStackName}-${prefix}-https-listener`).toString();
existingAlbHttpListenerArn = cdk.Fn.importValue(`${albStackName}-${prefix}-http-listener`).toString();
}
// ALB listeners (for cluster ALB mode) - import from cluster stack (backward compatibility)
// Cluster exports: ${clusterStackName}-internet-facing-https-listener, ${clusterStackName}-internal-https-listener
let externalListenerArn = app.node.tryGetContext('externalListenerArn');
let internalListenerArn = app.node.tryGetContext('internalListenerArn');
// Auto-import listener ARNs from cluster stack if clusterStackName provided and listeners not explicitly set
// This matches the old pattern: Fn::ImportValue: !Sub "${ClusterName}-internet-facing-https-listener"
// Only used if albLoadBalancerArn is not provided (backward compatibility)
if (!existingAlbArn && clusterStackName && !externalListenerArn && !internalListenerArn) {
const useExternal = app.node.tryGetContext('useExternalALB') === 'true';
const useInternal = app.node.tryGetContext('useInternalALB') === 'true';
const albScheme =
app.node.tryGetContext('albScheme') ||
(useExternal ? 'internet-facing' : useInternal ? 'internal' : 'internet-facing');
// Try HTTPS listener first (most common), fall back to HTTP if needed
if (albScheme === 'internet-facing' || useExternal) {
externalListenerArn = cdk.Fn.importValue(`${clusterStackName}-internet-facing-https-listener`).toString();
} else if (albScheme === 'internal' || useInternal) {
internalListenerArn = cdk.Fn.importValue(`${clusterStackName}-internal-https-listener`).toString();
}
}
const hostHeader = app.node.tryGetContext('hostHeader');
const pathPatterns = app.node.tryGetContext('pathPatterns')?.split(',');
const priority = app.node.tryGetContext('priority') ? parseInt(app.node.tryGetContext('priority')) : undefined;
const useExternal = app.node.tryGetContext('useExternalALB') === 'true';
const useInternal = app.node.tryGetContext('useInternalALB') === 'true';
let routing: LoadBalancerRoutingConfig | undefined;
// Use unified ALB parameters if provided (from pipeline resolveAlbDetails)
// Otherwise fall back to cluster ALB mode (backward compatibility)
const useBgCommonAlb = existingAlbArn != null;
if (useBgCommonAlb) {
// ALB details are provided by pipeline (resolved from cluster or ALB stack)
// Pipeline sets: albLoadBalancerArn, albHttpsListenerArn, albHttpListenerArn
if (!hostHeader && (!pathPatterns || pathPatterns.length === 0)) {
throw new Error('When using ALB, either hostHeader or pathPatterns must be provided for routing');
}
// Use unified ALB parameters (from cluster or ALB stack, resolved by pipeline)
routing = {
existingALB: existingAlbArn!, // ALB ARN (from cluster or ALB stack)
httpsListenerArn: existingAlbHttpsListenerArn, // HTTPS listener ARN (if certificate provided)
httpListenerArn: existingAlbHttpListenerArn, // HTTP listener ARN (if no certificate or for redirect)
hostHeader,
pathPatterns,
priority,
stickiness: app.node.tryGetContext('stickiness') !== 'false',
stickinessDuration: app.node.tryGetContext('stickinessDuration')
? parseInt(app.node.tryGetContext('stickinessDuration'))
: undefined,
deregistrationDelay: app.node.tryGetContext('deregistrationDelay')
? parseInt(app.node.tryGetContext('deregistrationDelay'))
: undefined,
};
// Blue/Green DNS configuration (for bg-common ALB)
// Note: Both DNS records are created and point to the same ALB.
// Routing is controlled by listener rule priorities, not DNS changes.
// The isActive flag is informational - both records are always created.
const activeHostname = app.node.tryGetContext('activeHostname');
const inactiveHostname = app.node.tryGetContext('inactiveHostname');
const bgHostedZoneId = app.node.tryGetContext('bgHostedZoneId') || app.node.tryGetContext('hostedZoneId');
const isActive = app.node.tryGetContext('isActive') !== 'false'; // default true
if (activeHostname && inactiveHostname && bgHostedZoneId) {
routing.blueGreenDns = {
activeHostname,
inactiveHostname,
hostedZoneId: bgHostedZoneId,
isActive, // Informational only - both DNS records are always created
};
}
} else if (hostHeader || pathPatterns) {
// Cluster ALB mode (existing behavior)
// Validate required parameters for cluster ALB
// Note: externalListenerArn/internalListenerArn were declared above and may have been imported if clusterStackName was provided
if (!externalListenerArn && !internalListenerArn) {
throw new Error('When using cluster ALB, either externalListenerArn or internalListenerArn must be provided');
}
if (externalListenerArn && internalListenerArn) {
throw new Error(
'Cannot use both externalListenerArn and internalListenerArn. Choose either external OR internal, not both'
);
}
if (useExternal && useInternal) {
throw new Error(
'Cannot use both useExternalALB and useInternalALB. Choose either external OR internal, not both'
);
}
if (useExternal && !externalListenerArn) {
throw new Error('useExternalALB=true requires externalListenerArn to be provided');
}
if (useInternal && !internalListenerArn) {
throw new Error('useInternalALB=true requires internalListenerArn to be provided');
}
// Auto-detect if not explicitly set
const detectedExternal = externalListenerArn ? true : false;
const detectedInternal = internalListenerArn ? true : false;
// Use explicit flags if set, otherwise auto-detect from listener ARNs
routing = {
external: useExternal || (detectedExternal && !useInternal),
internal: useInternal || (detectedInternal && !useExternal),
hostHeader,
pathPatterns,
priority: priority ?? 100,
stickiness: app.node.tryGetContext('stickiness') === 'true',
stickinessDuration: app.node.tryGetContext('stickinessDuration')
? parseInt(app.node.tryGetContext('stickinessDuration'))
: undefined,
deregistrationDelay: app.node.tryGetContext('deregistrationDelay')
? parseInt(app.node.tryGetContext('deregistrationDelay'))
: undefined,
};
} else {
// No routing configured - this is valid for services without ALB (e.g., workers, pub/sub)
// No validation needed - service can run without ALB
}
// DNS (for cluster ALB mode)
const hostedZoneId = app.node.tryGetContext('hostedZoneId');
const zoneName = app.node.tryGetContext('zoneName');
const recordName = app.node.tryGetContext('recordName');
const dns: DnsConfig | undefined =
hostedZoneId && zoneName && recordName
? {
hostedZoneId,
zoneName,
recordName,
}
: undefined;
// Deployment
const circuitBreaker = app.node.tryGetContext('circuitBreaker') !== 'false';
const enableExecuteCommand = app.node.tryGetContext('enableExecuteCommand') !== 'false';
const deployment: DeploymentConfig = {
circuitBreaker,
enableExecuteCommand,
};
// Public subnet IDs (for individual ALB)
const publicSubnetIds = app.node.tryGetContext('publicSubnetIds')?.split(',');
// Auto-import logs bucket from cluster stack if not explicitly provided
// This matches the old pattern: Fn::ImportValue: !Sub "${ClusterName}-logs-s3-bucket"
// Cluster exports: ${clusterStackName}-logs-s3-bucket
let clusterLogsBucketName = app.node.tryGetContext('clusterLogsBucketName');
if (!clusterLogsBucketName && clusterStackName) {
clusterLogsBucketName = cdk.Fn.importValue(`${clusterStackName}-logs-s3-bucket`).toString();
}
// Note: clusterLogsBucketName is optional (only needed for bg-common ALB access logs)
// We don't fail if it's missing - it's only used if provided
return new SpicyEcsServiceStack(scope, id, {
...stackProps,
clusterName: clusterStackName,
vpcId: vpcId.toString(),
vpcCidrBlock,
numberOfAzs,
availabilityZones,
privateSubnetIds,
serviceName,
image,
containerPort,
cpu,
memory,
environment,
secrets,
desiredCount,
capacityProviderStrategy,
scaling,
healthCheckPath,
routing,
externalListenerArn,
internalListenerArn,
publicSubnetIds,
clusterLogsBucketName,
dns,
deployment,
serviceTags,
});
}
}

View File

@@ -0,0 +1,164 @@
import * as cdk from 'aws-cdk-lib/core';
import { Construct } from 'constructs';
import { SpicyVpc, SpicyVpcProps, SubnetCidrConfig } from '../constructs/spicy-vpc';
/**
* Props for SpicyVpcStack
* Extends SpicyVpcProps but makes tags optional since we can derive from context
*/
export interface SpicyVpcStackProps extends cdk.StackProps {
/**
* VPC configuration - all SpicyVpcProps except tags (derived from stack props)
*/
readonly vpcConfig?: Omit<SpicyVpcProps, 'tags'>;
/**
* Owner tag
*/
readonly ownerTag: string;
/**
* Product tag
*/
readonly productTag: string;
/**
* Component tag
*/
readonly componentTag: string;
/**
* Build identifier (e.g., git SHA)
*/
readonly buildTag?: string;
}
/**
* SpicyVpcStack - A CloudFormation stack that creates a Spicy VPC
*
* This stack can be deployed via CDK CLI or from Jenkins pipelines.
* Configuration can be provided via:
* 1. Direct props
* 2. CDK context (cdk deploy -c key=value)
* 3. Environment variables
*/
export class SpicyVpcStack extends cdk.Stack {
/** The VPC construct */
public readonly spicyVpc: SpicyVpc;
constructor(scope: Construct, id: string, props: SpicyVpcStackProps) {
super(scope, id, props);
// Create the VPC
this.spicyVpc = new SpicyVpc(this, 'SpicyVpc', {
...props.vpcConfig,
tags: {
owner: props.ownerTag,
product: props.productTag,
component: props.componentTag,
build: props.buildTag,
},
});
}
/**
* Create a SpicyVpcStack from CDK context values
* This is useful when deploying from Jenkins where context is passed via -c flags
*/
static fromContext(scope: Construct, id: string, stackProps?: cdk.StackProps): SpicyVpcStack {
const app = scope.node.root as cdk.App;
// Required context values
const ownerTag = app.node.tryGetContext('ownerTag') ?? 'SpicyTeam';
const productTag = app.node.tryGetContext('productTag') ?? 'spicy';
const componentTag = app.node.tryGetContext('componentTag') ?? 'spicy-VPC';
const buildTag = app.node.tryGetContext('build');
// Optional VPC configuration from context
const vpcCidr = app.node.tryGetContext('vpcCidr') as string | undefined;
const vpcTenancy = app.node.tryGetContext('vpcTenancy') as 'default' | 'dedicated' | undefined;
const numberOfAzs = app.node.tryGetContext('numberOfAzs') as string | undefined;
const availabilityZones = app.node.tryGetContext('availabilityZones') as string | undefined;
const createPrivateSubnets = app.node.tryGetContext('createPrivateSubnets') as string | undefined;
const createAdditionalPrivateSubnets = app.node.tryGetContext('createAdditionalPrivateSubnets') as
| string
| undefined;
const publicSubnetTag = app.node.tryGetContext('publicSubnetTag') as string | undefined;
const privateSubnetATag = app.node.tryGetContext('privateSubnetATag') as string | undefined;
const privateSubnetBTag = app.node.tryGetContext('privateSubnetBTag') as string | undefined;
// Parse subnet CIDRs from context
const subnetCidrs = SpicyVpcStack.parseSubnetCidrsFromContext(app);
// Build VPC config using a mutable object then cast
const vpcConfigBuilder: {
vpcCidr?: string;
vpcTenancy?: 'default' | 'dedicated';
numberOfAzs?: 2 | 3 | 4;
availabilityZones?: string[];
createPrivateSubnets?: boolean;
createAdditionalPrivateSubnets?: boolean;
publicSubnetTag?: string;
privateSubnetATag?: string;
privateSubnetBTag?: string;
subnetCidrs?: SpicyVpcProps['subnetCidrs'];
} = {};
if (vpcCidr) vpcConfigBuilder.vpcCidr = vpcCidr;
if (vpcTenancy) vpcConfigBuilder.vpcTenancy = vpcTenancy;
if (numberOfAzs) vpcConfigBuilder.numberOfAzs = parseInt(numberOfAzs, 10) as 2 | 3 | 4;
if (availabilityZones) {
vpcConfigBuilder.availabilityZones = availabilityZones.split(',');
}
if (createPrivateSubnets !== undefined) {
vpcConfigBuilder.createPrivateSubnets = createPrivateSubnets === 'true';
}
if (createAdditionalPrivateSubnets !== undefined) {
vpcConfigBuilder.createAdditionalPrivateSubnets = createAdditionalPrivateSubnets === 'true';
}
if (publicSubnetTag) vpcConfigBuilder.publicSubnetTag = publicSubnetTag;
if (privateSubnetATag) vpcConfigBuilder.privateSubnetATag = privateSubnetATag;
if (privateSubnetBTag) vpcConfigBuilder.privateSubnetBTag = privateSubnetBTag;
if (subnetCidrs && Object.keys(subnetCidrs).length > 0) {
vpcConfigBuilder.subnetCidrs = subnetCidrs;
}
return new SpicyVpcStack(scope, id, {
...stackProps,
ownerTag,
productTag,
componentTag,
buildTag,
vpcConfig: vpcConfigBuilder,
});
}
/**
* Parse subnet CIDR configurations from CDK context
*/
private static parseSubnetCidrsFromContext(app: cdk.App): SpicyVpcProps['subnetCidrs'] {
const cidrs: SpicyVpcProps['subnetCidrs'] = {};
const azLetters = ['a', 'b', 'c', 'd'] as const;
for (const az of azLetters) {
const azUpper = az.toUpperCase();
const publicCidr = app.node.tryGetContext(`publicSubnet${azUpper}Cidr`);
const private1Cidr = app.node.tryGetContext(`privateSubnet${azUpper}1Cidr`);
const private2Cidr = app.node.tryGetContext(`privateSubnet${azUpper}2Cidr`);
if (publicCidr || private1Cidr || private2Cidr) {
const config: SubnetCidrConfig = {
publicCidr: publicCidr ?? '',
private1Cidr: private1Cidr ?? '',
};
if (private2Cidr) {
config.private2Cidr = private2Cidr;
}
cidrs[az] = config;
}
}
return cidrs;
}
}

29
resources/package.json Normal file
View File

@@ -0,0 +1,29 @@
{
"name": "spicy-automation",
"version": "0.1.0",
"bin": {
"spicy-automation": "bin/spicy-cdk.ts"
},
"scripts": {
"build": "tsc",
"watch": "tsc -w",
"test": "jest",
"cdk": "cdk",
"synth": "cdk synth",
"deploy": "cdk deploy",
"diff": "cdk diff"
},
"devDependencies": {
"@types/jest": "^30.0.0",
"@types/node": "^24.10.1",
"aws-cdk": "2.1033.0",
"jest": "^30.2.0",
"ts-jest": "^29.4.5",
"ts-node": "^10.9.2",
"typescript": "~5.9.3"
},
"dependencies": {
"aws-cdk-lib": "2.230.0",
"constructs": "^10.4.3"
}
}

3104
resources/pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

30
resources/tsconfig.json Normal file
View File

@@ -0,0 +1,30 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "CommonJS",
"lib": ["es2022"],
"declaration": true,
"strict": true,
"outDir": "./dist",
"rootDir": ".",
"noImplicitAny": true,
"strictNullChecks": true,
"noImplicitThis": true,
"alwaysStrict": true,
"noUnusedLocals": false,
"noUnusedParameters": false,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": false,
"inlineSourceMap": true,
"inlineSources": true,
"experimentalDecorators": true,
"strictPropertyInitialization": false,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"esModuleInterop": true,
"resolveJsonModule": true,
"typeRoots": ["./node_modules/@types"]
},
"include": ["bin/**/*", "lib/**/*", "test/**/*", "resources/bin/spicy-cdk.ts", "resources/index.ts"],
"exclude": ["node_modules", "cdk.out", "dist"]
}

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