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

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